diff --git a/.github/scripts/check_documented_examples.py b/.github/scripts/check_documented_examples.py index 7cc853dbc5..1d747b43f6 100755 --- a/.github/scripts/check_documented_examples.py +++ b/.github/scripts/check_documented_examples.py @@ -15,6 +15,11 @@ from pathlib import Path +EXEMPT_EXAMPLES = { + "examples/01_standalone_sdk/31_windows.py", +} + + def find_documented_examples(docs_path: Path) -> set[str]: """ Find all example file references in the docs repository. @@ -149,6 +154,13 @@ def main() -> None: agent_examples = find_agent_sdk_examples(agent_sdk_root) print(f" Found {len(agent_examples)} example file(s)") + exempt_present = agent_examples & EXEMPT_EXAMPLES + if exempt_present: + print(" Exempting the following example(s) from documentation check:") + for example in sorted(exempt_present): + print(f" - {example}") + agent_examples -= exempt_present + # Find all documented examples in docs print("\nšŸ“„ Scanning docs repository...") documented_examples = find_documented_examples(docs_path) diff --git a/.github/workflows/run-examples.yml b/.github/workflows/run-examples.yml index 4e651aba4f..2d8077ed4e 100644 --- a/.github/workflows/run-examples.yml +++ b/.github/workflows/run-examples.yml @@ -20,15 +20,19 @@ permissions: jobs: test-examples: - if: github.event.label.name == 'test-examples' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' + needs: test-examples-windows + if: ${{ always() && (github.event.label.name == 'test-examples' || contains(fromJson('["workflow_dispatch","schedule"]'), github.event_name)) + }} runs-on: blacksmith-2vcpu-ubuntu-2404 timeout-minutes: 60 steps: - name: Wait for agent server to finish build - if: github.event_name == 'pull_request' + if: > + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository uses: lewagon/wait-on-check-action@v1.4.1 with: - ref: ${{ github.event.pull_request.head.ref }} + ref: ${{ github.event.pull_request.head.sha }} check-name: Build & Push (python-amd64) repo-token: ${{ secrets.GITHUB_TOKEN }} wait-interval: 10 @@ -49,6 +53,13 @@ jobs: with: node-version: '22' + - name: Download Windows example results + if: needs.test-examples-windows.result == 'success' + uses: actions/download-artifact@v4 + with: + name: examples-results-windows + path: .windows-example-results + - name: Install dependencies run: uv sync --frozen --group dev @@ -129,7 +140,13 @@ jobs: uv run pytest tests/examples/test_examples.py \ --run-examples \ --examples-results-dir "$RESULTS_DIR" \ - -n 4 || EXIT_CODE=$? + -n 4 \ + -k "not 31_windows.py" \ + || EXIT_CODE=$? + + if [ -d ".windows-example-results" ]; then + cp .windows-example-results/*.json "$RESULTS_DIR"/ || true + fi TIMESTAMP="$(date -u '+%Y-%m-%d %H:%M:%S UTC')" WORKFLOW_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" @@ -178,3 +195,60 @@ jobs: **Workflow Run:** ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} ${{ steps.read_report.outputs.report }} + + test-examples-windows: + if: github.event.label.name == 'test-examples' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' + runs-on: windows-latest + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.ref || github.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install dependencies + run: uv sync --frozen --group dev + + - name: Run Windows example + shell: bash + env: + LLM_API_KEY: ${{ secrets.LLM_API_KEY }} + LLM_MODEL: litellm_proxy/claude-haiku-4-5-20251001 + LLM_BASE_URL: https://llm-proxy.app.all-hands.dev + RUNTIME_API_KEY: ${{ secrets.RUNTIME_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO_OWNER: ${{ github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + GITHUB_SHA: ${{ github.event.pull_request.head.sha }} + run: | + RESULTS_DIR=".example-test-results" + rm -rf "$RESULTS_DIR" + mkdir -p "$RESULTS_DIR" + + uv run pytest tests/examples/test_examples.py \ + --run-examples \ + --examples-results-dir "$RESULTS_DIR" \ + -n auto \ + -k "31_windows.py" + + - name: Upload Windows example results + if: always() + uses: actions/upload-artifact@v4 + with: + name: examples-results-windows + path: .example-test-results + include-hidden-files: true + if-no-files-found: ignore + diff --git a/examples/01_standalone_sdk/30_tom_agent.py b/examples/01_standalone_sdk/30_tom_agent.py index 5794f98616..d6664780d4 100644 --- a/examples/01_standalone_sdk/30_tom_agent.py +++ b/examples/01_standalone_sdk/30_tom_agent.py @@ -90,6 +90,10 @@ print("Tom agent consultation example completed!") print("=" * 80) +# Report cost +cost = llm.metrics.accumulated_cost +print(f"EXAMPLE_COST: {cost}") + # Optional: Index this conversation for Tom's user modeling # This builds user preferences and patterns from conversation history diff --git a/examples/01_standalone_sdk/31_windows.py b/examples/01_standalone_sdk/31_windows.py new file mode 100644 index 0000000000..4c9c90b8d7 --- /dev/null +++ b/examples/01_standalone_sdk/31_windows.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import os +import sys + +from pydantic import SecretStr + +from openhands.sdk import ( + LLM, + Agent, + Conversation, + Event, + LLMConvertibleEvent, + get_logger, +) +from openhands.sdk.tool import Tool +from openhands.tools.browser_use import BrowserToolSet +from openhands.tools.file_editor import FileEditorTool + + +logger = get_logger(__name__) + +api_key = os.getenv("LLM_API_KEY") +if api_key is None: + raise RuntimeError("LLM_API_KEY environment variable is not set.") + +model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") +base_url = os.getenv("LLM_BASE_URL") + +llm = LLM( + usage_id="agent", + model=model, + base_url=base_url, + api_key=SecretStr(api_key), +) + +cwd = os.getcwd() +tools = [ + Tool(name=FileEditorTool.name), + Tool(name=BrowserToolSet.name), +] + +agent = Agent(llm=llm, tools=tools) + +llm_messages = [] + + +def _safe_preview(text: str, limit: int = 200) -> str: + truncated = text[:limit] + encoding = getattr(sys.stdout, "encoding", None) or "utf-8" + return truncated.encode(encoding, errors="replace").decode(encoding) + + +def conversation_callback(event: Event) -> None: + if isinstance(event, LLMConvertibleEvent): + llm_messages.append(event.to_llm_message()) + + +conversation = Conversation( + agent=agent, + callbacks=[conversation_callback], + workspace=cwd, + visualizer=None, +) + +try: + conversation.send_message( + "Open https://openhands.dev/blog in the browser and summarize the key points " + "from the latest post." + ) + conversation.run() +finally: + conversation.close() + +print("=" * 100) +print("Conversation finished. Got the following LLM messages:") +for i, message in enumerate(llm_messages): + preview = _safe_preview(str(message)) + print(f"Message {i}: {preview}") + +cost = llm.metrics.accumulated_cost +print(f"EXAMPLE_COST: {cost}") diff --git a/openhands-tools/openhands/tools/browser_use/definition.py b/openhands-tools/openhands/tools/browser_use/definition.py index f73482a230..a786713589 100644 --- a/openhands-tools/openhands/tools/browser_use/definition.py +++ b/openhands-tools/openhands/tools/browser_use/definition.py @@ -560,12 +560,26 @@ def create( ) -> list[ToolDefinition[BrowserAction, BrowserObservation]]: # Import executor only when actually needed to # avoid hanging during module import - from openhands.tools.browser_use.impl import BrowserToolExecutor + import sys + + # Use Windows-specific executor on Windows systems + if sys.platform == "win32": + from openhands.tools.browser_use.impl_windows import ( + WindowsBrowserToolExecutor, + ) + + executor = WindowsBrowserToolExecutor( + full_output_save_dir=conv_state.env_observation_persistence_dir, + **executor_config, + ) + else: + from openhands.tools.browser_use.impl import BrowserToolExecutor + + executor = BrowserToolExecutor( + full_output_save_dir=conv_state.env_observation_persistence_dir, + **executor_config, + ) - executor = BrowserToolExecutor( - full_output_save_dir=conv_state.env_observation_persistence_dir, - **executor_config, - ) # Each tool.create() returns a Sequence[Self], so we flatten the results tools: list[ToolDefinition[BrowserAction, BrowserObservation]] = [] for tool_class in [ diff --git a/openhands-tools/openhands/tools/browser_use/impl.py b/openhands-tools/openhands/tools/browser_use/impl.py index 4f5f6c146a..f7537d1fc6 100644 --- a/openhands-tools/openhands/tools/browser_use/impl.py +++ b/openhands-tools/openhands/tools/browser_use/impl.py @@ -29,38 +29,6 @@ logger = get_logger(__name__) -def _check_chromium_available() -> str | None: - """Check if a Chromium/Chrome binary is available in PATH.""" - for binary in ("chromium", "chromium-browser", "google-chrome", "chrome"): - if path := shutil.which(binary): - return path - - # Check Playwright-installed Chromium - playwright_cache_candidates = [ - Path.home() / ".cache" / "ms-playwright", - Path.home() / "Library" / "Caches" / "ms-playwright", - ] - for playwright_cache in playwright_cache_candidates: - if playwright_cache.exists(): - chromium_dirs = list(playwright_cache.glob("chromium-*")) - for chromium_dir in chromium_dirs: - # Check platform-specific paths - possible_paths = [ - chromium_dir / "chrome-linux" / "chrome", # Linux - chromium_dir - / "chrome-mac" - / "Chromium.app" - / "Contents" - / "MacOS" - / "Chromium", # macOS - chromium_dir / "chrome-win" / "chrome.exe", # Windows - ] - for p in possible_paths: - if p.exists(): - return str(p) - return None - - def _install_chromium() -> bool: """Attempt to install Chromium via uvx playwright install.""" try: @@ -88,18 +56,9 @@ def _install_chromium() -> bool: return False -def _ensure_chromium_available() -> str: - """Ensure Chromium is available for browser operations. - - Raises: - Exception: If Chromium is not available - """ - if path := _check_chromium_available(): - logger.info(f"Chromium is available for browser operations at {path}") - return path - - # Chromium not available - provide clear installation instructions - error_msg = ( +def _get_chromium_error_message() -> str: + """Get the error message for when Chromium is not available.""" + return ( "Chromium is required for browser operations but is not installed.\n\n" "To install Chromium, run one of the following commands:\n" " 1. Using uvx (recommended): uvx playwright install chromium " @@ -111,7 +70,6 @@ def _ensure_chromium_available() -> str: " - Windows: winget install Chromium.Chromium\n\n" "After installation, restart your application to use the browser tool." ) - raise Exception(error_msg) class BrowserToolExecutor(ToolExecutor[BrowserAction, BrowserObservation]): @@ -123,6 +81,72 @@ class BrowserToolExecutor(ToolExecutor[BrowserAction, BrowserObservation]): _async_executor: AsyncExecutor _cleanup_initiated: bool + def _check_chromium_available(self) -> str | None: + """Check if a Chromium/Chrome binary is available. + + This method can be overridden by subclasses to provide + platform-specific detection logic. + + Returns: + Path to Chromium binary if found, None otherwise + """ + for binary in ("chromium", "chromium-browser", "google-chrome", "chrome"): + if path := shutil.which(binary): + return path + + # Check standard installation paths + standard_paths = [ + # Linux + "/usr/bin/google-chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + # macOS + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + ] + for install_path in standard_paths: + p = Path(install_path) + if p.exists(): + return str(p) + + # Check Playwright-installed Chromium + playwright_cache_candidates = [ + Path.home() / ".cache" / "ms-playwright", # Linux + Path.home() / "Library" / "Caches" / "ms-playwright", # macOS + ] + + for playwright_cache in playwright_cache_candidates: + if playwright_cache.exists(): + chromium_dirs = list(playwright_cache.glob("chromium-*")) + for chromium_dir in chromium_dirs: + # Check platform-specific paths + possible_paths = [ + chromium_dir / "chrome-linux" / "chrome", # Linux + chromium_dir + / "chrome-mac" + / "Chromium.app" + / "Contents" + / "MacOS" + / "Chromium", # macOS + ] + for p in possible_paths: + if p.exists(): + return str(p) + return None + + def _ensure_chromium_available(self) -> str: + """Ensure Chromium is available for browser operations. + + Raises: + Exception: If Chromium is not available + """ + if path := self._check_chromium_available(): + logger.info(f"Chromium is available for browser operations at {path}") + return path + + # Chromium not available - provide clear installation instructions + raise Exception(_get_chromium_error_message()) + def __init__( self, headless: bool = True, @@ -146,7 +170,7 @@ def __init__( def init_logic(): nonlocal headless - executable_path = _ensure_chromium_available() + executable_path = self._ensure_chromium_available() self._server = CustomBrowserUseServer( session_timeout_minutes=session_timeout_minutes, ) diff --git a/openhands-tools/openhands/tools/browser_use/impl_windows.py b/openhands-tools/openhands/tools/browser_use/impl_windows.py new file mode 100644 index 0000000000..c97939b452 --- /dev/null +++ b/openhands-tools/openhands/tools/browser_use/impl_windows.py @@ -0,0 +1,67 @@ +"""Windows-specific browser tool executor implementation.""" + +import os +import shutil +from pathlib import Path + +from openhands.tools.browser_use.impl import BrowserToolExecutor + + +class WindowsBrowserToolExecutor(BrowserToolExecutor): + """Windows-specific browser tool executor with Chromium detection. + + This class extends BrowserToolExecutor to provide Windows-specific + browser detection logic for Chrome and Edge installations. + """ + + def _check_chromium_available(self) -> str | None: + """Check if a Chromium/Chrome binary is available on Windows. + + Checks: + 1. Standard PATH binaries + 2. Common Windows installation paths for Chrome and Edge + 3. Playwright cache directory using LOCALAPPDATA + + Returns: + Path to Chromium binary if found, None otherwise + """ + # First check if chromium/chrome is in PATH + for binary in ("chromium", "chromium-browser", "google-chrome", "chrome"): + if path := shutil.which(binary): + return path + + # Check common Windows installation paths + # Short-circuit on first found browser for efficiency + env_vars = [ + ("PROGRAMFILES", "C:\\Program Files"), + ("PROGRAMFILES(X86)", "C:\\Program Files (x86)"), + ("LOCALAPPDATA", None), # Will skip if not set + ] + windows_browsers = [ + ("Google", "Chrome", "Application", "chrome.exe"), + ("Microsoft", "Edge", "Application", "msedge.exe"), + ] + + for env_var, default in env_vars: + base_path_str = os.environ.get(env_var, default) + if not base_path_str: + continue # Skip if env var not set and no default + + base_path = Path(base_path_str) + for vendor, browser, app_dir, executable in windows_browsers: + chrome_path = base_path / vendor / browser / app_dir / executable + if chrome_path.exists(): + return str(chrome_path) + + # Check Playwright-installed Chromium (Windows path) + localappdata = os.environ.get("LOCALAPPDATA", "") + if localappdata: + playwright_cache = Path(localappdata) / "ms-playwright" + if playwright_cache.exists(): + chromium_dirs = list(playwright_cache.glob("chromium-*")) + for chromium_dir in chromium_dirs: + chrome_exe = chromium_dir / "chrome-win" / "chrome.exe" + if chrome_exe.exists(): + return str(chrome_exe) + + return None diff --git a/tests/tools/browser_use/test_browser_cleanup.py b/tests/tools/browser_use/test_browser_cleanup.py index e090f16614..5e968d6e5e 100644 --- a/tests/tools/browser_use/test_browser_cleanup.py +++ b/tests/tools/browser_use/test_browser_cleanup.py @@ -17,8 +17,9 @@ def mock_executor(self): mock_async_executor = MagicMock() with ( - patch( - "openhands.tools.browser_use.impl._ensure_chromium_available", + patch.object( + BrowserToolExecutor, + "_ensure_chromium_available", return_value="/usr/bin/chromium", ), patch( diff --git a/tests/tools/browser_use/test_browser_initialization.py b/tests/tools/browser_use/test_browser_initialization.py index 543b1edd95..8d04e03cac 100644 --- a/tests/tools/browser_use/test_browser_initialization.py +++ b/tests/tools/browser_use/test_browser_initialization.py @@ -14,8 +14,9 @@ class TestBrowserInitialization: def test_initialization_timeout_handling(self): """Test that initialization timeout is handled properly.""" with ( - patch( - "openhands.tools.browser_use.impl._ensure_chromium_available", + patch.object( + BrowserToolExecutor, + "_ensure_chromium_available", return_value="/usr/bin/chromium", ), patch( @@ -35,8 +36,9 @@ def test_initialization_custom_timeout(self): mock_server = MagicMock() with ( - patch( - "openhands.tools.browser_use.impl._ensure_chromium_available", + patch.object( + BrowserToolExecutor, + "_ensure_chromium_available", return_value="/usr/bin/chromium", ), patch( @@ -56,8 +58,9 @@ def test_initialization_default_timeout(self): mock_server = MagicMock() with ( - patch( - "openhands.tools.browser_use.impl._ensure_chromium_available", + patch.object( + BrowserToolExecutor, + "_ensure_chromium_available", return_value="/usr/bin/chromium", ), patch( @@ -77,8 +80,9 @@ def test_initialization_config_passed_to_server(self): mock_server = MagicMock() with ( - patch( - "openhands.tools.browser_use.impl._ensure_chromium_available", + patch.object( + BrowserToolExecutor, + "_ensure_chromium_available", return_value="/usr/bin/chromium", ), patch( @@ -107,8 +111,9 @@ def test_initialization_server_creation_with_timeout(self): mock_server = MagicMock() with ( - patch( - "openhands.tools.browser_use.impl._ensure_chromium_available", + patch.object( + BrowserToolExecutor, + "_ensure_chromium_available", return_value="/usr/bin/chromium", ), patch( @@ -126,8 +131,9 @@ def test_initialization_async_executor_created(self): mock_async_executor = MagicMock() with ( - patch( - "openhands.tools.browser_use.impl._ensure_chromium_available", + patch.object( + BrowserToolExecutor, + "_ensure_chromium_available", return_value="/usr/bin/chromium", ), patch( @@ -146,8 +152,9 @@ def test_initialization_async_executor_created(self): def test_initialization_chromium_not_available(self): """Test initialization when Chromium is not available.""" - with patch( - "openhands.tools.browser_use.impl._ensure_chromium_available", + with patch.object( + BrowserToolExecutor, + "_ensure_chromium_available", side_effect=Exception("Chromium not found"), ): with pytest.raises(Exception) as exc_info: @@ -168,8 +175,9 @@ def test_call_method_delegates_to_async_executor(self): mock_async_executor.run_async.return_value = expected_result with ( - patch( - "openhands.tools.browser_use.impl._ensure_chromium_available", + patch.object( + BrowserToolExecutor, + "_ensure_chromium_available", return_value="/usr/bin/chromium", ), patch( @@ -196,8 +204,9 @@ def test_call_method_timeout_configuration(self): mock_action = MagicMock() with ( - patch( - "openhands.tools.browser_use.impl._ensure_chromium_available", + patch.object( + BrowserToolExecutor, + "_ensure_chromium_available", return_value="/usr/bin/chromium", ), patch( diff --git a/tests/tools/browser_use/test_chromium_detection.py b/tests/tools/browser_use/test_chromium_detection.py index 419ed731cc..e40a9525b8 100644 --- a/tests/tools/browser_use/test_chromium_detection.py +++ b/tests/tools/browser_use/test_chromium_detection.py @@ -6,11 +6,7 @@ import pytest -from openhands.tools.browser_use.impl import ( - _check_chromium_available, - _ensure_chromium_available, - _install_chromium, -) +from openhands.tools.browser_use.impl import BrowserToolExecutor, _install_chromium class TestChromiumDetection: @@ -18,12 +14,14 @@ class TestChromiumDetection: def test_check_chromium_available_system_binary(self): """Test detection of system-installed Chromium binary.""" + executor = BrowserToolExecutor.__new__(BrowserToolExecutor) with patch("shutil.which", return_value="/usr/bin/chromium"): - result = _check_chromium_available() + result = executor._check_chromium_available() assert result == "/usr/bin/chromium" def test_check_chromium_available_multiple_binaries(self): """Test that first available binary is returned.""" + executor = BrowserToolExecutor.__new__(BrowserToolExecutor) def mock_which(binary): if binary == "chromium": @@ -31,11 +29,12 @@ def mock_which(binary): return None with patch("shutil.which", side_effect=mock_which): - result = _check_chromium_available() + result = executor._check_chromium_available() assert result == "/usr/bin/chromium" def test_check_chromium_available_chrome_binary(self): """Test detection of Chrome binary when Chromium not available.""" + executor = BrowserToolExecutor.__new__(BrowserToolExecutor) def mock_which(binary): if binary == "google-chrome": @@ -43,11 +42,44 @@ def mock_which(binary): return None with patch("shutil.which", side_effect=mock_which): - result = _check_chromium_available() + result = executor._check_chromium_available() assert result == "/usr/bin/google-chrome" + def test_check_chromium_available_standard_linux_path(self): + """Test detection via standard Linux installation paths.""" + executor = BrowserToolExecutor.__new__(BrowserToolExecutor) + chrome_path = Path("/usr/bin/google-chrome") + + def mock_exists(self): + return str(self) == str(chrome_path) + + with ( + patch("shutil.which", return_value=None), + patch.object(Path, "exists", mock_exists), + ): + result = executor._check_chromium_available() + assert result == str(chrome_path) + + def test_check_chromium_available_standard_macos_path(self): + """Test detection via standard macOS installation paths.""" + executor = BrowserToolExecutor.__new__(BrowserToolExecutor) + chrome_path = Path( + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + ) + + def mock_exists(self): + return str(self) == str(chrome_path) + + with ( + patch("shutil.which", return_value=None), + patch.object(Path, "exists", mock_exists), + ): + result = executor._check_chromium_available() + assert result == str(chrome_path) + def test_check_chromium_available_playwright_linux(self): """Test detection of Playwright-installed Chromium on Linux.""" + executor = BrowserToolExecutor.__new__(BrowserToolExecutor) mock_cache_dir = Path("/home/user/.cache/ms-playwright") mock_chromium_dir = mock_cache_dir / "chromium-1234" mock_chrome_path = mock_chromium_dir / "chrome-linux" / "chrome" @@ -63,11 +95,12 @@ def mock_exists(self): ): mock_glob.return_value = [mock_chromium_dir] - result = _check_chromium_available() + result = executor._check_chromium_available() assert result == str(mock_chrome_path) def test_check_chromium_available_playwright_macos(self): """Test detection of Playwright-installed Chromium on macOS.""" + executor = BrowserToolExecutor.__new__(BrowserToolExecutor) mock_cache_dir = Path("/Users/user/Library/Caches/ms-playwright") mock_chromium_dir = mock_cache_dir / "chromium-1234" mock_chrome_path = ( @@ -90,47 +123,58 @@ def mock_exists(self): ): mock_glob.return_value = [mock_chromium_dir] - result = _check_chromium_available() + result = executor._check_chromium_available() assert result == str(mock_chrome_path) def test_check_chromium_available_playwright_windows(self): """Test detection of Playwright-installed Chromium on Windows.""" - mock_cache_dir = Path("/home/user/.cache/ms-playwright") + from openhands.tools.browser_use.impl_windows import WindowsBrowserToolExecutor + + executor = WindowsBrowserToolExecutor.__new__(WindowsBrowserToolExecutor) + mock_cache_dir = Path("C:/Users/user/AppData/Local/ms-playwright") mock_chromium_dir = mock_cache_dir / "chromium-1234" mock_chrome_path = mock_chromium_dir / "chrome-win" / "chrome.exe" def mock_exists(self): return str(self) in [str(mock_cache_dir), str(mock_chrome_path)] + def mock_environ_get(key, default=None): + """Mock environment variable getter for Windows-specific tests.""" + if key == "LOCALAPPDATA": + return "C:/Users/user/AppData/Local" + return default + with ( patch("shutil.which", return_value=None), - patch("pathlib.Path.home", return_value=Path("/home/user")), + patch("os.environ.get", side_effect=mock_environ_get), patch.object(Path, "exists", mock_exists), patch.object(Path, "glob") as mock_glob, ): mock_glob.return_value = [mock_chromium_dir] - result = _check_chromium_available() + result = executor._check_chromium_available() assert result == str(mock_chrome_path) def test_check_chromium_available_not_found(self): """Test when no Chromium binary is found.""" + executor = BrowserToolExecutor.__new__(BrowserToolExecutor) with ( patch("shutil.which", return_value=None), patch("pathlib.Path.home", return_value=Path("/home/user")), patch.object(Path, "exists", return_value=False), ): - result = _check_chromium_available() + result = executor._check_chromium_available() assert result is None def test_check_chromium_available_playwright_cache_not_found(self): """Test when Playwright cache directory doesn't exist.""" + executor = BrowserToolExecutor.__new__(BrowserToolExecutor) with ( patch("shutil.which", return_value=None), patch("pathlib.Path.home", return_value=Path("/home/user")), patch.object(Path, "exists", return_value=False), ): - result = _check_chromium_available() + result = executor._check_chromium_available() assert result is None @@ -201,21 +245,19 @@ class TestEnsureChromiumAvailable: def test_ensure_chromium_available_already_available(self): """Test when Chromium is already available.""" - with patch( - "openhands.tools.browser_use.impl._check_chromium_available", - return_value="/usr/bin/chromium", + executor = BrowserToolExecutor.__new__(BrowserToolExecutor) + with patch.object( + executor, "_check_chromium_available", return_value="/usr/bin/chromium" ): - result = _ensure_chromium_available() + result = executor._ensure_chromium_available() assert result == "/usr/bin/chromium" def test_ensure_chromium_available_not_found_raises_error(self): """Test that clear error is raised when Chromium is not available.""" - with patch( - "openhands.tools.browser_use.impl._check_chromium_available", - return_value=None, - ): + executor = BrowserToolExecutor.__new__(BrowserToolExecutor) + with patch.object(executor, "_check_chromium_available", return_value=None): with pytest.raises(Exception) as exc_info: - _ensure_chromium_available() + executor._ensure_chromium_available() error_message = str(exc_info.value) assert "Chromium is required for browser operations" in error_message