mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-28 05:12:26 +00:00
* fix(security): detect Rust at import time + SSRF Python fallback (#225) RUST_AVAILABLE was hardcoded to True, so the exported flag never reflected reality and the pure-Python fallbacks it was meant to gate were unreachable. - _rust_bridge: compute RUST_AVAILABLE dynamically by probing the compiled extension once at import time. - security.ssrf: check_ssrf now falls back to the existing _check_ssrf_python implementation when the Rust extension is not built, instead of raising ImportError. The SSRF guard is security-critical and must never be silently skipped or crash just because Rust was not compiled. - tools.browser: drop the `except ImportError: pass` around the SSRF check, which previously disabled SSRF protection entirely on installs without the compiled backend (internal/metadata endpoints reachable). - tests: cover the Python fallback path (metadata IP, private IP, and public URL) with RUST_AVAILABLE patched False. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(security): make test_no_hostname backend-agnostic The cherry-picked #451 fix adds a pure-Python SSRF fallback, but test_no_hostname asserted the Rust-specific message "Invalid URL". The Python fallback returns "No hostname in URL" for the same input, so the SSRF suite failed on exactly the uncompiled-install path #451 targets (in CI the Rust extension is built, masking it). Assert the security behavior (URL blocked, non-None reason) and accept either backend's wording, so the suite passes on both the Rust and Python paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(tools): update browser SSRF test for non-bypassable check The #225 fix removes the `except ImportError: pass` that silently disabled the SSRF check in browser navigation. The existing test_execute_ssrf_module_missing asserted that very anti-pattern ("skip check and proceed"), so it failed once the swallow was removed. Replace it with tests that assert the SECURE behavior: the SSRF check runs unconditionally and is honored (a private-IP URL is blocked even when navigation would otherwise succeed), and a public URL still navigates. check_ssrf's pure-Python fallback means the import never fails anymore, so the old skip path no longer exists. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Rahul <therahulll56@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
934 lines
33 KiB
Python
934 lines
33 KiB
Python
"""Tests for browser automation tools."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
from unittest.mock import MagicMock, PropertyMock, patch
|
|
|
|
import pytest
|
|
|
|
from openjarvis.core.registry import ToolRegistry
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers — mock the _session before importing tools
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_mock_page():
|
|
"""Create a fully mocked Playwright page object."""
|
|
page = MagicMock()
|
|
page.title.return_value = "Test Page"
|
|
page.inner_text.return_value = "Hello World"
|
|
page.screenshot.return_value = b"\x89PNG\x00fake-screenshot-data"
|
|
|
|
response = MagicMock()
|
|
response.status = 200
|
|
page.goto.return_value = response
|
|
|
|
page.click.return_value = None
|
|
page.get_by_text.return_value = MagicMock()
|
|
page.fill.return_value = None
|
|
page.type.return_value = None
|
|
page.eval_on_selector_all.return_value = []
|
|
|
|
return page
|
|
|
|
|
|
def _make_mock_session(page=None):
|
|
"""Create a mocked _BrowserSession whose .page returns a mock page."""
|
|
if page is None:
|
|
page = _make_mock_page()
|
|
session = MagicMock()
|
|
type(session).page = PropertyMock(return_value=page)
|
|
return session
|
|
|
|
|
|
def _make_import_error_session():
|
|
"""Create a session whose .page raises ImportError."""
|
|
session = MagicMock()
|
|
type(session).page = PropertyMock(
|
|
side_effect=ImportError(
|
|
"playwright not installed. Install with: uv sync --extra browser"
|
|
)
|
|
)
|
|
return session
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestBrowserNavigateTool
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBrowserNavigateTool:
|
|
def test_spec_name_and_category(self):
|
|
from openjarvis.tools.browser import BrowserNavigateTool
|
|
|
|
tool = BrowserNavigateTool()
|
|
assert tool.spec.name == "browser_navigate"
|
|
assert tool.spec.category == "browser"
|
|
|
|
def test_spec_requires_url_parameter(self):
|
|
from openjarvis.tools.browser import BrowserNavigateTool
|
|
|
|
tool = BrowserNavigateTool()
|
|
assert "url" in tool.spec.parameters["properties"]
|
|
assert "url" in tool.spec.parameters["required"]
|
|
|
|
def test_spec_has_wait_for_parameter(self):
|
|
from openjarvis.tools.browser import BrowserNavigateTool
|
|
|
|
tool = BrowserNavigateTool()
|
|
assert "wait_for" in tool.spec.parameters["properties"]
|
|
|
|
def test_spec_required_capabilities(self):
|
|
from openjarvis.tools.browser import BrowserNavigateTool
|
|
|
|
tool = BrowserNavigateTool()
|
|
assert "network:fetch" in tool.spec.required_capabilities
|
|
|
|
def test_tool_id(self):
|
|
from openjarvis.tools.browser import BrowserNavigateTool
|
|
|
|
tool = BrowserNavigateTool()
|
|
assert tool.tool_id == "browser_navigate"
|
|
|
|
def test_execute_no_url(self):
|
|
from openjarvis.tools.browser import BrowserNavigateTool
|
|
|
|
tool = BrowserNavigateTool()
|
|
result = tool.execute(url="")
|
|
assert result.success is False
|
|
assert "No URL" in result.content
|
|
|
|
def test_execute_no_url_param(self):
|
|
from openjarvis.tools.browser import BrowserNavigateTool
|
|
|
|
tool = BrowserNavigateTool()
|
|
result = tool.execute()
|
|
assert result.success is False
|
|
assert "No URL" in result.content
|
|
|
|
def test_execute_playwright_not_installed(self):
|
|
from openjarvis.tools.browser import BrowserNavigateTool
|
|
|
|
session = _make_import_error_session()
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserNavigateTool()
|
|
result = tool.execute(url="https://example.com")
|
|
assert result.success is False
|
|
assert "playwright not installed" in result.content
|
|
|
|
def test_execute_ssrf_blocked(self):
|
|
from openjarvis.tools.browser import BrowserNavigateTool
|
|
|
|
mock_ssrf_module = MagicMock()
|
|
mock_ssrf_module.check_ssrf.return_value = (
|
|
"Blocked host: 169.254.169.254 (cloud metadata endpoint)"
|
|
)
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{"openjarvis.security.ssrf": mock_ssrf_module},
|
|
):
|
|
tool = BrowserNavigateTool()
|
|
result = tool.execute(url="http://169.254.169.254/latest/meta-data/")
|
|
|
|
assert result.success is False
|
|
assert "SSRF blocked" in result.content
|
|
|
|
def test_execute_ssrf_check_is_not_bypassable(self):
|
|
"""The SSRF check must never be silently skipped (#225/#467).
|
|
|
|
Previously browser navigation wrapped the SSRF check in
|
|
``except ImportError: pass``, so an environment where the check
|
|
couldn't be imported silently disabled SSRF protection. ``check_ssrf``
|
|
now has a pure-Python fallback and is always reachable, so the import
|
|
no longer fails — and the guard must not be bypassed. Here a real
|
|
blocked URL is rejected even though navigation would otherwise
|
|
succeed, proving the check runs unconditionally.
|
|
"""
|
|
from openjarvis.tools.browser import BrowserNavigateTool
|
|
|
|
mock_ssrf_module = MagicMock()
|
|
mock_ssrf_module.check_ssrf.return_value = (
|
|
"URL resolves to private IP: 127.0.0.1"
|
|
)
|
|
page = _make_mock_page()
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{"openjarvis.security.ssrf": mock_ssrf_module},
|
|
):
|
|
tool = BrowserNavigateTool()
|
|
result = tool.execute(url="http://127.0.0.1:8080/admin")
|
|
|
|
# Blocked — the check ran and was honored, not skipped.
|
|
assert result.success is False
|
|
assert "SSRF blocked" in result.content
|
|
|
|
def test_execute_allows_public_url(self):
|
|
"""The always-on SSRF check must not break legitimate navigation."""
|
|
from openjarvis.tools.browser import BrowserNavigateTool
|
|
|
|
mock_ssrf_module = MagicMock()
|
|
mock_ssrf_module.check_ssrf.return_value = None # public URL, allowed
|
|
page = _make_mock_page()
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{"openjarvis.security.ssrf": mock_ssrf_module},
|
|
):
|
|
tool = BrowserNavigateTool()
|
|
result = tool.execute(url="https://example.com")
|
|
|
|
assert result.success is True
|
|
|
|
def test_execute_success(self):
|
|
from openjarvis.tools.browser import BrowserNavigateTool
|
|
|
|
page = _make_mock_page()
|
|
page.title.return_value = "Example Domain"
|
|
page.inner_text.return_value = "Example page content"
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserNavigateTool()
|
|
result = tool.execute(url="https://example.com")
|
|
|
|
assert result.success is True
|
|
assert "Example Domain" in result.content
|
|
assert "Example page content" in result.content
|
|
assert result.metadata["url"] == "https://example.com"
|
|
assert result.metadata["title"] == "Example Domain"
|
|
assert result.metadata["status"] == 200
|
|
|
|
def test_execute_with_wait_for(self):
|
|
from openjarvis.tools.browser import BrowserNavigateTool
|
|
|
|
page = _make_mock_page()
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserNavigateTool()
|
|
tool.execute(url="https://example.com", wait_for="networkidle")
|
|
|
|
page.goto.assert_called_once_with(
|
|
"https://example.com", wait_until="networkidle"
|
|
)
|
|
|
|
def test_execute_invalid_wait_for_defaults_to_load(self):
|
|
from openjarvis.tools.browser import BrowserNavigateTool
|
|
|
|
page = _make_mock_page()
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserNavigateTool()
|
|
tool.execute(url="https://example.com", wait_for="invalid")
|
|
|
|
page.goto.assert_called_once_with("https://example.com", wait_until="load")
|
|
|
|
def test_execute_content_truncation(self):
|
|
from openjarvis.tools.browser import BrowserNavigateTool
|
|
|
|
page = _make_mock_page()
|
|
page.inner_text.return_value = "x" * 6000
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserNavigateTool()
|
|
result = tool.execute(url="https://example.com")
|
|
|
|
assert result.success is True
|
|
assert "[Content truncated]" in result.content
|
|
|
|
def test_execute_navigation_error(self):
|
|
from openjarvis.tools.browser import BrowserNavigateTool
|
|
|
|
page = _make_mock_page()
|
|
page.goto.side_effect = Exception("net::ERR_NAME_NOT_RESOLVED")
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserNavigateTool()
|
|
result = tool.execute(url="https://nonexistent.example")
|
|
|
|
assert result.success is False
|
|
assert "Navigation error" in result.content
|
|
|
|
def test_to_openai_function(self):
|
|
from openjarvis.tools.browser import BrowserNavigateTool
|
|
|
|
tool = BrowserNavigateTool()
|
|
fn = tool.to_openai_function()
|
|
assert fn["type"] == "function"
|
|
assert fn["function"]["name"] == "browser_navigate"
|
|
assert "url" in fn["function"]["parameters"]["properties"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestBrowserClickTool
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBrowserClickTool:
|
|
def test_spec_name_and_category(self):
|
|
from openjarvis.tools.browser import BrowserClickTool
|
|
|
|
tool = BrowserClickTool()
|
|
assert tool.spec.name == "browser_click"
|
|
assert tool.spec.category == "browser"
|
|
|
|
def test_spec_requires_selector(self):
|
|
from openjarvis.tools.browser import BrowserClickTool
|
|
|
|
tool = BrowserClickTool()
|
|
assert "selector" in tool.spec.parameters["properties"]
|
|
assert "selector" in tool.spec.parameters["required"]
|
|
|
|
def test_spec_has_by_text_parameter(self):
|
|
from openjarvis.tools.browser import BrowserClickTool
|
|
|
|
tool = BrowserClickTool()
|
|
assert "by_text" in tool.spec.parameters["properties"]
|
|
|
|
def test_tool_id(self):
|
|
from openjarvis.tools.browser import BrowserClickTool
|
|
|
|
tool = BrowserClickTool()
|
|
assert tool.tool_id == "browser_click"
|
|
|
|
def test_execute_no_selector(self):
|
|
from openjarvis.tools.browser import BrowserClickTool
|
|
|
|
tool = BrowserClickTool()
|
|
result = tool.execute(selector="")
|
|
assert result.success is False
|
|
assert "No selector" in result.content
|
|
|
|
def test_execute_no_selector_param(self):
|
|
from openjarvis.tools.browser import BrowserClickTool
|
|
|
|
tool = BrowserClickTool()
|
|
result = tool.execute()
|
|
assert result.success is False
|
|
assert "No selector" in result.content
|
|
|
|
def test_execute_playwright_not_installed(self):
|
|
from openjarvis.tools.browser import BrowserClickTool
|
|
|
|
session = _make_import_error_session()
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserClickTool()
|
|
result = tool.execute(selector="#btn")
|
|
assert result.success is False
|
|
assert "playwright not installed" in result.content
|
|
|
|
def test_execute_click_by_css(self):
|
|
from openjarvis.tools.browser import BrowserClickTool
|
|
|
|
page = _make_mock_page()
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserClickTool()
|
|
result = tool.execute(selector="#submit-btn")
|
|
|
|
assert result.success is True
|
|
assert "Clicked element" in result.content
|
|
page.click.assert_called_once_with("#submit-btn")
|
|
assert result.metadata["selector"] == "#submit-btn"
|
|
assert result.metadata["by_text"] is False
|
|
|
|
def test_execute_click_by_text(self):
|
|
from openjarvis.tools.browser import BrowserClickTool
|
|
|
|
page = _make_mock_page()
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserClickTool()
|
|
result = tool.execute(selector="Sign In", by_text=True)
|
|
|
|
assert result.success is True
|
|
page.get_by_text.assert_called_once_with("Sign In")
|
|
page.get_by_text.return_value.click.assert_called_once()
|
|
assert result.metadata["by_text"] is True
|
|
|
|
def test_execute_click_error(self):
|
|
from openjarvis.tools.browser import BrowserClickTool
|
|
|
|
page = _make_mock_page()
|
|
page.click.side_effect = Exception("Element not found")
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserClickTool()
|
|
result = tool.execute(selector="#nonexistent")
|
|
|
|
assert result.success is False
|
|
assert "Click error" in result.content
|
|
|
|
def test_to_openai_function(self):
|
|
from openjarvis.tools.browser import BrowserClickTool
|
|
|
|
tool = BrowserClickTool()
|
|
fn = tool.to_openai_function()
|
|
assert fn["type"] == "function"
|
|
assert fn["function"]["name"] == "browser_click"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestBrowserTypeTool
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBrowserTypeTool:
|
|
def test_spec_name_and_category(self):
|
|
from openjarvis.tools.browser import BrowserTypeTool
|
|
|
|
tool = BrowserTypeTool()
|
|
assert tool.spec.name == "browser_type"
|
|
assert tool.spec.category == "browser"
|
|
|
|
def test_spec_requires_selector_and_text(self):
|
|
from openjarvis.tools.browser import BrowserTypeTool
|
|
|
|
tool = BrowserTypeTool()
|
|
assert "selector" in tool.spec.parameters["properties"]
|
|
assert "text" in tool.spec.parameters["properties"]
|
|
assert "selector" in tool.spec.parameters["required"]
|
|
assert "text" in tool.spec.parameters["required"]
|
|
|
|
def test_spec_has_clear_parameter(self):
|
|
from openjarvis.tools.browser import BrowserTypeTool
|
|
|
|
tool = BrowserTypeTool()
|
|
assert "clear" in tool.spec.parameters["properties"]
|
|
|
|
def test_tool_id(self):
|
|
from openjarvis.tools.browser import BrowserTypeTool
|
|
|
|
tool = BrowserTypeTool()
|
|
assert tool.tool_id == "browser_type"
|
|
|
|
def test_execute_no_selector(self):
|
|
from openjarvis.tools.browser import BrowserTypeTool
|
|
|
|
tool = BrowserTypeTool()
|
|
result = tool.execute(selector="", text="hello")
|
|
assert result.success is False
|
|
assert "No selector" in result.content
|
|
|
|
def test_execute_no_text(self):
|
|
from openjarvis.tools.browser import BrowserTypeTool
|
|
|
|
tool = BrowserTypeTool()
|
|
result = tool.execute(selector="#input", text="")
|
|
assert result.success is False
|
|
assert "No text" in result.content
|
|
|
|
def test_execute_no_params(self):
|
|
from openjarvis.tools.browser import BrowserTypeTool
|
|
|
|
tool = BrowserTypeTool()
|
|
result = tool.execute()
|
|
assert result.success is False
|
|
|
|
def test_execute_playwright_not_installed(self):
|
|
from openjarvis.tools.browser import BrowserTypeTool
|
|
|
|
session = _make_import_error_session()
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserTypeTool()
|
|
result = tool.execute(selector="#input", text="hello")
|
|
assert result.success is False
|
|
assert "playwright not installed" in result.content
|
|
|
|
def test_execute_fill_clear_true(self):
|
|
from openjarvis.tools.browser import BrowserTypeTool
|
|
|
|
page = _make_mock_page()
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserTypeTool()
|
|
result = tool.execute(selector="#search", text="query", clear=True)
|
|
|
|
assert result.success is True
|
|
page.fill.assert_called_once_with("#search", "query")
|
|
page.type.assert_not_called()
|
|
assert result.metadata["selector"] == "#search"
|
|
|
|
def test_execute_fill_default_clear(self):
|
|
"""Default clear=True should use page.fill()."""
|
|
from openjarvis.tools.browser import BrowserTypeTool
|
|
|
|
page = _make_mock_page()
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserTypeTool()
|
|
result = tool.execute(selector="#search", text="query")
|
|
|
|
assert result.success is True
|
|
page.fill.assert_called_once_with("#search", "query")
|
|
|
|
def test_execute_type_clear_false(self):
|
|
from openjarvis.tools.browser import BrowserTypeTool
|
|
|
|
page = _make_mock_page()
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserTypeTool()
|
|
result = tool.execute(selector="#search", text="query", clear=False)
|
|
|
|
assert result.success is True
|
|
page.type.assert_called_once_with("#search", "query")
|
|
page.fill.assert_not_called()
|
|
|
|
def test_execute_type_error(self):
|
|
from openjarvis.tools.browser import BrowserTypeTool
|
|
|
|
page = _make_mock_page()
|
|
page.fill.side_effect = Exception("Element not editable")
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserTypeTool()
|
|
result = tool.execute(selector="#readonly", text="hello")
|
|
|
|
assert result.success is False
|
|
assert "Type error" in result.content
|
|
|
|
def test_to_openai_function(self):
|
|
from openjarvis.tools.browser import BrowserTypeTool
|
|
|
|
tool = BrowserTypeTool()
|
|
fn = tool.to_openai_function()
|
|
assert fn["type"] == "function"
|
|
assert fn["function"]["name"] == "browser_type"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestBrowserScreenshotTool
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBrowserScreenshotTool:
|
|
def test_spec_name_and_category(self):
|
|
from openjarvis.tools.browser import BrowserScreenshotTool
|
|
|
|
tool = BrowserScreenshotTool()
|
|
assert tool.spec.name == "browser_screenshot"
|
|
assert tool.spec.category == "browser"
|
|
|
|
def test_spec_has_path_and_full_page(self):
|
|
from openjarvis.tools.browser import BrowserScreenshotTool
|
|
|
|
tool = BrowserScreenshotTool()
|
|
props = tool.spec.parameters["properties"]
|
|
assert "path" in props
|
|
assert "full_page" in props
|
|
|
|
def test_spec_no_required_params(self):
|
|
from openjarvis.tools.browser import BrowserScreenshotTool
|
|
|
|
tool = BrowserScreenshotTool()
|
|
assert "required" not in tool.spec.parameters
|
|
|
|
def test_tool_id(self):
|
|
from openjarvis.tools.browser import BrowserScreenshotTool
|
|
|
|
tool = BrowserScreenshotTool()
|
|
assert tool.tool_id == "browser_screenshot"
|
|
|
|
def test_execute_playwright_not_installed(self):
|
|
from openjarvis.tools.browser import BrowserScreenshotTool
|
|
|
|
session = _make_import_error_session()
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserScreenshotTool()
|
|
result = tool.execute()
|
|
assert result.success is False
|
|
assert "playwright not installed" in result.content
|
|
|
|
def test_execute_screenshot_basic(self):
|
|
from openjarvis.tools.browser import BrowserScreenshotTool
|
|
|
|
fake_png = b"\x89PNG\x00screenshot-data"
|
|
page = _make_mock_page()
|
|
page.screenshot.return_value = fake_png
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserScreenshotTool()
|
|
result = tool.execute()
|
|
|
|
assert result.success is True
|
|
assert "Screenshot taken" in result.content
|
|
assert "full page" not in result.content
|
|
expected_b64 = base64.b64encode(fake_png).decode("utf-8")
|
|
assert result.metadata["screenshot_base64"] == expected_b64
|
|
page.screenshot.assert_called_once_with(full_page=False)
|
|
|
|
def test_execute_screenshot_full_page(self):
|
|
from openjarvis.tools.browser import BrowserScreenshotTool
|
|
|
|
page = _make_mock_page()
|
|
page.screenshot.return_value = b"png-data"
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserScreenshotTool()
|
|
result = tool.execute(full_page=True)
|
|
|
|
assert result.success is True
|
|
assert "full page" in result.content
|
|
page.screenshot.assert_called_once_with(full_page=True)
|
|
|
|
def test_execute_screenshot_save_to_file(self, tmp_path):
|
|
from openjarvis.tools.browser import BrowserScreenshotTool
|
|
|
|
fake_png = b"\x89PNGscreenshot"
|
|
page = _make_mock_page()
|
|
page.screenshot.return_value = fake_png
|
|
session = _make_mock_session(page)
|
|
|
|
save_path = str(tmp_path / "test_screenshot.png")
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserScreenshotTool()
|
|
result = tool.execute(path=save_path)
|
|
|
|
assert result.success is True
|
|
assert save_path in result.content
|
|
# Verify file was written
|
|
with open(save_path, "rb") as f:
|
|
assert f.read() == fake_png
|
|
|
|
def test_execute_screenshot_error(self):
|
|
from openjarvis.tools.browser import BrowserScreenshotTool
|
|
|
|
page = _make_mock_page()
|
|
page.screenshot.side_effect = Exception("Browser crashed")
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserScreenshotTool()
|
|
result = tool.execute()
|
|
|
|
assert result.success is False
|
|
assert "Screenshot error" in result.content
|
|
|
|
def test_to_openai_function(self):
|
|
from openjarvis.tools.browser import BrowserScreenshotTool
|
|
|
|
tool = BrowserScreenshotTool()
|
|
fn = tool.to_openai_function()
|
|
assert fn["type"] == "function"
|
|
assert fn["function"]["name"] == "browser_screenshot"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestBrowserExtractTool
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBrowserExtractTool:
|
|
def test_spec_name_and_category(self):
|
|
from openjarvis.tools.browser import BrowserExtractTool
|
|
|
|
tool = BrowserExtractTool()
|
|
assert tool.spec.name == "browser_extract"
|
|
assert tool.spec.category == "browser"
|
|
|
|
def test_spec_has_selector_and_extract_type(self):
|
|
from openjarvis.tools.browser import BrowserExtractTool
|
|
|
|
tool = BrowserExtractTool()
|
|
props = tool.spec.parameters["properties"]
|
|
assert "selector" in props
|
|
assert "extract_type" in props
|
|
|
|
def test_tool_id(self):
|
|
from openjarvis.tools.browser import BrowserExtractTool
|
|
|
|
tool = BrowserExtractTool()
|
|
assert tool.tool_id == "browser_extract"
|
|
|
|
def test_execute_playwright_not_installed(self):
|
|
from openjarvis.tools.browser import BrowserExtractTool
|
|
|
|
session = _make_import_error_session()
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserExtractTool()
|
|
result = tool.execute()
|
|
assert result.success is False
|
|
assert "playwright not installed" in result.content
|
|
|
|
def test_execute_invalid_extract_type(self):
|
|
from openjarvis.tools.browser import BrowserExtractTool
|
|
|
|
tool = BrowserExtractTool()
|
|
result = tool.execute(extract_type="images")
|
|
assert result.success is False
|
|
assert "Invalid extract_type" in result.content
|
|
|
|
def test_execute_extract_text(self):
|
|
from openjarvis.tools.browser import BrowserExtractTool
|
|
|
|
page = _make_mock_page()
|
|
page.inner_text.return_value = "Page text content here"
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserExtractTool()
|
|
result = tool.execute(extract_type="text")
|
|
|
|
assert result.success is True
|
|
assert result.content == "Page text content here"
|
|
page.inner_text.assert_called_with("body")
|
|
assert result.metadata["extract_type"] == "text"
|
|
|
|
def test_execute_extract_text_custom_selector(self):
|
|
from openjarvis.tools.browser import BrowserExtractTool
|
|
|
|
page = _make_mock_page()
|
|
page.inner_text.return_value = "Article content"
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserExtractTool()
|
|
result = tool.execute(selector="#article", extract_type="text")
|
|
|
|
assert result.success is True
|
|
page.inner_text.assert_called_with("#article")
|
|
assert result.metadata["selector"] == "#article"
|
|
|
|
def test_execute_extract_text_default(self):
|
|
"""Default extract_type should be 'text'."""
|
|
from openjarvis.tools.browser import BrowserExtractTool
|
|
|
|
page = _make_mock_page()
|
|
page.inner_text.return_value = "Default text"
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserExtractTool()
|
|
result = tool.execute()
|
|
|
|
assert result.success is True
|
|
assert result.content == "Default text"
|
|
|
|
def test_execute_extract_text_truncation(self):
|
|
from openjarvis.tools.browser import BrowserExtractTool
|
|
|
|
page = _make_mock_page()
|
|
page.inner_text.return_value = "a" * 12000
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserExtractTool()
|
|
result = tool.execute(extract_type="text")
|
|
|
|
assert result.success is True
|
|
assert "[Content truncated]" in result.content
|
|
# Content should be truncated at 10000 + truncation notice
|
|
assert len(result.content) < 11000
|
|
|
|
def test_execute_extract_links(self):
|
|
from openjarvis.tools.browser import BrowserExtractTool
|
|
|
|
page = _make_mock_page()
|
|
page.eval_on_selector_all.return_value = [
|
|
{"href": "https://example.com/page1", "text": "Page 1"},
|
|
{"href": "https://example.com/page2", "text": "Page 2"},
|
|
]
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserExtractTool()
|
|
result = tool.execute(extract_type="links")
|
|
|
|
assert result.success is True
|
|
assert "[Page 1](https://example.com/page1)" in result.content
|
|
assert "[Page 2](https://example.com/page2)" in result.content
|
|
assert result.metadata["num_links"] == 2
|
|
|
|
def test_execute_extract_links_empty(self):
|
|
from openjarvis.tools.browser import BrowserExtractTool
|
|
|
|
page = _make_mock_page()
|
|
page.eval_on_selector_all.return_value = []
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserExtractTool()
|
|
result = tool.execute(extract_type="links")
|
|
|
|
assert result.success is True
|
|
assert result.content == "No links found."
|
|
assert result.metadata["num_links"] == 0
|
|
|
|
def test_execute_extract_tables(self):
|
|
from openjarvis.tools.browser import BrowserExtractTool
|
|
|
|
page = _make_mock_page()
|
|
page.eval_on_selector_all.return_value = [
|
|
"Name\tAge\nAlice\t30",
|
|
"City\tCountry\nNYC\tUSA",
|
|
]
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserExtractTool()
|
|
result = tool.execute(extract_type="tables")
|
|
|
|
assert result.success is True
|
|
assert "Alice" in result.content
|
|
assert "NYC" in result.content
|
|
assert result.metadata["num_tables"] == 2
|
|
|
|
def test_execute_extract_tables_empty(self):
|
|
from openjarvis.tools.browser import BrowserExtractTool
|
|
|
|
page = _make_mock_page()
|
|
page.eval_on_selector_all.return_value = []
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserExtractTool()
|
|
result = tool.execute(extract_type="tables")
|
|
|
|
assert result.success is True
|
|
assert result.content == "No tables found."
|
|
|
|
def test_execute_extract_error(self):
|
|
from openjarvis.tools.browser import BrowserExtractTool
|
|
|
|
page = _make_mock_page()
|
|
page.inner_text.side_effect = Exception("Selector not found")
|
|
session = _make_mock_session(page)
|
|
|
|
with patch("openjarvis.tools.browser._session", session):
|
|
tool = BrowserExtractTool()
|
|
result = tool.execute(selector="#missing", extract_type="text")
|
|
|
|
assert result.success is False
|
|
assert "Extract error" in result.content
|
|
|
|
def test_to_openai_function(self):
|
|
from openjarvis.tools.browser import BrowserExtractTool
|
|
|
|
tool = BrowserExtractTool()
|
|
fn = tool.to_openai_function()
|
|
assert fn["type"] == "function"
|
|
assert fn["function"]["name"] == "browser_extract"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestBrowserSession
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBrowserSession:
|
|
def test_session_close_resets_state(self):
|
|
from openjarvis.tools.browser import _BrowserSession
|
|
|
|
session = _BrowserSession()
|
|
session._playwright = MagicMock()
|
|
session._browser = MagicMock()
|
|
session._page = MagicMock()
|
|
|
|
session.close()
|
|
|
|
assert session._playwright is None
|
|
assert session._browser is None
|
|
assert session._page is None
|
|
|
|
def test_session_close_noop_when_not_initialized(self):
|
|
from openjarvis.tools.browser import _BrowserSession
|
|
|
|
session = _BrowserSession()
|
|
# Should not raise
|
|
session.close()
|
|
assert session._playwright is None
|
|
|
|
def test_session_ensure_browser_import_error(self):
|
|
from openjarvis.tools.browser import _BrowserSession
|
|
|
|
session = _BrowserSession()
|
|
|
|
import builtins
|
|
|
|
original_import = builtins.__import__
|
|
|
|
def _mock_import(name, *args, **kwargs):
|
|
if "playwright" in name:
|
|
raise ImportError("No module named 'playwright'")
|
|
return original_import(name, *args, **kwargs)
|
|
|
|
with patch.object(builtins, "__import__", side_effect=_mock_import):
|
|
with pytest.raises(ImportError, match="playwright not installed"):
|
|
session._ensure_browser()
|
|
|
|
def test_session_page_reuses_existing(self):
|
|
from openjarvis.tools.browser import _BrowserSession
|
|
|
|
session = _BrowserSession()
|
|
mock_page = MagicMock()
|
|
session._page = mock_page
|
|
|
|
# _ensure_browser should not re-create if page exists
|
|
session._ensure_browser()
|
|
assert session._page is mock_page
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestRegistryIntegration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRegistryIntegration:
|
|
def test_all_tools_registered(self):
|
|
# Registration happens at import time via @ToolRegistry.register.
|
|
# Other test modules may clear the registry, so re-register if needed.
|
|
from openjarvis.tools.browser import (
|
|
BrowserClickTool,
|
|
BrowserExtractTool,
|
|
BrowserNavigateTool,
|
|
BrowserScreenshotTool,
|
|
BrowserTypeTool,
|
|
)
|
|
|
|
tools = {
|
|
"browser_navigate": BrowserNavigateTool,
|
|
"browser_click": BrowserClickTool,
|
|
"browser_type": BrowserTypeTool,
|
|
"browser_screenshot": BrowserScreenshotTool,
|
|
"browser_extract": BrowserExtractTool,
|
|
}
|
|
for key, cls in tools.items():
|
|
if not ToolRegistry.contains(key):
|
|
ToolRegistry.register_value(key, cls)
|
|
|
|
assert ToolRegistry.contains("browser_navigate")
|
|
assert ToolRegistry.contains("browser_click")
|
|
assert ToolRegistry.contains("browser_type")
|
|
assert ToolRegistry.contains("browser_screenshot")
|
|
assert ToolRegistry.contains("browser_extract")
|
|
|
|
def test_module_exports(self):
|
|
from openjarvis.tools.browser import __all__
|
|
|
|
assert "BrowserNavigateTool" in __all__
|
|
assert "BrowserClickTool" in __all__
|
|
assert "BrowserTypeTool" in __all__
|
|
assert "BrowserScreenshotTool" in __all__
|
|
assert "BrowserExtractTool" in __all__
|