ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
@@ -0,0 +1,239 @@
"""
Pytest configuration for browser-use CI tests.
Sets up environment variables to ensure tests never connect to production services.
"""
import os
import socketserver
import tempfile
from unittest.mock import AsyncMock
import pytest
from dotenv import load_dotenv
from pytest_httpserver import HTTPServer
# Fix for httpserver hanging on shutdown - prevent blocking on socket close
# This prevents tests from hanging when shutting down HTTP servers
socketserver.ThreadingMixIn.block_on_close = False
# Also set daemon threads to prevent hanging
socketserver.ThreadingMixIn.daemon_threads = True
from browser_use.agent.views import AgentOutput
from browser_use.llm import BaseChatModel
from browser_use.llm.views import ChatInvokeCompletion
from browser_use.tools.service import Tools
# Load environment variables before any imports
load_dotenv()
# Skip LLM API key verification for tests
os.environ['SKIP_LLM_API_KEY_VERIFICATION'] = 'true'
from bubus import BaseEvent
from browser_use import Agent
from browser_use.browser import BrowserProfile, BrowserSession
from browser_use.sync.service import CloudSync
@pytest.fixture(autouse=True)
def setup_test_environment():
"""
Automatically set up test environment for all tests.
"""
# Create a temporary directory for test config (but not for extensions)
config_dir = tempfile.mkdtemp(prefix='browseruse_tests_')
original_env = {}
test_env_vars = {
'SKIP_LLM_API_KEY_VERIFICATION': 'true',
'ANONYMIZED_TELEMETRY': 'false',
'BROWSER_USE_CLOUD_SYNC': 'true',
'BROWSER_USE_CLOUD_API_URL': 'http://placeholder-will-be-replaced-by-specific-test-fixtures',
'BROWSER_USE_CLOUD_UI_URL': 'http://placeholder-will-be-replaced-by-specific-test-fixtures',
# Don't set BROWSER_USE_CONFIG_DIR anymore - let it use the default ~/.config/browseruse
# This way extensions will be cached in ~/.config/browseruse/extensions
}
for key, value in test_env_vars.items():
original_env[key] = os.environ.get(key)
os.environ[key] = value
yield
# Restore original environment
for key, value in original_env.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
# not a fixture, mock_llm() provides this in a fixture below, this is a helper so that it can accept args
def create_mock_llm(actions: list[str] | None = None) -> BaseChatModel:
"""Create a mock LLM that returns specified actions or a default done action.
Args:
actions: Optional list of JSON strings representing actions to return in sequence.
If not provided, returns a single done action.
After all actions are exhausted, returns a done action.
Returns:
Mock LLM that will return the actions in order, or just a done action if no actions provided.
"""
tools = Tools()
ActionModel = tools.registry.create_action_model()
AgentOutputWithActions = AgentOutput.type_with_custom_actions(ActionModel)
llm = AsyncMock(spec=BaseChatModel)
llm.model = 'mock-llm'
llm._verified_api_keys = True
# Add missing properties from BaseChatModel protocol
llm.provider = 'mock'
llm.name = 'mock-llm'
llm.model_name = 'mock-llm' # Ensure this returns a string, not a mock
# Default done action
default_done_action = """
{
"thinking": "null",
"evaluation_previous_goal": "Successfully completed the task",
"memory": "Task completed",
"next_goal": "Task completed",
"action": [
{
"done": {
"text": "Task completed successfully",
"success": true
}
}
]
}
"""
# Unified logic for both cases
action_index = 0
def get_next_action() -> str:
nonlocal action_index
if actions is not None and action_index < len(actions):
action = actions[action_index]
action_index += 1
return action
else:
return default_done_action
async def mock_ainvoke(*args, **kwargs):
# Check if output_format is provided (2nd argument or in kwargs)
output_format = None
if len(args) >= 2:
output_format = args[1]
elif 'output_format' in kwargs:
output_format = kwargs['output_format']
action_json = get_next_action()
if output_format is None:
# Return string completion
return ChatInvokeCompletion(completion=action_json, usage=None)
else:
# Parse with provided output_format (could be AgentOutputWithActions or another model)
if output_format == AgentOutputWithActions:
parsed = AgentOutputWithActions.model_validate_json(action_json)
else:
# For other output formats, try to parse the JSON with that model
parsed = output_format.model_validate_json(action_json)
return ChatInvokeCompletion(completion=parsed, usage=None)
llm.ainvoke.side_effect = mock_ainvoke
return llm
@pytest.fixture(scope='module')
async def browser_session():
"""Create a real browser session for testing"""
session = BrowserSession(
browser_profile=BrowserProfile(
headless=True,
user_data_dir=None, # Use temporary directory
keep_alive=True,
enable_default_extensions=True, # Enable extensions during tests
)
)
await session.start()
yield session
await session.kill()
# Ensure event bus is properly stopped
await session.event_bus.stop(clear=True, timeout=5)
@pytest.fixture(scope='function')
def cloud_sync(httpserver: HTTPServer):
"""
Create a CloudSync instance configured for testing.
This fixture creates a real CloudSync instance and sets up the test environment
to use the httpserver URLs.
"""
# Set up test environment
test_http_server_url = httpserver.url_for('')
os.environ['BROWSER_USE_CLOUD_API_URL'] = test_http_server_url
os.environ['BROWSER_USE_CLOUD_UI_URL'] = test_http_server_url
os.environ['BROWSER_USE_CLOUD_SYNC'] = 'true'
# Create CloudSync with test server URL
cloud_sync = CloudSync(
base_url=test_http_server_url,
)
return cloud_sync
@pytest.fixture(scope='function')
def mock_llm():
"""Create a mock LLM that just returns the done action if queried"""
return create_mock_llm(actions=None)
@pytest.fixture(scope='function')
def agent_with_cloud(browser_session, mock_llm, cloud_sync):
"""Create agent with cloud sync enabled (using real CloudSync)."""
agent = Agent(
task='Test task',
llm=mock_llm,
browser_session=browser_session,
cloud_sync=cloud_sync,
)
return agent
@pytest.fixture(scope='function')
def event_collector():
"""Helper to collect all events emitted during tests"""
events = []
event_order = []
class EventCollector:
def __init__(self):
self.events = events
self.event_order = event_order
async def collect_event(self, event: BaseEvent):
self.events.append(event)
self.event_order.append(event.event_type)
return 'collected'
def get_events_by_type(self, event_type: str) -> list[BaseEvent]:
return [e for e in self.events if e.event_type == event_type]
def clear(self):
self.events.clear()
self.event_order.clear()
return EventCollector()
@@ -0,0 +1,346 @@
"""
Runs all agent tasks in parallel (up to 10 at a time) using separate subprocesses.
Each task gets its own Python process, preventing browser session interference.
Does not fail on partial failures (always exits 0).
"""
import argparse
import asyncio
import glob
import json
import logging
import os
import sys
import warnings
import aiofiles
import yaml
from pydantic import BaseModel
from browser_use import Agent, AgentHistoryList, BrowserProfile, BrowserSession, ChatOpenAI
from browser_use.llm.messages import UserMessage
# --- CONFIG ---
MAX_PARALLEL = 10
TASK_DIR = (
sys.argv[1]
if len(sys.argv) > 1 and not sys.argv[1].startswith('--')
else os.path.join(os.path.dirname(__file__), '../agent_tasks')
)
TASK_FILES = glob.glob(os.path.join(TASK_DIR, '*.yaml'))
class JudgeResponse(BaseModel):
success: bool
explanation: str
async def run_single_task(task_file):
"""Run a single task in the current process (called by subprocess)"""
try:
print(f'[DEBUG] Starting task: {os.path.basename(task_file)}', file=sys.stderr)
# Suppress all logging in subprocess to avoid interfering with JSON output
logging.getLogger().setLevel(logging.CRITICAL)
for logger_name in ['browser_use', 'telemetry', 'message_manager']:
logging.getLogger(logger_name).setLevel(logging.CRITICAL)
warnings.filterwarnings('ignore')
print('[DEBUG] Loading task file...', file=sys.stderr)
async with aiofiles.open(task_file, 'r') as f:
content = await f.read()
task_data = yaml.safe_load(content)
task = task_data['task']
judge_context = task_data.get('judge_context', ['The agent must solve the task'])
max_steps = task_data.get('max_steps', 15)
print(f'[DEBUG] Task: {task[:100]}...', file=sys.stderr)
print(f'[DEBUG] Max steps: {max_steps}', file=sys.stderr)
agent_llm = ChatOpenAI(model='gpt-4.1-mini')
judge_llm = ChatOpenAI(model='gpt-4.1-mini')
print('[DEBUG] LLMs initialized', file=sys.stderr)
# Each subprocess gets its own profile and session
print('[DEBUG] Creating browser session...', file=sys.stderr)
profile = BrowserProfile(
headless=True,
user_data_dir=None,
chromium_sandbox=False, # Disable sandbox for CI environment (GitHub Actions)
)
session = BrowserSession(browser_profile=profile)
print('[DEBUG] Browser session created', file=sys.stderr)
# Test if browser is working
try:
await session.start()
from browser_use.browser.events import NavigateToUrlEvent
event = session.event_bus.dispatch(NavigateToUrlEvent(url='https://httpbin.org/get', new_tab=True))
await event
print('[DEBUG] Browser test: navigation successful', file=sys.stderr)
title = await session.get_current_page_title()
print(f"[DEBUG] Browser test: got title '{title}'", file=sys.stderr)
except Exception as browser_error:
print(f'[DEBUG] Browser test failed: {str(browser_error)}', file=sys.stderr)
print(
f'[DEBUG] Browser error type: {type(browser_error).__name__}',
file=sys.stderr,
)
print('[DEBUG] Starting agent execution...', file=sys.stderr)
agent = Agent(task=task, llm=agent_llm, browser_session=session)
try:
history: AgentHistoryList = await agent.run(max_steps=max_steps)
print('[DEBUG] Agent.run() returned successfully', file=sys.stderr)
except Exception as agent_error:
print(
f'[DEBUG] Agent.run() failed with error: {str(agent_error)}',
file=sys.stderr,
)
print(f'[DEBUG] Error type: {type(agent_error).__name__}', file=sys.stderr)
# Re-raise to be caught by outer try-catch
raise agent_error
agent_output = history.final_result() or ''
print('[DEBUG] Agent execution completed', file=sys.stderr)
# Test if LLM is working by making a simple call
try:
response = await agent_llm.ainvoke([UserMessage(content="Say 'test'")])
print(
f'[DEBUG] LLM test call successful: {response.completion[:50]}',
file=sys.stderr,
)
except Exception as llm_error:
print(f'[DEBUG] LLM test call failed: {str(llm_error)}', file=sys.stderr)
# Debug: capture more details about the agent execution
total_steps = len(history.history) if hasattr(history, 'history') else 0
last_action = history.history[-1] if hasattr(history, 'history') and history.history else None
debug_info = f'Steps: {total_steps}, Final result length: {len(agent_output)}'
if last_action:
debug_info += f', Last action: {type(last_action).__name__}'
# Log to stderr so it shows up in GitHub Actions (won't interfere with JSON output to stdout)
print(f'[DEBUG] Task {os.path.basename(task_file)}: {debug_info}', file=sys.stderr)
if agent_output:
print(
f'[DEBUG] Agent output preview: {agent_output[:200]}...',
file=sys.stderr,
)
else:
print('[DEBUG] Agent produced no output!', file=sys.stderr)
criteria = '\n- '.join(judge_context)
judge_prompt = f"""
You are a evaluator of a browser agent task inside a ci/cd pipeline. Here was the agent's task:
{task}
Here is the agent's output:
{agent_output if agent_output else '[No output provided]'}
Debug info: {debug_info}
Criteria for success:
- {criteria}
Reply in JSON with keys: success (true/false), explanation (string).
If the agent provided no output, explain what might have gone wrong.
"""
response = await judge_llm.ainvoke([UserMessage(content=judge_prompt)], output_format=JudgeResponse)
judge_response = response.completion
result = {
'file': os.path.basename(task_file),
'success': judge_response.success,
'explanation': judge_response.explanation,
}
# Clean up session before returning
await session.kill()
return result
except Exception as e:
# Ensure session cleanup even on error
try:
await session.kill()
except Exception:
pass
return {
'file': os.path.basename(task_file),
'success': False,
'explanation': f'Task failed with error: {str(e)}',
}
async def run_task_subprocess(task_file, semaphore):
"""Run a task in a separate subprocess"""
async with semaphore:
try:
# Set environment to reduce noise in subprocess
env = os.environ.copy()
env['PYTHONPATH'] = os.pathsep.join(sys.path)
proc = await asyncio.create_subprocess_exec(
sys.executable,
__file__,
'--task',
task_file,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
stdout, stderr = await proc.communicate()
if proc.returncode == 0:
try:
# Parse JSON result from subprocess
stdout_text = stdout.decode().strip()
stderr_text = stderr.decode().strip()
# Display subprocess debug logs
if stderr_text:
print(f'[SUBPROCESS {os.path.basename(task_file)}] Debug output:')
for line in stderr_text.split('\n'):
if line.strip():
print(f' {line}')
# Find the JSON line (should be the last line that starts with {)
lines = stdout_text.split('\n')
json_line = None
for line in reversed(lines):
line = line.strip()
if line.startswith('{') and line.endswith('}'):
json_line = line
break
if json_line:
result = json.loads(json_line)
print(f'[PARENT] Task {os.path.basename(task_file)} completed: {result["success"]}')
else:
raise ValueError(f'No JSON found in output: {stdout_text}')
except (json.JSONDecodeError, ValueError) as e:
result = {
'file': os.path.basename(task_file),
'success': False,
'explanation': f'Failed to parse subprocess result: {str(e)[:100]}',
}
print(f'[PARENT] Task {os.path.basename(task_file)} failed to parse: {str(e)}')
print(f'[PARENT] Full stdout was: {stdout.decode()[:500]}')
else:
stderr_text = stderr.decode().strip()
result = {
'file': os.path.basename(task_file),
'success': False,
'explanation': f'Subprocess failed (code {proc.returncode}): {stderr_text[:200]}',
}
print(f'[PARENT] Task {os.path.basename(task_file)} subprocess failed with code {proc.returncode}')
if stderr_text:
print(f'[PARENT] stderr: {stderr_text[:1000]}')
stdout_text = stdout.decode().strip()
if stdout_text:
print(f'[PARENT] stdout: {stdout_text[:1000]}')
except Exception as e:
result = {
'file': os.path.basename(task_file),
'success': False,
'explanation': f'Failed to start subprocess: {str(e)}',
}
print(f'[PARENT] Failed to start subprocess for {os.path.basename(task_file)}: {str(e)}')
return result
async def main():
"""Run all tasks in parallel using subprocesses"""
semaphore = asyncio.Semaphore(MAX_PARALLEL)
print(f'Found task files: {TASK_FILES}')
if not TASK_FILES:
print('No task files found!')
return 0, 0
# Run all tasks in parallel subprocesses
tasks = [run_task_subprocess(task_file, semaphore) for task_file in TASK_FILES]
results = await asyncio.gather(*tasks)
passed = sum(1 for r in results if r['success'])
total = len(results)
print('\n' + '=' * 60)
print(f'{"RESULTS":^60}\n')
# Prepare table data
headers = ['Task', 'Success', 'Reason']
rows = []
for r in results:
status = '' if r['success'] else ''
rows.append([r['file'], status, r['explanation']])
# Calculate column widths
col_widths = [max(len(str(row[i])) for row in ([headers] + rows)) for i in range(3)]
# Print header
header_row = ' | '.join(headers[i].ljust(col_widths[i]) for i in range(3))
print(header_row)
print('-+-'.join('-' * w for w in col_widths))
# Print rows
for row in rows:
print(' | '.join(str(row[i]).ljust(col_widths[i]) for i in range(3)))
print('\n' + '=' * 60)
print(f'\n{"SCORE":^60}')
print(f'\n{"=" * 60}\n')
print(f'\n{"*" * 10} {passed}/{total} PASSED {"*" * 10}\n')
print('=' * 60 + '\n')
# Output results for GitHub Actions
print(f'PASSED={passed}')
print(f'TOTAL={total}')
# Output detailed results as JSON for GitHub Actions
detailed_results = []
for r in results:
detailed_results.append(
{
'task': r['file'].replace('.yaml', ''),
'success': r['success'],
'reason': r['explanation'],
}
)
print('DETAILED_RESULTS=' + json.dumps(detailed_results))
return passed, total
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--task', type=str, help='Path to a single task YAML file (for subprocess mode)')
args = parser.parse_args()
if args.task:
# Subprocess mode: run a single task and output ONLY JSON
try:
result = asyncio.run(run_single_task(args.task))
# Output ONLY the JSON result, nothing else
print(json.dumps(result))
except Exception as e:
# Even on critical failure, output valid JSON
error_result = {
'file': os.path.basename(args.task),
'success': False,
'explanation': f'Critical subprocess error: {str(e)}',
}
print(json.dumps(error_result))
else:
# Parent process mode: run all tasks in parallel subprocesses
passed, total = asyncio.run(main())
# Results already printed by main() function
@@ -0,0 +1,271 @@
import pytest
from pydantic import BaseModel, Field
from browser_use.agent.message_manager.service import MessageManager
from browser_use.agent.views import MessageManagerState
from browser_use.filesystem.file_system import FileSystem
from browser_use.llm import SystemMessage, UserMessage
from browser_use.llm.messages import ContentPartTextParam
from browser_use.tools.registry.service import Registry
from browser_use.utils import is_new_tab_page, match_url_with_domain_pattern
class SensitiveParams(BaseModel):
"""Test parameter model for sensitive data testing."""
text: str = Field(description='Text with sensitive data placeholders')
@pytest.fixture
def registry():
return Registry()
@pytest.fixture
def message_manager():
import os
import tempfile
import uuid
base_tmp = tempfile.gettempdir() # e.g., /tmp on Unix
file_system_path = os.path.join(base_tmp, str(uuid.uuid4()))
return MessageManager(
task='Test task',
system_message=SystemMessage(content='System message'),
state=MessageManagerState(),
file_system=FileSystem(file_system_path),
)
def test_replace_sensitive_data_with_missing_keys(registry, caplog):
"""Test that _replace_sensitive_data handles missing keys gracefully"""
# Create a simple Pydantic model with sensitive data placeholders
params = SensitiveParams(text='Please enter <secret>username</secret> and <secret>password</secret>')
# Case 1: All keys present - both placeholders should be replaced
sensitive_data = {'username': 'user123', 'password': 'pass456'}
result = registry._replace_sensitive_data(params, sensitive_data)
assert result.text == 'Please enter user123 and pass456'
assert '<secret>' not in result.text # No secret tags should remain
# Case 2: One key missing - only available key should be replaced
sensitive_data = {'username': 'user123'} # password is missing
result = registry._replace_sensitive_data(params, sensitive_data)
assert result.text == 'Please enter user123 and <secret>password</secret>'
assert 'user123' in result.text
assert '<secret>password</secret>' in result.text # Missing key's tag remains
# Case 3: Multiple keys missing - all tags should be preserved
sensitive_data = {} # both keys missing
result = registry._replace_sensitive_data(params, sensitive_data)
assert result.text == 'Please enter <secret>username</secret> and <secret>password</secret>'
assert '<secret>username</secret>' in result.text
assert '<secret>password</secret>' in result.text
# Case 4: One key empty - empty values are treated as missing
sensitive_data = {'username': 'user123', 'password': ''}
result = registry._replace_sensitive_data(params, sensitive_data)
assert result.text == 'Please enter user123 and <secret>password</secret>'
assert 'user123' in result.text
assert '<secret>password</secret>' in result.text # Empty value's tag remains
def test_simple_domain_specific_sensitive_data(registry, caplog):
"""Test the basic functionality of domain-specific sensitive data replacement"""
# Create a simple Pydantic model with sensitive data placeholders
params = SensitiveParams(text='Please enter <secret>username</secret> and <secret>password</secret>')
# Simple test with directly instantiable values
sensitive_data = {
'example.com': {'username': 'example_user'},
'other_data': 'non_secret_value', # Old format mixed with new
}
# Without a URL, domain-specific secrets should NOT be exposed
result = registry._replace_sensitive_data(params, sensitive_data)
assert result.text == 'Please enter <secret>username</secret> and <secret>password</secret>'
assert '<secret>username</secret>' in result.text # Should NOT be replaced without URL
assert '<secret>password</secret>' in result.text # Password is missing in sensitive_data
assert 'example_user' not in result.text # Domain-specific value should not appear
# Test with a matching URL - domain-specific secrets should be exposed
result = registry._replace_sensitive_data(params, sensitive_data, 'https://example.com/login')
assert result.text == 'Please enter example_user and <secret>password</secret>'
assert 'example_user' in result.text # Should be replaced with matching URL
assert '<secret>password</secret>' in result.text # Password is still missing
assert '<secret>username</secret>' not in result.text # Username tag should be replaced
def test_match_url_with_domain_pattern():
"""Test that the domain pattern matching utility works correctly"""
# Test exact domain matches
assert match_url_with_domain_pattern('https://example.com', 'example.com') is True
assert match_url_with_domain_pattern('http://example.com', 'example.com') is False # Default scheme is now https
assert match_url_with_domain_pattern('https://google.com', 'example.com') is False
# Test subdomain pattern matches
assert match_url_with_domain_pattern('https://sub.example.com', '*.example.com') is True
assert match_url_with_domain_pattern('https://example.com', '*.example.com') is True # Base domain should match too
assert match_url_with_domain_pattern('https://sub.sub.example.com', '*.example.com') is True
assert match_url_with_domain_pattern('https://example.org', '*.example.com') is False
# Test protocol pattern matches
assert match_url_with_domain_pattern('https://example.com', 'http*://example.com') is True
assert match_url_with_domain_pattern('http://example.com', 'http*://example.com') is True
assert match_url_with_domain_pattern('ftp://example.com', 'http*://example.com') is False
# Test explicit http protocol
assert match_url_with_domain_pattern('http://example.com', 'http://example.com') is True
assert match_url_with_domain_pattern('https://example.com', 'http://example.com') is False
# Test Chrome extension pattern
assert match_url_with_domain_pattern('chrome-extension://abcdefghijkl', 'chrome-extension://*') is True
assert match_url_with_domain_pattern('chrome-extension://mnopqrstuvwx', 'chrome-extension://abcdefghijkl') is False
# Test new tab page handling
assert match_url_with_domain_pattern('about:blank', 'example.com') is False
assert match_url_with_domain_pattern('about:blank', '*://*') is False
assert match_url_with_domain_pattern('chrome://new-tab-page/', 'example.com') is False
assert match_url_with_domain_pattern('chrome://new-tab-page/', '*://*') is False
assert match_url_with_domain_pattern('chrome://new-tab-page', 'example.com') is False
assert match_url_with_domain_pattern('chrome://new-tab-page', '*://*') is False
def test_unsafe_domain_patterns():
"""Test that unsafe domain patterns are rejected"""
# These are unsafe patterns that could match too many domains
assert match_url_with_domain_pattern('https://evil.com', '*google.com') is False
assert match_url_with_domain_pattern('https://google.com.evil.com', '*.*.com') is False
assert match_url_with_domain_pattern('https://google.com', '**google.com') is False
assert match_url_with_domain_pattern('https://google.com', 'g*e.com') is False
assert match_url_with_domain_pattern('https://google.com', '*com*') is False
# Test with patterns that have multiple asterisks in different positions
assert match_url_with_domain_pattern('https://subdomain.example.com', '*domain*example*') is False
assert match_url_with_domain_pattern('https://sub.domain.example.com', '*.*.example.com') is False
# Test patterns with wildcards in TLD part
assert match_url_with_domain_pattern('https://example.com', 'example.*') is False
assert match_url_with_domain_pattern('https://example.org', 'example.*') is False
def test_malformed_urls_and_patterns():
"""Test handling of malformed URLs or patterns"""
# Malformed URLs
assert match_url_with_domain_pattern('not-a-url', 'example.com') is False
assert match_url_with_domain_pattern('http://', 'example.com') is False
assert match_url_with_domain_pattern('https://', 'example.com') is False
assert match_url_with_domain_pattern('ftp:/example.com', 'example.com') is False # Missing slash
# Empty URLs or patterns
assert match_url_with_domain_pattern('', 'example.com') is False
assert match_url_with_domain_pattern('https://example.com', '') is False
# URLs with no hostname
assert match_url_with_domain_pattern('file:///path/to/file.txt', 'example.com') is False
# Invalid pattern formats
assert match_url_with_domain_pattern('https://example.com', '..example.com') is False
assert match_url_with_domain_pattern('https://example.com', '.*.example.com') is False
assert match_url_with_domain_pattern('https://example.com', '**') is False
# Nested URL attacks in path, query or fragments
assert match_url_with_domain_pattern('https://example.com/redirect?url=https://evil.com', 'example.com') is True
assert match_url_with_domain_pattern('https://example.com/path/https://evil.com', 'example.com') is True
assert match_url_with_domain_pattern('https://example.com#https://evil.com', 'example.com') is True
# These should match example.com, not evil.com since urlparse extracts the hostname correctly
# Complex URL obfuscation attempts
assert match_url_with_domain_pattern('https://example.com/path?next=//evil.com/attack', 'example.com') is True
assert match_url_with_domain_pattern('https://example.com@evil.com', 'example.com') is False
assert match_url_with_domain_pattern('https://evil.com?example.com', 'example.com') is False
assert match_url_with_domain_pattern('https://user:example.com@evil.com', 'example.com') is False
# urlparse correctly identifies evil.com as the hostname in these cases
def test_url_components():
"""Test handling of URL components like credentials, ports, fragments, etc."""
# URLs with credentials (username:password@)
assert match_url_with_domain_pattern('https://user:pass@example.com', 'example.com') is True
assert match_url_with_domain_pattern('https://user:pass@example.com', '*.example.com') is True
# URLs with ports
assert match_url_with_domain_pattern('https://example.com:4242', 'example.com') is True
assert match_url_with_domain_pattern('https://example.com:4242', 'example.com:4242') is True # Port is stripped from pattern
# URLs with paths
assert match_url_with_domain_pattern('https://example.com/path/to/page', 'example.com') is True
assert (
match_url_with_domain_pattern('https://example.com/path/to/page', 'example.com/path') is False
) # Paths in patterns are not supported
# URLs with query parameters
assert match_url_with_domain_pattern('https://example.com?param=value', 'example.com') is True
# URLs with fragments
assert match_url_with_domain_pattern('https://example.com#section', 'example.com') is True
# URLs with all components
assert match_url_with_domain_pattern('https://user:pass@example.com:4242/path?query=val#fragment', 'example.com') is True
def test_filter_sensitive_data(message_manager):
"""Test that _filter_sensitive_data handles all sensitive data scenarios correctly"""
# Set up a message with sensitive information
message = UserMessage(content='My username is admin and password is secret123')
# Case 1: No sensitive data provided
message_manager.sensitive_data = None
result = message_manager._filter_sensitive_data(message)
assert result.content == 'My username is admin and password is secret123'
# Case 2: All sensitive data is properly replaced
message_manager.sensitive_data = {'username': 'admin', 'password': 'secret123'}
result = message_manager._filter_sensitive_data(message)
assert '<secret>username</secret>' in result.content
assert '<secret>password</secret>' in result.content
# Case 3: Make sure it works with nested content
nested_message = UserMessage(content=[ContentPartTextParam(text='My username is admin and password is secret123')])
result = message_manager._filter_sensitive_data(nested_message)
assert '<secret>username</secret>' in result.content[0].text
assert '<secret>password</secret>' in result.content[0].text
# Case 4: Test with empty values
message_manager.sensitive_data = {'username': 'admin', 'password': ''}
result = message_manager._filter_sensitive_data(message)
assert '<secret>username</secret>' in result.content
# Only username should be replaced since password is empty
# Case 5: Test with domain-specific sensitive data format
message_manager.sensitive_data = {
'example.com': {'username': 'admin', 'password': 'secret123'},
'google.com': {'email': 'user@example.com', 'password': 'google_pass'},
}
# Update the message to include the values we're going to test
message = UserMessage(content='My username is admin, email is user@example.com and password is secret123 or google_pass')
result = message_manager._filter_sensitive_data(message)
# All sensitive values should be replaced regardless of domain
assert '<secret>username</secret>' in result.content
assert '<secret>password</secret>' in result.content
assert '<secret>email</secret>' in result.content
def test_is_new_tab_page():
"""Test is_new_tab_page function"""
# Test about:blank
assert is_new_tab_page('about:blank') is True
# Test chrome://new-tab-page variations
assert is_new_tab_page('chrome://new-tab-page/') is True
assert is_new_tab_page('chrome://new-tab-page') is True
# Test regular URLs
assert is_new_tab_page('https://example.com') is False
assert is_new_tab_page('http://google.com') is False
assert is_new_tab_page('') is False
assert is_new_tab_page('chrome://settings') is False
@@ -0,0 +1,670 @@
"""Test GetDropdownOptionsEvent and SelectDropdownOptionEvent functionality.
This file consolidates all tests related to dropdown functionality including:
- Native <select> dropdowns
- ARIA role="menu" dropdowns
- Custom dropdown implementations
"""
import pytest
from pytest_httpserver import HTTPServer
from browser_use.agent.views import ActionModel, ActionResult
from browser_use.browser import BrowserSession
from browser_use.browser.events import GetDropdownOptionsEvent, NavigationCompleteEvent, SelectDropdownOptionEvent
from browser_use.browser.profile import BrowserProfile
from browser_use.tools.service import Tools
from browser_use.tools.views import GoToUrlAction
@pytest.fixture(scope='session')
def http_server():
"""Create and provide a test HTTP server that serves static content."""
server = HTTPServer()
server.start()
# Add route for native dropdown test page
server.expect_request('/native-dropdown').respond_with_data(
"""
<!DOCTYPE html>
<html>
<head>
<title>Native Dropdown Test</title>
</head>
<body>
<h1>Native Dropdown Test</h1>
<select id="test-dropdown" name="test-dropdown">
<option value="">Please select</option>
<option value="option1">First Option</option>
<option value="option2">Second Option</option>
<option value="option3">Third Option</option>
</select>
<div id="result">No selection made</div>
<script>
document.getElementById('test-dropdown').addEventListener('change', function(e) {
document.getElementById('result').textContent = 'Selected: ' + e.target.options[e.target.selectedIndex].text;
});
</script>
</body>
</html>
""",
content_type='text/html',
)
# Add route for ARIA menu test page
server.expect_request('/aria-menu').respond_with_data(
"""
<!DOCTYPE html>
<html>
<head>
<title>ARIA Menu Test</title>
<style>
.menu {
list-style: none;
padding: 0;
margin: 0;
border: 1px solid #ccc;
background: white;
width: 200px;
}
.menu-item {
padding: 10px 20px;
border-bottom: 1px solid #eee;
}
.menu-item:hover {
background: #f0f0f0;
}
.menu-item-anchor {
text-decoration: none;
color: #333;
display: block;
}
#result {
margin-top: 20px;
padding: 10px;
border: 1px solid #ddd;
min-height: 20px;
}
</style>
</head>
<body>
<h1>ARIA Menu Test</h1>
<p>This menu uses ARIA roles instead of native select elements</p>
<ul class="menu menu-format-standard menu-regular" role="menu" id="pyNavigation1752753375773" style="display: block;">
<li class="menu-item menu-item-enabled" role="presentation">
<a href="#" onclick="pd(event);" class="menu-item-anchor" tabindex="0" role="menuitem">
<span class="menu-item-title-wrap"><span class="menu-item-title">Filter</span></span>
</a>
</li>
<li class="menu-item menu-item-enabled" role="presentation" id="menu-item-$PpyNavigation1752753375773$ppyElements$l2">
<a href="#" onclick="pd(event);" class="menu-item-anchor menu-item-expand" tabindex="0" role="menuitem" aria-haspopup="true">
<span class="menu-item-title-wrap"><span class="menu-item-title">Sort</span></span>
</a>
<div class="menu-panel-wrapper">
<ul class="menu menu-format-standard menu-regular" role="menu" id="$PpyNavigation1752753375773$ppyElements$l2">
<li class="menu-item menu-item-enabled" role="presentation">
<a href="#" onclick="pd(event);" class="menu-item-anchor" tabindex="0" role="menuitem">
<span class="menu-item-title-wrap"><span class="menu-item-title">Lowest to highest</span></span>
</a>
</li>
<li class="menu-item menu-item-enabled" role="presentation">
<a href="#" onclick="pd(event);" class="menu-item-anchor" tabindex="0" role="menuitem">
<span class="menu-item-title-wrap"><span class="menu-item-title">Highest to lowest</span></span>
</a>
</li>
</ul>
</div>
</li>
<li class="menu-item menu-item-enabled" role="presentation">
<a href="#" onclick="pd(event);" class="menu-item-anchor" tabindex="0" role="menuitem">
<span class="menu-item-title-wrap"><span class="menu-item-title">Appearance</span></span>
</a>
</li>
<li class="menu-item menu-item-enabled" role="presentation">
<a href="#" onclick="pd(event);" class="menu-item-anchor" tabindex="0" role="menuitem">
<span class="menu-item-title-wrap"><span class="menu-item-title">Summarize</span></span>
</a>
</li>
<li class="menu-item menu-item-enabled" role="presentation">
<a href="#" onclick="pd(event);" class="menu-item-anchor" tabindex="0" role="menuitem">
<span class="menu-item-title-wrap"><span class="menu-item-title">Delete</span></span>
</a>
</li>
</ul>
<div id="result">Click an option to see the result</div>
<script>
// Mock the pd function that prevents default
function pd(event) {
event.preventDefault();
const text = event.target.closest('[role="menuitem"]').textContent.trim();
document.getElementById('result').textContent = 'Clicked: ' + text;
}
</script>
</body>
</html>
""",
content_type='text/html',
)
# Add route for custom dropdown test page
server.expect_request('/custom-dropdown').respond_with_data(
"""
<!DOCTYPE html>
<html>
<head>
<title>Custom Dropdown Test</title>
<style>
.dropdown {
position: relative;
display: inline-block;
width: 200px;
}
.dropdown-button {
padding: 10px;
border: 1px solid #ccc;
background: white;
cursor: pointer;
width: 100%;
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
right: 0;
border: 1px solid #ccc;
background: white;
display: block;
z-index: 1000;
}
.dropdown-menu.hidden {
display: none;
}
.dropdown .item {
padding: 10px;
cursor: pointer;
}
.dropdown .item:hover {
background: #f0f0f0;
}
.dropdown .item.selected {
background: #e0e0e0;
}
#result {
margin-top: 20px;
padding: 10px;
border: 1px solid #ddd;
}
</style>
</head>
<body>
<h1>Custom Dropdown Test</h1>
<p>This is a custom dropdown implementation (like Semantic UI)</p>
<div class="dropdown ui" id="custom-dropdown">
<div class="dropdown-button" onclick="toggleDropdown()">
<span id="selected-text">Choose an option</span>
</div>
<div class="dropdown-menu" id="dropdown-menu">
<div class="item" data-value="red" onclick="selectOption('Red', 'red')">Red</div>
<div class="item" data-value="green" onclick="selectOption('Green', 'green')">Green</div>
<div class="item" data-value="blue" onclick="selectOption('Blue', 'blue')">Blue</div>
<div class="item" data-value="yellow" onclick="selectOption('Yellow', 'yellow')">Yellow</div>
</div>
</div>
<div id="result">No selection made</div>
<script>
function toggleDropdown() {
const menu = document.getElementById('dropdown-menu');
menu.classList.toggle('hidden');
}
function selectOption(text, value) {
document.getElementById('selected-text').textContent = text;
document.getElementById('result').textContent = 'Selected: ' + text + ' (value: ' + value + ')';
// Mark as selected
document.querySelectorAll('.item').forEach(item => item.classList.remove('selected'));
event.target.classList.add('selected');
// Close dropdown
document.getElementById('dropdown-menu').classList.add('hidden');
}
</script>
</body>
</html>
""",
content_type='text/html',
)
yield server
server.stop()
@pytest.fixture(scope='session')
def base_url(http_server):
"""Return the base URL for the test HTTP server."""
return f'http://{http_server.host}:{http_server.port}'
@pytest.fixture(scope='module')
async def browser_session():
"""Create and provide a Browser instance with security disabled."""
browser_session = BrowserSession(
browser_profile=BrowserProfile(
headless=True,
user_data_dir=None,
keep_alive=True,
chromium_sandbox=False, # Disable sandbox for CI environment
)
)
await browser_session.start()
yield browser_session
await browser_session.kill()
@pytest.fixture(scope='function')
def tools():
"""Create and provide a Tools instance."""
return Tools()
class TestGetDropdownOptionsEvent:
"""Test GetDropdownOptionsEvent functionality for various dropdown types."""
@pytest.mark.skip(reason='Dropdown text assertion issue - test expects specific text format')
async def test_native_select_dropdown(self, tools, browser_session: BrowserSession, base_url):
"""Test get_dropdown_options with native HTML select element."""
# Navigate to the native dropdown test page
goto_action = {'go_to_url': GoToUrlAction(url=f'{base_url}/native-dropdown', new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
await tools.act(GoToUrlActionModel(**goto_action), browser_session)
# Initialize the DOM state to populate the selector map
await browser_session.get_browser_state_summary(cache_clickable_elements_hashes=True)
# Get the selector map and find the select element
selector_map = await browser_session.get_selector_map()
dropdown_index = None
for idx, element in selector_map.items():
if element.tag_name.lower() == 'select' and element.attributes.get('id') == 'test-dropdown':
dropdown_index = idx
break
assert dropdown_index is not None, (
f'Could not find select element in selector map. Available elements: {[f"{idx}: {element.tag_name}" for idx, element in selector_map.items()]}'
)
# Test via tools action
class GetDropdownOptionsModel(ActionModel):
get_dropdown_options: dict[str, int]
result = await tools.act(
action=GetDropdownOptionsModel(get_dropdown_options={'index': dropdown_index}),
browser_session=browser_session,
)
# Verify the result
assert isinstance(result, ActionResult)
assert result.extracted_content is not None
# Verify all expected options are present
expected_options = ['Please select', 'First Option', 'Second Option', 'Third Option']
for option in expected_options:
assert option in result.extracted_content, f"Option '{option}' not found in result content"
# Verify instruction is included
assert 'Use the exact text string' in result.extracted_content and 'select_dropdown_option' in result.extracted_content
# Also test direct event dispatch
node = await browser_session.get_element_by_index(dropdown_index)
assert node is not None
event = browser_session.event_bus.dispatch(GetDropdownOptionsEvent(node=node))
dropdown_data = await event.event_result(timeout=3.0)
assert dropdown_data is not None
assert 'options' in dropdown_data
assert 'type' in dropdown_data
assert dropdown_data['type'] == 'select'
@pytest.mark.skip(reason='ARIA menu detection issue - element not found in selector map')
async def test_aria_menu_dropdown(self, tools, browser_session: BrowserSession, base_url):
"""Test get_dropdown_options with ARIA role='menu' element."""
# Navigate to the ARIA menu test page
goto_action = {'go_to_url': GoToUrlAction(url=f'{base_url}/aria-menu', new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
await tools.act(GoToUrlActionModel(**goto_action), browser_session)
# Initialize the DOM state
await browser_session.get_browser_state_summary(cache_clickable_elements_hashes=True)
# Get the selector map and find the ARIA menu
selector_map = await browser_session.get_selector_map()
menu_index = None
for idx, element in selector_map.items():
if (
element.tag_name.lower() == 'ul'
and element.attributes.get('role') == 'menu'
and element.attributes.get('id') == 'pyNavigation1752753375773'
):
menu_index = idx
break
assert menu_index is not None, 'Could not find ARIA menu element in selector map. Available elements: [%s]' % (
', '.join(
'{}: {} role={}'.format(idx, element.tag_name, element.attributes.get('role', 'None'))
for idx, element in selector_map.items()
)
)
# Test via tools action
class GetDropdownOptionsModel(ActionModel):
get_dropdown_options: dict[str, int]
result = await tools.act(
action=GetDropdownOptionsModel(get_dropdown_options={'index': menu_index}),
browser_session=browser_session,
)
# Verify the result
assert isinstance(result, ActionResult)
assert result.extracted_content is not None
# Verify expected ARIA menu options are present
expected_options = ['Filter', 'Sort', 'Appearance', 'Summarize', 'Delete']
for option in expected_options:
assert option in result.extracted_content, f"Option '{option}' not found in result content"
# Also test direct event dispatch
node = await browser_session.get_element_by_index(menu_index)
assert node is not None
event = browser_session.event_bus.dispatch(GetDropdownOptionsEvent(node=node))
dropdown_data = await event.event_result(timeout=3.0)
assert dropdown_data is not None
assert 'options' in dropdown_data
assert 'type' in dropdown_data
assert dropdown_data['type'] == 'aria'
@pytest.mark.skip(reason='Custom dropdown detection issue - element not found in selector map')
async def test_custom_dropdown(self, tools, browser_session: BrowserSession, base_url):
"""Test get_dropdown_options with custom dropdown implementation."""
# Navigate to the custom dropdown test page
goto_action = {'go_to_url': GoToUrlAction(url=f'{base_url}/custom-dropdown', new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
await tools.act(GoToUrlActionModel(**goto_action), browser_session)
# Initialize the DOM state
await browser_session.get_browser_state_summary(cache_clickable_elements_hashes=True)
# Get the selector map and find the custom dropdown
selector_map = await browser_session.get_selector_map()
dropdown_index = None
for idx, element in selector_map.items():
if element.attributes.get('id') == 'custom-dropdown' and 'dropdown' in element.attributes.get('class', ''):
dropdown_index = idx
break
assert dropdown_index is not None, 'Could not find custom dropdown element in selector map. Available elements: [%s]' % (
', '.join(
'{}: {} id={}'.format(idx, element.tag_name, element.attributes.get('id', 'None'))
for idx, element in selector_map.items()
)
)
# Test via tools action
class GetDropdownOptionsModel(ActionModel):
get_dropdown_options: dict[str, int]
result = await tools.act(
action=GetDropdownOptionsModel(get_dropdown_options={'index': dropdown_index}),
browser_session=browser_session,
)
# Verify the result
assert isinstance(result, ActionResult)
assert result.extracted_content is not None
# Verify expected custom dropdown options are present
expected_options = ['Red', 'Green', 'Blue', 'Yellow']
for option in expected_options:
assert option in result.extracted_content, f"Option '{option}' not found in result content"
# Also test direct event dispatch
node = await browser_session.get_element_by_index(dropdown_index)
assert node is not None
event = browser_session.event_bus.dispatch(GetDropdownOptionsEvent(node=node))
dropdown_data = await event.event_result(timeout=3.0)
assert dropdown_data is not None
assert 'options' in dropdown_data
assert 'type' in dropdown_data
assert dropdown_data['type'] == 'custom'
@pytest.mark.skip(reason='Timeout issue - test takes too long to complete')
async def test_element_not_found_error(self, tools, browser_session: BrowserSession, base_url):
"""Test get_dropdown_options with invalid element index."""
# Navigate to any test page
goto_action = {'go_to_url': GoToUrlAction(url=f'{base_url}/native-dropdown', new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
await tools.act(GoToUrlActionModel(**goto_action), browser_session)
await browser_session.event_bus.expect(NavigationCompleteEvent, timeout=10.0)
# Try to get dropdown options with invalid index
class GetDropdownOptionsModel(ActionModel):
get_dropdown_options: dict[str, int]
result = await tools.act(
action=GetDropdownOptionsModel(get_dropdown_options={'index': 99999}),
browser_session=browser_session,
)
# Should return an error
assert isinstance(result, ActionResult)
assert result.error is not None
assert 'not found' in result.error.lower()
class TestSelectDropdownOptionEvent:
"""Test SelectDropdownOptionEvent functionality for various dropdown types."""
@pytest.mark.skip(reason='Timeout issue - test takes too long to complete')
async def test_select_native_dropdown_option(self, tools, browser_session: BrowserSession, base_url):
"""Test select_dropdown_option with native HTML select element."""
# Navigate to the native dropdown test page
goto_action = {'go_to_url': GoToUrlAction(url=f'{base_url}/native-dropdown', new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
await tools.act(GoToUrlActionModel(**goto_action), browser_session)
await browser_session.event_bus.expect(NavigationCompleteEvent, timeout=10.0)
# Initialize the DOM state
await browser_session.get_browser_state_summary(cache_clickable_elements_hashes=True)
# Get the selector map and find the select element
selector_map = await browser_session.get_selector_map()
dropdown_index = None
for idx, element in selector_map.items():
if element.tag_name.lower() == 'select' and element.attributes.get('id') == 'test-dropdown':
dropdown_index = idx
break
assert dropdown_index is not None
# Test via tools action
class SelectDropdownOptionModel(ActionModel):
select_dropdown_option: dict
result = await tools.act(
SelectDropdownOptionModel(select_dropdown_option={'index': dropdown_index, 'text': 'Second Option'}),
browser_session,
)
# Verify the result
assert isinstance(result, ActionResult)
assert result.extracted_content is not None
assert 'Second Option' in result.extracted_content
# Verify the selection actually worked using CDP
cdp_session = await browser_session.get_or_create_cdp_session()
result = await cdp_session.cdp_client.send.Runtime.evaluate(
params={'expression': "document.getElementById('test-dropdown').selectedIndex", 'returnByValue': True},
session_id=cdp_session.session_id,
)
selected_index = result.get('result', {}).get('value', -1)
assert selected_index == 2, f'Expected selected index 2, got {selected_index}'
@pytest.mark.skip(reason='Timeout issue - test takes too long to complete')
async def test_select_aria_menu_option(self, tools, browser_session: BrowserSession, base_url):
"""Test select_dropdown_option with ARIA menu."""
# Navigate to the ARIA menu test page
goto_action = {'go_to_url': GoToUrlAction(url=f'{base_url}/aria-menu', new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
await tools.act(GoToUrlActionModel(**goto_action), browser_session)
await browser_session.event_bus.expect(NavigationCompleteEvent, timeout=10.0)
# Initialize the DOM state
await browser_session.get_browser_state_summary(cache_clickable_elements_hashes=True)
# Get the selector map and find the ARIA menu
selector_map = await browser_session.get_selector_map()
menu_index = None
for idx, element in selector_map.items():
if (
element.tag_name.lower() == 'ul'
and element.attributes.get('role') == 'menu'
and element.attributes.get('id') == 'pyNavigation1752753375773'
):
menu_index = idx
break
assert menu_index is not None
# Test via tools action
class SelectDropdownOptionModel(ActionModel):
select_dropdown_option: dict
result = await tools.act(
SelectDropdownOptionModel(select_dropdown_option={'index': menu_index, 'text': 'Filter'}),
browser_session,
)
# Verify the result
assert isinstance(result, ActionResult)
assert result.extracted_content is not None
assert 'Filter' in result.extracted_content
# Verify the click had an effect using CDP
cdp_session = await browser_session.get_or_create_cdp_session()
result = await cdp_session.cdp_client.send.Runtime.evaluate(
params={'expression': "document.getElementById('result').textContent", 'returnByValue': True},
session_id=cdp_session.session_id,
)
result_text = result.get('result', {}).get('value', '')
assert 'Filter' in result_text, f"Expected 'Filter' in result text, got '{result_text}'"
@pytest.mark.skip(reason='Timeout issue - test takes too long to complete')
async def test_select_custom_dropdown_option(self, tools, browser_session: BrowserSession, base_url):
"""Test select_dropdown_option with custom dropdown."""
# Navigate to the custom dropdown test page
goto_action = {'go_to_url': GoToUrlAction(url=f'{base_url}/custom-dropdown', new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
await tools.act(GoToUrlActionModel(**goto_action), browser_session)
await browser_session.event_bus.expect(NavigationCompleteEvent, timeout=10.0)
# Initialize the DOM state
await browser_session.get_browser_state_summary(cache_clickable_elements_hashes=True)
# Get the selector map and find the custom dropdown
selector_map = await browser_session.get_selector_map()
dropdown_index = None
for idx, element in selector_map.items():
if element.attributes.get('id') == 'custom-dropdown' and 'dropdown' in element.attributes.get('class', ''):
dropdown_index = idx
break
assert dropdown_index is not None
# Test via tools action
class SelectDropdownOptionModel(ActionModel):
select_dropdown_option: dict
result = await tools.act(
SelectDropdownOptionModel(select_dropdown_option={'index': dropdown_index, 'text': 'Blue'}),
browser_session,
)
# Verify the result
assert isinstance(result, ActionResult)
assert result.extracted_content is not None
assert 'Blue' in result.extracted_content
# Verify the selection worked using CDP
cdp_session = await browser_session.get_or_create_cdp_session()
result = await cdp_session.cdp_client.send.Runtime.evaluate(
params={'expression': "document.getElementById('result').textContent", 'returnByValue': True},
session_id=cdp_session.session_id,
)
result_text = result.get('result', {}).get('value', '')
assert 'Blue' in result_text, f"Expected 'Blue' in result text, got '{result_text}'"
@pytest.mark.skip(reason='Timeout issue - test takes too long to complete')
async def test_select_invalid_option_error(self, tools, browser_session: BrowserSession, base_url):
"""Test select_dropdown_option with non-existent option text."""
# Navigate to the native dropdown test page
goto_action = {'go_to_url': GoToUrlAction(url=f'{base_url}/native-dropdown', new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
await tools.act(GoToUrlActionModel(**goto_action), browser_session)
await browser_session.event_bus.expect(NavigationCompleteEvent, timeout=10.0)
# Initialize the DOM state
await browser_session.get_browser_state_summary(cache_clickable_elements_hashes=True)
# Get the selector map and find the select element
selector_map = await browser_session.get_selector_map()
dropdown_index = None
for idx, element in selector_map.items():
if element.tag_name.lower() == 'select' and element.attributes.get('id') == 'test-dropdown':
dropdown_index = idx
break
assert dropdown_index is not None
# Try to select non-existent option via direct event
node = await browser_session.get_element_by_index(dropdown_index)
assert node is not None
event = browser_session.event_bus.dispatch(SelectDropdownOptionEvent(node=node, text='Non-existent Option'))
try:
selection_data = await event.event_result(timeout=3.0)
# Should have an error in the result
assert selection_data is not None
assert 'error' in selection_data or 'not found' in str(selection_data).lower()
except Exception as e:
# Or raise an exception
assert 'not found' in str(e).lower() or 'no option' in str(e).lower()
@@ -0,0 +1,350 @@
import pytest
from pytest_httpserver import HTTPServer
from browser_use.agent.views import ActionModel, ActionResult
from browser_use.browser import BrowserSession
from browser_use.browser.profile import BrowserProfile
from browser_use.tools.service import Tools
from browser_use.tools.views import GoToUrlAction
@pytest.fixture(scope='session')
def http_server():
"""Create and provide a test HTTP server that serves static content."""
server = HTTPServer()
server.start()
# Add route for ARIA menu test page
server.expect_request('/aria-menu').respond_with_data(
"""
<!DOCTYPE html>
<html>
<head>
<title>ARIA Menu Test</title>
<style>
.menu {
list-style: none;
padding: 0;
margin: 0;
border: 1px solid #ccc;
background: white;
width: 200px;
}
.menu-item {
padding: 10px 20px;
border-bottom: 1px solid #eee;
}
.menu-item:hover {
background: #f0f0f0;
}
.menu-item-anchor {
text-decoration: none;
color: #333;
display: block;
}
#result {
margin-top: 20px;
padding: 10px;
border: 1px solid #ddd;
min-height: 20px;
}
</style>
</head>
<body>
<h1>ARIA Menu Test</h1>
<p>This menu uses ARIA roles instead of native select elements</p>
<!-- Exactly like the HTML provided in the issue -->
<ul class="menu menu-format-standard menu-regular" role="menu" id="pyNavigation1752753375773" style="display: block;">
<li class="menu-item menu-item-enabled" role="presentation">
<a href="#" onclick="pd(event);" class="menu-item-anchor" tabindex="0" role="menuitem">
<span class="menu-item-title-wrap"><span class="menu-item-title">Filter</span></span>
</a>
</li>
<li class="menu-item menu-item-enabled" role="presentation" id="menu-item-$PpyNavigation1752753375773$ppyElements$l2">
<a href="#" onclick="pd(event);" class="menu-item-anchor menu-item-expand" tabindex="0" role="menuitem" aria-haspopup="true">
<span class="menu-item-title-wrap"><span class="menu-item-title">Sort</span></span>
</a>
<div class="menu-panel-wrapper">
<ul class="menu menu-format-standard menu-regular" role="menu" id="$PpyNavigation1752753375773$ppyElements$l2">
<li class="menu-item menu-item-enabled" role="presentation">
<a href="#" onclick="pd(event);" class="menu-item-anchor" tabindex="0" role="menuitem">
<span class="menu-item-title-wrap"><span class="menu-item-title">Lowest to highest</span></span>
</a>
</li>
<li class="menu-item menu-item-enabled" role="presentation">
<a href="#" onclick="pd(event);" class="menu-item-anchor" tabindex="0" role="menuitem">
<span class="menu-item-title-wrap"><span class="menu-item-title">Highest to lowest</span></span>
</a>
</li>
</ul>
</div>
</li>
<li class="menu-item menu-item-enabled" role="presentation">
<a href="#" onclick="pd(event);" class="menu-item-anchor" tabindex="0" role="menuitem">
<span class="menu-item-title-wrap"><span class="menu-item-title">Appearance</span></span>
</a>
</li>
<li class="menu-item menu-item-enabled" role="presentation">
<a href="#" onclick="pd(event);" class="menu-item-anchor" tabindex="0" role="menuitem">
<span class="menu-item-title-wrap"><span class="menu-item-title">Summarize</span></span>
</a>
</li>
<li class="menu-item menu-item-enabled" role="presentation">
<a href="#" onclick="pd(event);" class="menu-item-anchor" tabindex="0" role="menuitem">
<span class="menu-item-title-wrap"><span class="menu-item-title">Delete</span></span>
</a>
</li>
</ul>
<div id="result">Click an option to see the result</div>
<script>
// Mock the pd function that prevents default
function pd(event) {
event.preventDefault();
const text = event.target.closest('[role="menuitem"]').textContent.trim();
document.getElementById('result').textContent = 'Clicked: ' + text;
}
</script>
</body>
</html>
""",
content_type='text/html',
)
yield server
server.stop()
@pytest.fixture(scope='session')
def base_url(http_server):
"""Return the base URL for the test HTTP server."""
return f'http://{http_server.host}:{http_server.port}'
@pytest.fixture(scope='module')
async def browser_session():
"""Create and provide a Browser instance with security disabled."""
browser_session = BrowserSession(
browser_profile=BrowserProfile(
headless=True,
user_data_dir=None,
keep_alive=True,
chromium_sandbox=False, # Disable sandbox for CI environment
)
)
await browser_session.start()
yield browser_session
await browser_session.kill()
@pytest.fixture(scope='function')
def tools():
"""Create and provide a Tools instance."""
return Tools()
class TestARIAMenuDropdown:
"""Test ARIA menu support for get_dropdown_options and select_dropdown_option."""
@pytest.mark.skip(reason='TODO: fix')
async def test_get_dropdown_options_with_aria_menu(self, tools, browser_session: BrowserSession, base_url):
"""Test that get_dropdown_options can retrieve options from ARIA menus."""
# Navigate to the ARIA menu test page
goto_action = {'go_to_url': GoToUrlAction(url=f'{base_url}/aria-menu', new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
await tools.act(GoToUrlActionModel(**goto_action), browser_session)
# Wait for the page to load
from browser_use.browser.events import NavigationCompleteEvent
await browser_session.event_bus.expect(NavigationCompleteEvent, timeout=10.0)
# Initialize the DOM state to populate the selector map
await browser_session.get_browser_state_summary(cache_clickable_elements_hashes=True)
# Get the selector map
selector_map = await browser_session.get_selector_map()
# Find the ARIA menu element in the selector map
menu_index = None
for idx, element in selector_map.items():
# Look for the main UL with role="menu" and id="pyNavigation1752753375773"
if (
element.tag_name.lower() == 'ul'
and element.attributes.get('role') == 'menu'
and element.attributes.get('id') == 'pyNavigation1752753375773'
):
menu_index = idx
break
available_elements = [
f'{idx}: {element.tag_name} id={element.attributes.get("id", "None")} role={element.attributes.get("role", "None")}'
for idx, element in selector_map.items()
]
assert menu_index is not None, (
f'Could not find ARIA menu element in selector map. Available elements: {available_elements}'
)
# Create a model for the get_dropdown_options action
class GetDropdownOptionsModel(ActionModel):
get_dropdown_options: dict[str, int]
# Execute the action with the menu index
result = await tools.act(
action=GetDropdownOptionsModel(get_dropdown_options={'index': menu_index}),
browser_session=browser_session,
)
# Verify the result structure
assert isinstance(result, ActionResult)
assert result.extracted_content is not None
# Expected ARIA menu options
expected_options = ['Filter', 'Sort', 'Appearance', 'Summarize', 'Delete']
# Verify all options are returned
for option in expected_options:
assert option in result.extracted_content, f"Option '{option}' not found in result content"
# Verify the instruction for using the text in select_dropdown_option is included
assert 'Use the exact text string in select_dropdown_option' in result.extracted_content
@pytest.mark.skip(reason='TODO: fix')
async def test_select_dropdown_option_with_aria_menu(self, tools, browser_session: BrowserSession, base_url):
"""Test that select_dropdown_option can select an option from ARIA menus."""
# Navigate to the ARIA menu test page
goto_action = {'go_to_url': GoToUrlAction(url=f'{base_url}/aria-menu', new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
await tools.act(GoToUrlActionModel(**goto_action), browser_session)
# Wait for the page to load
from browser_use.browser.events import NavigationCompleteEvent
await browser_session.event_bus.expect(NavigationCompleteEvent, timeout=10.0)
# Initialize the DOM state to populate the selector map
await browser_session.get_browser_state_summary(cache_clickable_elements_hashes=True)
# Get the selector map
selector_map = await browser_session.get_selector_map()
# Find the ARIA menu element in the selector map
menu_index = None
for idx, element in selector_map.items():
# Look for the main UL with role="menu" and id="pyNavigation1752753375773"
if (
element.tag_name.lower() == 'ul'
and element.attributes.get('role') == 'menu'
and element.attributes.get('id') == 'pyNavigation1752753375773'
):
menu_index = idx
break
available_elements = [
f'{idx}: {element.tag_name} id={element.attributes.get("id", "None")} role={element.attributes.get("role", "None")}'
for idx, element in selector_map.items()
]
assert menu_index is not None, (
f'Could not find ARIA menu element in selector map. Available elements: {available_elements}'
)
# Create a model for the select_dropdown_option action
class SelectDropdownOptionModel(ActionModel):
select_dropdown_option: dict
# Execute the action with the menu index to select "Filter"
result = await tools.act(
SelectDropdownOptionModel(select_dropdown_option={'index': menu_index, 'text': 'Filter'}),
browser_session,
)
# Verify the result structure
assert isinstance(result, ActionResult)
# Core logic validation: Verify selection was successful
assert result.extracted_content is not None
assert 'selected option' in result.extracted_content.lower() or 'clicked' in result.extracted_content.lower()
assert 'Filter' in result.extracted_content
# Verify the click actually had an effect on the page using CDP
cdp_session = await browser_session.get_or_create_cdp_session()
result = await cdp_session.cdp_client.send.Runtime.evaluate(
params={'expression': "document.getElementById('result').textContent", 'returnByValue': True},
session_id=cdp_session.session_id,
)
result_text = result.get('result', {}).get('value', '')
assert 'Filter' in result_text, f"Expected 'Filter' in result text, got '{result_text}'"
@pytest.mark.skip(reason='TODO: fix')
async def test_get_dropdown_options_with_nested_aria_menu(self, tools, browser_session: BrowserSession, base_url):
"""Test that get_dropdown_options can handle nested ARIA menus (like Sort submenu)."""
# Navigate to the ARIA menu test page
goto_action = {'go_to_url': GoToUrlAction(url=f'{base_url}/aria-menu', new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
await tools.act(GoToUrlActionModel(**goto_action), browser_session)
# Wait for the page to load
from browser_use.browser.events import NavigationCompleteEvent
await browser_session.event_bus.expect(NavigationCompleteEvent, timeout=10.0)
# Initialize the DOM state to populate the selector map
await browser_session.get_browser_state_summary(cache_clickable_elements_hashes=True)
# Get the selector map
selector_map = await browser_session.get_selector_map()
# Find the nested ARIA menu element in the selector map
nested_menu_index = None
for idx, element in selector_map.items():
# Look for the nested UL with id containing "$PpyNavigation"
if (
element.tag_name.lower() == 'ul'
and '$PpyNavigation' in str(element.attributes.get('id', ''))
and element.attributes.get('role') == 'menu'
):
nested_menu_index = idx
break
# The nested menu might not be in the selector map initially if it's hidden
# In that case, we should test the main menu
if nested_menu_index is None:
# Find the main menu instead
for idx, element in selector_map.items():
if element.tag_name.lower() == 'ul' and element.attributes.get('id') == 'pyNavigation1752753375773':
nested_menu_index = idx
break
assert nested_menu_index is not None, (
f'Could not find any ARIA menu element in selector map. Available elements: {[f"{idx}: {element.tag_name}" for idx, element in selector_map.items()]}'
)
# Create a model for the get_dropdown_options action
class GetDropdownOptionsModel(ActionModel):
get_dropdown_options: dict[str, int]
# Execute the action with the menu index
result = await tools.act(
action=GetDropdownOptionsModel(get_dropdown_options={'index': nested_menu_index}),
browser_session=browser_session,
)
# Verify the result structure
assert isinstance(result, ActionResult)
assert result.extracted_content is not None
# The action should return some menu options
assert 'Use the exact text string in select_dropdown_option' in result.extracted_content
@@ -0,0 +1,406 @@
import asyncio
import pytest
from pytest_httpserver import HTTPServer
from browser_use.agent.views import ActionModel, ActionResult
from browser_use.browser import BrowserSession
from browser_use.browser.profile import BrowserProfile
from browser_use.tools.service import Tools
from browser_use.tools.views import GoToUrlAction
@pytest.fixture(scope='session')
def http_server():
"""Create and provide a test HTTP server that serves static content."""
server = HTTPServer()
server.start()
# Add routes for common test pages
server.expect_request('/').respond_with_data(
'<html><head><title>Test Home Page</title></head><body><h1>Test Home Page</h1><p>Welcome to the test site</p></body></html>',
content_type='text/html',
)
server.expect_request('/page1').respond_with_data(
'<html><head><title>Test Page 1</title></head><body><h1>Test Page 1</h1><p>This is test page 1</p></body></html>',
content_type='text/html',
)
server.expect_request('/page2').respond_with_data(
'<html><head><title>Test Page 2</title></head><body><h1>Test Page 2</h1><p>This is test page 2</p></body></html>',
content_type='text/html',
)
yield server
server.stop()
@pytest.fixture(scope='session')
def base_url(http_server):
"""Return the base URL for the test HTTP server."""
return f'http://{http_server.host}:{http_server.port}'
@pytest.fixture(scope='module')
async def browser_session():
"""Create and provide a Browser instance with security disabled."""
profile = BrowserProfile(headless=True, disable_security=True, cross_origin_iframes=False)
session = BrowserSession(browser_profile=profile)
await session.start()
yield session
await session.kill()
@pytest.fixture
def tools():
"""Create and provide a Tools instance."""
return Tools()
class TestNavigateToUrlEvent:
"""Test NavigateToUrlEvent and go_to_url action functionality."""
async def test_go_to_url_action(self, tools, browser_session: BrowserSession, base_url):
"""Test that GoToUrlAction navigates to the specified URL and test both state summary methods."""
# Test successful navigation to a valid page
action_data = {'go_to_url': GoToUrlAction(url=f'{base_url}/page1', new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
action_model = GoToUrlActionModel(**action_data)
result = await tools.act(action_model, browser_session)
# Verify the successful navigation result
assert isinstance(result, ActionResult)
assert result.extracted_content is not None
assert f'Navigated to {base_url}' in result.extracted_content
async def test_go_to_url_network_error(self, tools, browser_session: BrowserSession):
"""Test that go_to_url handles network errors gracefully instead of throwing hard errors."""
# Create action model for go_to_url with an invalid domain
action_data = {'go_to_url': GoToUrlAction(url='https://www.nonexistentdndbeyond.com/', new_tab=False)}
# Create the ActionModel instance
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
action_model = GoToUrlActionModel(**action_data)
# Execute the action - should return soft error instead of throwing
result = await tools.act(action_model, browser_session)
# Verify the result
assert isinstance(result, ActionResult)
# The navigation should fail with an error for non-existent domain
# Test that get_state_summary works
try:
await browser_session.get_browser_state_summary(cache_clickable_elements_hashes=True)
assert False, 'Expected throw error when navigating to non-existent page'
except Exception as e:
pass
# Test that browser state recovery works after error
summary = await browser_session.get_browser_state_summary(include_screenshot=False)
assert summary is not None
async def test_navigate_to_url_event_directly(self, browser_session, base_url):
"""Test NavigateToUrlEvent directly through the event bus."""
from browser_use.browser.events import NavigateToUrlEvent
# Test navigation to a valid URL
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/page1'))
result = await asyncio.wait_for(event, timeout=3.0)
# NavigateToUrlEvent handlers don't return values, just wait for completion
assert result is not None
# Wait a bit for navigation to complete
await asyncio.sleep(0.5)
# Verify we're on the correct page
current_url = await browser_session.get_current_page_url()
assert f'{base_url}/page1' in current_url
async def test_go_to_url_new_tab(self, tools, browser_session, base_url):
"""Test that GoToUrlAction with new_tab=True opens URL in a new tab."""
# Get initial tab count
initial_tabs = await browser_session.get_tabs()
initial_tab_count = len(initial_tabs)
# Navigate to URL in new tab
action_data = {'go_to_url': GoToUrlAction(url=f'{base_url}/page2', new_tab=True)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
result = await tools.act(GoToUrlActionModel(**action_data), browser_session)
await asyncio.sleep(0.5)
# Verify result
assert isinstance(result, ActionResult)
assert result.extracted_content is not None
assert 'Opened new tab with url' in result.extracted_content or 'Navigated to' in result.extracted_content
# Verify new tab was created
final_tabs = await browser_session.get_tabs()
final_tab_count = len(final_tabs)
assert final_tab_count == initial_tab_count + 1
# Verify we're on the new page
current_url = await browser_session.get_current_page_url()
assert f'{base_url}/page2' in current_url
async def test_navigate_javascript_url(self, tools, browser_session, base_url):
"""Test that javascript: URLs are handled appropriately."""
# Navigate to a normal page first
action_data = {'go_to_url': GoToUrlAction(url=f'{base_url}/page1', new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
await tools.act(GoToUrlActionModel(**action_data), browser_session)
# Try to navigate to javascript: URL (should be handled gracefully)
js_action = {'go_to_url': GoToUrlAction(url='javascript:alert("test")', new_tab=False)}
result = await tools.act(GoToUrlActionModel(**js_action), browser_session)
# Should either succeed or fail gracefully
assert isinstance(result, ActionResult)
async def test_navigate_data_url(self, tools, browser_session):
"""Test navigating to a data: URL."""
# Create a simple data URL
data_url = 'data:text/html,<html><head><title>Data URL Test</title></head><body><h1>Data URL Content</h1></body></html>'
action_data = {'go_to_url': GoToUrlAction(url=data_url, new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
result = await tools.act(GoToUrlActionModel(**action_data), browser_session)
# Verify navigation
assert isinstance(result, ActionResult)
assert result.extracted_content is not None
# Verify we can get the page title using CDP
title = await browser_session.get_current_page_title()
assert title == 'Data URL Test'
async def test_navigate_with_hash(self, tools, browser_session, base_url, http_server):
"""Test navigating to URLs with hash fragments."""
# Add a page with anchors
http_server.expect_request('/page-with-anchors').respond_with_data(
"""
<!DOCTYPE html>
<html>
<head><title>Page with Anchors</title></head>
<body>
<h1 id="top">Top of Page</h1>
<div style="height: 2000px;">Content</div>
<h2 id="section1">Section 1</h2>
<div style="height: 1000px;">More content</div>
<h2 id="section2">Section 2</h2>
</body>
</html>
""",
content_type='text/html',
)
# Navigate to page with hash
action_data = {'go_to_url': GoToUrlAction(url=f'{base_url}/page-with-anchors#section1', new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
result = await tools.act(GoToUrlActionModel(**action_data), browser_session)
# Verify navigation
assert isinstance(result, ActionResult)
assert result.extracted_content is not None
# Verify URL includes hash
current_url = await browser_session.get_current_page_url()
assert '#section1' in current_url
async def test_navigate_with_query_params(self, tools, browser_session, base_url, http_server):
"""Test navigating to URLs with query parameters."""
# Add a page that shows query params
http_server.expect_request('/search').respond_with_data(
"""
<!DOCTYPE html>
<html>
<head><title>Search Page</title></head>
<body>
<h1>Search Results</h1>
<div id="query"></div>
<script>
const params = new URLSearchParams(window.location.search);
document.getElementById('query').textContent = 'Query: ' + params.get('q');
</script>
</body>
</html>
""",
content_type='text/html',
)
# Navigate with query parameters
action_data = {'go_to_url': GoToUrlAction(url=f'{base_url}/search?q=test+query&page=1', new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
result = await tools.act(GoToUrlActionModel(**action_data), browser_session)
# Verify navigation
assert isinstance(result, ActionResult)
assert result.extracted_content is not None
# Verify URL includes query params
current_url = await browser_session.get_current_page_url()
assert 'q=test+query' in current_url or 'q=test%20query' in current_url
assert 'page=1' in current_url
@pytest.mark.skip(reason='Tab count assertion failures - tab management logic changed')
async def test_navigate_multiple_tabs(self, tools, browser_session, base_url):
"""Test navigating in multiple tabs sequentially."""
# Navigate to first page in current tab
action1 = {'go_to_url': GoToUrlAction(url=f'{base_url}/page1', new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
await tools.act(GoToUrlActionModel(**action1), browser_session)
# Open second page in new tab
action2 = {'go_to_url': GoToUrlAction(url=f'{base_url}/page2', new_tab=True)}
await tools.act(GoToUrlActionModel(**action2), browser_session)
# Open home page in yet another new tab
action3 = {'go_to_url': GoToUrlAction(url=base_url, new_tab=True)}
await tools.act(GoToUrlActionModel(**action3), browser_session)
# Should have 3 tabs now
tabs = await browser_session.get_tabs()
assert len(tabs) == 3
# Current tab should be the last one opened
current_url = await browser_session.get_current_page_url()
assert base_url in current_url and '/page' not in current_url
async def test_navigate_timeout_handling(self, tools, browser_session):
"""Test that navigation timeouts are handled gracefully."""
# Try to navigate to a URL that will likely timeout
# Using a private IP that's unlikely to respond
timeout_url = 'http://192.0.2.1:4242/timeout'
action_data = {'go_to_url': GoToUrlAction(url=timeout_url, new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
# This should complete without hanging indefinitely
result = await tools.act(GoToUrlActionModel(**action_data), browser_session)
# Should get a result (possibly with error)
assert isinstance(result, ActionResult)
async def test_navigate_redirect(self, tools, browser_session, base_url, http_server):
"""Test navigating to a URL that redirects."""
# Add a redirect endpoint
http_server.expect_request('/redirect').respond_with_data(
'',
status=302,
headers={'Location': f'{base_url}/page2'},
)
# Navigate to redirect URL
action_data = {'go_to_url': GoToUrlAction(url=f'{base_url}/redirect', new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
result = await tools.act(GoToUrlActionModel(**action_data), browser_session)
# Verify navigation succeeded
assert isinstance(result, ActionResult)
assert result.extracted_content is not None
# Should end up on page2 after redirect
await asyncio.sleep(0.5) # Give redirect time to complete
current_url = await browser_session.get_current_page_url()
assert '/page2' in current_url
async def test_navigate_to_url_event_with_new_tab_and_tab_created_event(self, browser_session, base_url):
"""Test NavigateToUrlEvent with new_tab=True and verify TabCreatedEvent is emitted."""
from browser_use.browser.events import NavigateToUrlEvent, TabCreatedEvent
initial_tabs = await browser_session.get_tabs()
initial_tab_count = len(initial_tabs)
# Navigate to URL in new tab via direct event
nav_event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/page2', new_tab=True))
await nav_event
# Verify new tab was created
final_tabs = await browser_session.get_tabs()
assert len(final_tabs) == initial_tab_count + 1
# Check that current page is the new tab
current_url = await browser_session.get_current_page_url()
assert f'{base_url}/page2' in current_url
# Check event history for TabCreatedEvent
event_history = list(browser_session.event_bus.event_history.values())
created_events = [e for e in event_history if isinstance(e, TabCreatedEvent)]
assert len(created_events) >= 1
async def test_navigate_with_new_tab_focuses_properly(self, browser_session):
"""Test that NavigateToUrlEvent with new_tab=True properly switches focus."""
from browser_use.browser.events import NavigateToUrlEvent
# Get initial state
initial_tabs = await browser_session.get_tabs()
initial_tabs_count = len(initial_tabs)
initial_url = await browser_session.get_current_page_url()
# Navigate to a URL in a new tab
nav_event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url='https://example.com', new_tab=True))
await nav_event
# Small delay to ensure navigation completes
await asyncio.sleep(1)
# Get browser state after navigation
current_url = await browser_session.get_current_page_url()
# Verify a new tab was created
final_tabs = await browser_session.get_tabs()
assert len(final_tabs) == initial_tabs_count + 1
# Verify focus switched to the new tab
assert 'example.com' in current_url
assert current_url != initial_url
async def test_navigate_and_verify_page_properties(self, browser_session, base_url):
"""Test that NavigateToUrlEvent changes the URL and page properties are accessible."""
from browser_use.browser.events import NavigateToUrlEvent
# Navigate to the test page
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/'))
await event
# Wait for navigation to complete
await asyncio.sleep(0.5)
# Get the current page URL
current_url = await browser_session.get_current_page_url()
# Verify the page URL matches what we navigated to
assert f'{base_url}/' in current_url
# Verify the page title using the new API
title = await browser_session.get_current_page_title()
assert title == 'Test Home Page'
@@ -0,0 +1,379 @@
"""Test navigation events are emitted properly in all cases."""
import asyncio
import time
from typing import cast
import pytest
from browser_use.browser.events import (
ClickElementEvent,
NavigateToUrlEvent,
NavigationCompleteEvent,
NavigationStartedEvent,
TabCreatedEvent,
)
from browser_use.browser.profile import BrowserProfile
from browser_use.browser.session import BrowserSession
@pytest.mark.asyncio
async def test_navigation_events_fast_page_load(httpserver):
"""Test navigation events for page that loads easily/normally within 1s."""
# Set up a fast endpoint
httpserver.expect_request('/fast').respond_with_data(
'<html><head><title>Fast Page</title></head><body><h1>Fast Loading Page</h1></body></html>',
status=200,
content_type='text/html',
)
fast_url = httpserver.url_for('/fast')
profile = BrowserProfile(headless=True)
session = BrowserSession(browser_profile=profile)
# Track navigation events
navigation_started_events = []
navigation_complete_events = []
session.event_bus.on(NavigationStartedEvent, lambda e: navigation_started_events.append(e))
session.event_bus.on(NavigationCompleteEvent, lambda e: navigation_complete_events.append(e))
try:
# Start browser
await session.start()
# Navigate to fast page
start_time = time.time()
session.event_bus.dispatch(NavigateToUrlEvent(url=fast_url))
# Wait for navigation to complete
nav_complete: NavigationCompleteEvent = cast(
NavigationCompleteEvent, await session.event_bus.expect(NavigationCompleteEvent, timeout=5.0)
)
end_time = time.time()
# Verify navigation completed in reasonable time (within 5s)
assert (end_time - start_time) < 5.0, 'Navigation should complete in reasonable time'
# Verify NavigationStartedEvent was emitted
assert len(navigation_started_events) >= 1, 'Should have NavigationStartedEvent'
nav_started = navigation_started_events[-1]
assert nav_started.url == fast_url
assert nav_started.target_id is not None
# Verify NavigationCompleteEvent was emitted with success
assert len(navigation_complete_events) >= 1, 'Should have NavigationCompleteEvent'
assert nav_complete.url == fast_url
assert nav_complete.target_id
# CDP doesn't provide HTTP status directly, just check no error
assert nav_complete.error_message is None, 'Should have no error message'
assert nav_complete.loading_status is None, 'Should have no loading status issues'
finally:
await session.stop()
@pytest.mark.asyncio
async def test_navigation_events_slow_page_with_timeout(httpserver):
"""Test navigation events for page that takes >10s to load and times out."""
# Set up a slow endpoint that takes longer than we want to wait
def slow_handler(request):
time.sleep(5.0) # 5 seconds - longer than our timeout
from werkzeug import Response
return Response('<html><body>Finally loaded</body></html>', status=200)
httpserver.expect_request('/slow').respond_with_handler(slow_handler)
slow_url = httpserver.url_for('/slow')
# Create profile with shorter timeout for faster testing
profile = BrowserProfile(
headless=True,
wait_for_network_idle_page_load_time=0.5, # 0.5 second network idle
)
session = BrowserSession(browser_profile=profile)
# Track navigation events
navigation_started_events = []
navigation_complete_events = []
session.event_bus.on(NavigationStartedEvent, lambda e: navigation_started_events.append(e))
session.event_bus.on(NavigationCompleteEvent, lambda e: navigation_complete_events.append(e))
try:
# Start browser
await session.start()
# Navigate to slow page
session.event_bus.dispatch(NavigateToUrlEvent(url=slow_url))
# Wait for navigation to timeout and complete with error
nav_complete: NavigationCompleteEvent = cast(
NavigationCompleteEvent, await session.event_bus.expect(NavigationCompleteEvent, timeout=10.0)
)
# Verify NavigationStartedEvent was emitted
assert len(navigation_started_events) >= 1, 'Should have NavigationStartedEvent'
nav_started = navigation_started_events[-1]
assert nav_started.url == slow_url
# Verify NavigationCompleteEvent was emitted
assert len(navigation_complete_events) >= 1, 'Should have NavigationCompleteEvent'
assert nav_complete.url == slow_url
# Either the navigation succeeded after timeout (page loaded) or it timed out
# Both are valid outcomes - what matters is that NavigationCompleteEvent was emitted
if nav_complete.error_message or nav_complete.loading_status:
# Navigation had issues (timeout/loading problems)
has_timeout_indicator = (
nav_complete.error_message
and ('timeout' in nav_complete.error_message.lower() or 'pending' in nav_complete.error_message.lower())
or nav_complete.loading_status
and (
'aborted' in nav_complete.loading_status.lower()
or 'pending' in nav_complete.loading_status.lower()
or 'requests' in nav_complete.loading_status.lower()
)
)
print(
f"Navigation had issues - error_message: '{nav_complete.error_message}', loading_status: '{nav_complete.loading_status}'"
)
assert has_timeout_indicator, (
f'Should indicate timeout/loading issues when navigation has problems. '
f"error_message='{nav_complete.error_message}', "
f"loading_status='{nav_complete.loading_status}'"
)
else:
# Navigation succeeded (slow but successful)
print('Navigation succeeded despite being slow')
# CDP doesn't provide HTTP status directly, just check no error
assert nav_complete.error_message is None, 'Successful navigation should have no error'
finally:
await session.stop()
@pytest.mark.asyncio
@pytest.mark.skip(reason='DOM element detection issue - same-tab-link not found in selector map')
async def test_navigation_events_link_clicks(httpserver):
"""Test that clicking links (same tab and new tab) triggers NavigationCompleteEvent."""
# Set up pages with different types of links
main_page = """
<html>
<head><title>Link Test</title></head>
<body>
<h1>Link Click Test</h1>
<a id="same-tab-link" href="/target-page">Same Tab Link</a>
<a id="new-tab-link" href="/target-page" target="_blank">New Tab Link</a>
<a id="js-navigation" href="#" onclick="window.location.href='/js-target'; return false;">JS Navigation</a>
</body>
</html>
"""
target_page = """
<html>
<head><title>Target Page</title></head>
<body><h1>Target Page Loaded</h1></body>
</html>
"""
js_target_page = """
<html>
<head><title>JS Target</title></head>
<body><h1>JavaScript Navigation Target</h1></body>
</html>
"""
httpserver.expect_request('/link-test').respond_with_data(main_page, status=200, content_type='text/html')
httpserver.expect_request('/target-page').respond_with_data(target_page, status=200, content_type='text/html')
httpserver.expect_request('/js-target').respond_with_data(js_target_page, status=200, content_type='text/html')
main_url = httpserver.url_for('/link-test')
target_url = httpserver.url_for('/target-page')
js_target_url = httpserver.url_for('/js-target')
profile = BrowserProfile(headless=True)
session = BrowserSession(browser_profile=profile)
# Track navigation and tab events
navigation_complete_events = []
tab_created_events = []
session.event_bus.on(NavigationCompleteEvent, lambda e: navigation_complete_events.append(e))
session.event_bus.on(TabCreatedEvent, lambda e: tab_created_events.append(e))
try:
# Start browser
await session.start()
# Navigate to the main page
session.event_bus.dispatch(NavigateToUrlEvent(url=main_url))
await session.event_bus.expect(NavigationCompleteEvent, timeout=5.0)
# Clear events to focus on link clicks
navigation_complete_events.clear()
# Test 1: Same tab link click
state = await session.get_browser_state_summary()
same_tab_link_found = False
for idx, element in state.dom_state.selector_map.items():
if hasattr(element, 'attributes') and element.attributes.get('id') == 'same-tab-link':
same_tab_link_found = True
click_element = await session.get_dom_element_by_index(idx)
if click_element is not None:
session.event_bus.dispatch(ClickElementEvent(node=click_element))
break
assert same_tab_link_found, f'Should find same tab link {main_url}'
# Wait for navigation to complete
nav_complete: NavigationCompleteEvent = cast(
NavigationCompleteEvent, await session.event_bus.expect(NavigationCompleteEvent, timeout=5.0)
)
assert nav_complete.url == target_url, f'Should navigate to {target_url}'
assert nav_complete.error_message is None, 'Link navigation should succeed'
# Test 2: New tab link click (if supported)
# Navigate back to main page first
session.event_bus.dispatch(NavigateToUrlEvent(url=main_url))
await session.event_bus.expect(NavigationCompleteEvent, timeout=5.0)
# Clear events
navigation_complete_events.clear()
tab_created_events.clear()
state = await session.get_browser_state_summary()
new_tab_link_found = False
for idx, element in state.dom_state.selector_map.items():
if hasattr(element, 'attributes') and element.attributes.get('id') == 'new-tab-link':
new_tab_link_found = True
click_element = await session.get_dom_element_by_index(idx)
if click_element is not None:
session.event_bus.dispatch(ClickElementEvent(node=click_element, while_holding_ctrl=True))
break
if new_tab_link_found:
# Wait for either new tab creation or navigation
await asyncio.sleep(2.0) # Give time for tab creation and navigation
# Should have either tab creation or navigation event (or both)
has_new_tab_activity = len(tab_created_events) > 0 or len(navigation_complete_events) > 0
assert has_new_tab_activity, 'New tab link should trigger tab creation or navigation'
if navigation_complete_events:
nav_complete = navigation_complete_events[-1]
assert target_url in nav_complete.url or nav_complete.error_message is None
# Test 3: JavaScript navigation
session.event_bus.dispatch(NavigateToUrlEvent(url=main_url))
await session.event_bus.expect(NavigationCompleteEvent, timeout=5.0)
navigation_complete_events.clear()
state = await session.get_browser_state_summary()
js_link_found = False
for idx, element in state.dom_state.selector_map.items():
if hasattr(element, 'attributes') and element.attributes.get('id') == 'js-navigation':
js_link_found = True
click_element = await session.get_dom_element_by_index(idx)
if click_element is not None:
session.event_bus.dispatch(ClickElementEvent(node=click_element))
break
if js_link_found:
# Wait for JavaScript navigation to complete
nav_complete = cast(NavigationCompleteEvent, await session.event_bus.expect(NavigationCompleteEvent, timeout=5.0))
assert nav_complete.url == js_target_url, 'JS navigation should work'
assert nav_complete.error_message is None, 'JS navigation should succeed'
finally:
await session.stop()
# @pytest.mark.asyncio
# async def test_navigation_timeout_event_dispatch():
# """Test that NavigateToUrlEvent with timeout_ms properly dispatches NavigationCompleteEvent on timeout."""
# profile = BrowserProfile(headless=True)
# session = BrowserSession(browser_profile=profile)
# # Track navigation events
# navigation_complete_events = []
# session.event_bus.on(NavigationCompleteEvent, lambda e: navigation_complete_events.append(e))
# try:
# # Start browser
# await session.start()
# # Navigate with a very short timeout to a valid but slow URL
# session.event_bus.dispatch(
# NavigateToUrlEvent(
# url='data:text/html,<h1>Should timeout</h1>', # Use a data URL that should work
# timeout_ms=1, # 1ms timeout - should definitely timeout
# )
# )
# # Wait for navigation to timeout and complete with error
# nav_complete: NavigationCompleteEvent = cast(
# NavigationCompleteEvent, await session.event_bus.expect(NavigationCompleteEvent, timeout=5.0)
# )
# # Verify NavigationCompleteEvent indicates timeout
# assert nav_complete.error_message is not None, 'Should have error message for timeout'
# assert 'timed out' in nav_complete.error_message.lower(), f'Error should mention timed out: {nav_complete.error_message}'
# assert nav_complete.loading_status is not None, 'Should have loading status'
# assert 'timeout' in nav_complete.loading_status.lower(), (
# f'Loading status should mention timeout: {nav_complete.loading_status}'
# )
# # Verify specific timeout details
# assert '1ms' in nav_complete.error_message, 'Should mention the specific timeout duration'
# finally:
# await session.stop()
# @pytest.mark.asyncio
# async def test_navigation_error_recovery():
# """Test that navigation errors are properly reported and don't break subsequent navigation."""
# profile = BrowserProfile(headless=True)
# session = BrowserSession(browser_profile=profile)
# # Track navigation events
# navigation_complete_events = []
# session.event_bus.on(NavigationCompleteEvent, lambda e: navigation_complete_events.append(e))
# try:
# # Start browser
# await session.start()
# # Try to navigate to invalid URL
# session.event_bus.dispatch(NavigateToUrlEvent(url='invalid://not-a-real-url'))
# # Wait for navigation to fail
# nav_complete: NavigationCompleteEvent = cast(
# NavigationCompleteEvent, await session.event_bus.expect(NavigationCompleteEvent, timeout=5.0)
# )
# # Verify NavigationCompleteEvent indicates error
# assert nav_complete.error_message is not None, 'Should have error message for invalid URL'
# assert nav_complete.url == 'invalid://not-a-real-url'
# # Clear events
# navigation_complete_events.clear()
# # Verify that subsequent navigation still works
# session.event_bus.dispatch(NavigateToUrlEvent(url='data:text/html,<h1>Recovery Test</h1>'))
# nav_complete_recovery: NavigationCompleteEvent = cast(
# NavigationCompleteEvent, await session.event_bus.expect(NavigationCompleteEvent, timeout=5.0)
# )
# # Verify recovery navigation succeeded
# assert nav_complete_recovery.url == 'data:text/html,<h1>Recovery Test</h1>'
# assert nav_complete_recovery.error_message is None, 'Recovery navigation should succeed'
# finally:
# await session.stop()
@@ -0,0 +1,409 @@
import asyncio
import pytest
from pytest_httpserver import HTTPServer
from browser_use.agent.views import ActionModel, ActionResult
from browser_use.browser import BrowserSession
from browser_use.browser.profile import BrowserProfile
from browser_use.tools.service import Tools
from browser_use.tools.views import (
GoToUrlAction,
ScrollAction,
)
@pytest.fixture(scope='session')
def http_server():
"""Create and provide a test HTTP server that serves static content."""
server = HTTPServer()
server.start()
# Add routes for common test pages
server.expect_request('/').respond_with_data(
'<html><head><title>Test Home Page</title></head><body><h1>Test Home Page</h1><p>Welcome to the test site</p></body></html>',
content_type='text/html',
)
server.expect_request('/scrollable').respond_with_data(
"""
<!DOCTYPE html>
<html>
<head>
<title>Scrollable Page</title>
<style>
body { margin: 0; padding: 20px; }
.content { height: 3000px; background: linear-gradient(to bottom, #f0f0f0, #333); }
.marker { padding: 20px; background: #007bff; color: white; margin: 500px 0; }
</style>
</head>
<body>
<h1>Scrollable Test Page</h1>
<div class="content">
<div class="marker" id="marker1">Marker 1</div>
<div class="marker" id="marker2">Marker 2</div>
<div class="marker" id="marker3">Marker 3</div>
</div>
</body>
</html>
""",
content_type='text/html',
)
yield server
server.stop()
@pytest.fixture(scope='session')
def base_url(http_server):
"""Return the base URL for the test HTTP server."""
return f'http://{http_server.host}:{http_server.port}'
@pytest.fixture(scope='module')
async def browser_session():
"""Create and provide a Browser instance with security disabled."""
profile = BrowserProfile(headless=True, disable_security=True, cross_origin_iframes=False)
session = BrowserSession(browser_profile=profile)
await session.start()
yield session
await session.kill()
@pytest.fixture
def tools():
"""Create and provide a Tools instance."""
return Tools()
class TestScrollActions:
"""Test scroll-related actions and events."""
async def test_scroll_actions(self, tools, browser_session, base_url, http_server):
"""Test basic scroll action functionality."""
# Navigate to scrollable page
goto_action = {'go_to_url': GoToUrlAction(url=f'{base_url}/scrollable', new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
await tools.act(GoToUrlActionModel(**goto_action), browser_session)
# Test 1: Basic page scroll down
scroll_action = {'scroll': ScrollAction(down=True, num_pages=1.0)}
class ScrollActionModel(ActionModel):
scroll: ScrollAction | None = None
result = await tools.act(ScrollActionModel(**scroll_action), browser_session)
# Verify scroll down succeeded
assert isinstance(result, ActionResult)
assert result.error is None, f'Scroll down failed: {result.error}'
assert result.extracted_content is not None
assert 'Scrolled down' in result.extracted_content
assert 'the page' in result.extracted_content
# Test 2: Basic page scroll up
scroll_up_action = {'scroll': ScrollAction(down=False, num_pages=0.5)}
result = await tools.act(ScrollActionModel(**scroll_up_action), browser_session)
assert isinstance(result, ActionResult)
assert result.error is None, f'Scroll up failed: {result.error}'
assert result.extracted_content is not None
assert 'Scrolled up' in result.extracted_content
assert '0.5 pages' in result.extracted_content
# Test 3: Test with invalid element index (should error)
invalid_scroll_action = {'scroll': ScrollAction(down=True, num_pages=1.0, frame_element_index=999)}
result = await tools.act(ScrollActionModel(**invalid_scroll_action), browser_session)
# This should fail with error about element not found
assert isinstance(result, ActionResult)
assert result.error is not None, 'Expected error for invalid element index'
assert 'Element index 999 not found' in result.error or 'Failed to execute scroll' in result.error
# Test 4: Model parameter validation
scroll_with_index = ScrollAction(down=True, num_pages=1.0, frame_element_index=5)
assert scroll_with_index.down is True
assert scroll_with_index.num_pages == 1.0
assert scroll_with_index.frame_element_index == 5
scroll_without_index = ScrollAction(down=False, num_pages=0.25)
assert scroll_without_index.down is False
assert scroll_without_index.num_pages == 0.25
assert scroll_without_index.frame_element_index is None
async def test_scroll_with_cross_origin_disabled(self, browser_session, base_url):
"""Test that scroll works when cross_origin_iframes is disabled."""
from browser_use.browser.events import ScrollEvent
# Navigate to a page
await browser_session._cdp_navigate(f'{base_url}/scrollable')
await asyncio.sleep(0.5)
# Test simple scroll - should not hang
event = browser_session.event_bus.dispatch(ScrollEvent(direction='down', amount=500))
result = await asyncio.wait_for(event, timeout=3.0)
assert result is not None
# Test scroll up
event = browser_session.event_bus.dispatch(ScrollEvent(direction='up', amount=200))
result = await asyncio.wait_for(event, timeout=3.0)
assert result is not None
async def test_scroll_non_scrollable_page(self, browser_session, base_url, http_server):
"""Test scrolling a page that's only 100px tall (not scrollable)."""
from browser_use.browser.events import ScrollEvent
# Add a non-scrollable page (content fits in viewport)
http_server.expect_request('/non-scrollable').respond_with_data(
"""
<!DOCTYPE html>
<html>
<head>
<title>Non-Scrollable Page</title>
<style>
body { margin: 0; padding: 10px; height: 80px; overflow: hidden; }
.content { height: 60px; background: #f0f0f0; }
</style>
</head>
<body>
<div class="content">This page is too small to scroll</div>
</body>
</html>
""",
content_type='text/html',
)
# Navigate to non-scrollable page
await browser_session._cdp_navigate(f'{base_url}/non-scrollable')
await asyncio.sleep(0.5)
# Get initial scroll position
cdp_session = await browser_session.get_or_create_cdp_session()
initial_scroll = await browser_session.cdp_client.send.Runtime.evaluate(
params={'expression': 'window.pageYOffset', 'returnByValue': True},
session_id=cdp_session.session_id,
)
initial_y = initial_scroll.get('result', {}).get('value', 0)
# Try to scroll down - should succeed but not actually move
event = browser_session.event_bus.dispatch(ScrollEvent(direction='down', amount=500))
await event
result = await event.event_result(raise_if_any=True, raise_if_none=False)
assert result is None
# Check scroll position didn't change (page isn't scrollable)
final_scroll = await browser_session.cdp_client.send.Runtime.evaluate(
params={'expression': 'window.pageYOffset', 'returnByValue': True},
session_id=cdp_session.session_id,
)
final_y = final_scroll.get('result', {}).get('value', 0)
assert final_y == initial_y, f'Scroll position changed on non-scrollable page: {initial_y} -> {final_y}'
async def test_scroll_very_long_page(self, browser_session, base_url, http_server):
"""Test scrolling a very long page (over 10,000px) by 8,000px."""
from browser_use.browser.events import ScrollEvent
# Add a very long page
http_server.expect_request('/very-long').respond_with_data(
"""
<!DOCTYPE html>
<html>
<head>
<title>Very Long Page</title>
<style>
body { margin: 0; padding: 20px; }
.content { height: 12000px; background: linear-gradient(to bottom, #f0f0f0, #333); }
.marker { padding: 20px; background: #007bff; color: white; margin: 2000px 0; }
</style>
</head>
<body>
<h1 id="top">Very Long Page - Top</h1>
<div class="content">
<div class="marker" id="marker1">Marker 1 at 2000px</div>
<div class="marker" id="marker2">Marker 2 at 4000px</div>
<div class="marker" id="marker3">Marker 3 at 6000px</div>
<div class="marker" id="marker4">Marker 4 at 8000px</div>
<div class="marker" id="marker5">Marker 5 at 10000px</div>
</div>
<h1 id="bottom">Very Long Page - Bottom</h1>
</body>
</html>
""",
content_type='text/html',
)
# Navigate to very long page
await browser_session._cdp_navigate(f'{base_url}/very-long')
await asyncio.sleep(0.5)
# Get initial scroll position
cdp_session = await browser_session.get_or_create_cdp_session()
initial_scroll = await browser_session.cdp_client.send.Runtime.evaluate(
params={'expression': 'window.pageYOffset', 'returnByValue': True},
session_id=cdp_session.session_id,
)
initial_y = initial_scroll.get('result', {}).get('value', 0)
assert initial_y == 0, f'Page should start at top, but pageYOffset is {initial_y}'
# Scroll down by 8000px
event = browser_session.event_bus.dispatch(ScrollEvent(direction='down', amount=8000))
await event
result = await event.event_result(raise_if_any=True, raise_if_none=False)
assert result is None # ScrollEvent does not return a result
# Wait a bit for scroll to take effect
await asyncio.sleep(0.5)
# Check scroll position moved significantly
final_scroll = await browser_session.cdp_client.send.Runtime.evaluate(
params={'expression': 'window.pageYOffset', 'returnByValue': True},
session_id=cdp_session.session_id,
)
final_y = final_scroll.get('result', {}).get('value', 0)
# Get page height to understand constraints
page_height = await browser_session.cdp_client.send.Runtime.evaluate(
params={'expression': 'document.body.scrollHeight', 'returnByValue': True},
session_id=cdp_session.session_id,
)
scroll_height = page_height.get('result', {}).get('value', 0)
# Should have scrolled down significantly (might not be exactly 8000 due to viewport constraints)
assert final_y > 5000, f'Expected to scroll significantly (page height: {scroll_height}px), but only at {final_y}px'
# Verify we can see marker 4 which is at 8000px
marker4_visible = await browser_session.cdp_client.send.Runtime.evaluate(
params={
'expression': """
(() => {
const marker = document.getElementById('marker4');
const rect = marker.getBoundingClientRect();
return rect.top >= 0 && rect.top <= window.innerHeight;
})()
""",
'returnByValue': True,
},
session_id=cdp_session.session_id,
)
assert marker4_visible.get('result', {}).get('value', False), 'Marker 4 should be visible after scrolling 8000px'
async def test_scroll_iframe_content(self, browser_session, base_url, http_server):
"""Test scrolling inside a same-origin iframe."""
from browser_use.browser.events import ScrollEvent
# Add iframe content page
http_server.expect_request('/iframe-content').respond_with_data(
"""
<!DOCTYPE html>
<html>
<head>
<style>
body { margin: 0; padding: 10px; }
.content { height: 2000px; background: linear-gradient(to bottom, #e0e0e0, #666); }
</style>
</head>
<body>
<h2 id="iframe-top">Iframe Content - Top</h2>
<div class="content">
<div style="margin-top: 900px;">Middle of iframe content</div>
<div style="margin-top: 900px;">Bottom of iframe content</div>
</div>
</body>
</html>
""",
content_type='text/html',
)
# Add main page with iframe
http_server.expect_request('/page-with-iframe').respond_with_data(
f"""
<!DOCTYPE html>
<html>
<head>
<title>Page with Iframe</title>
<style>
body {{ margin: 0; padding: 20px; }}
#main-content {{ height: 200px; background: #f0f0f0; }}
#scrollable-iframe {{
width: 100%;
height: 400px;
border: 2px solid #333;
}}
</style>
</head>
<body>
<div id="main-content">
<h1>Main Page Content</h1>
<p>This is the main page with an embedded iframe below.</p>
</div>
<iframe id="scrollable-iframe" src="{base_url}/iframe-content"></iframe>
<div style="height: 200px; background: #e0e0e0;">
<p>Content after iframe</p>
</div>
</body>
</html>
""",
content_type='text/html',
)
# Navigate to page with iframe
await browser_session._cdp_navigate(f'{base_url}/page-with-iframe')
await asyncio.sleep(1.0) # Give iframe time to load
# Get initial scroll position of main page and iframe
cdp_session = await browser_session.get_or_create_cdp_session()
# Check main page scroll
main_scroll = await browser_session.cdp_client.send.Runtime.evaluate(
params={'expression': 'window.pageYOffset', 'returnByValue': True},
session_id=cdp_session.session_id,
)
main_y = main_scroll.get('result', {}).get('value', 0)
# Check iframe scroll (should start at 0)
iframe_initial = await browser_session.cdp_client.send.Runtime.evaluate(
params={
'expression': """
(() => {
const iframe = document.getElementById('scrollable-iframe');
if (iframe && iframe.contentWindow) {
return iframe.contentWindow.pageYOffset || 0;
}
return -1;
})()
""",
'returnByValue': True,
},
session_id=cdp_session.session_id,
)
iframe_y = iframe_initial.get('result', {}).get('value', -1)
assert iframe_y == 0, f'Iframe should start at top, but pageYOffset is {iframe_y}'
# Scroll the main page first to bring iframe into view
event = browser_session.event_bus.dispatch(ScrollEvent(direction='down', amount=100))
await asyncio.wait_for(event, timeout=3.0)
# Now try to scroll inside the iframe
# Note: This would require finding the iframe element and scrolling it specifically
# For now, we just verify the iframe exists and is scrollable
iframe_scrollable = await browser_session.cdp_client.send.Runtime.evaluate(
params={
'expression': """
(() => {
const iframe = document.getElementById('scrollable-iframe');
if (iframe && iframe.contentDocument) {
const iframeBody = iframe.contentDocument.body;
return iframeBody.scrollHeight > iframe.clientHeight;
}
return false;
})()
""",
'returnByValue': True,
},
session_id=cdp_session.session_id,
)
assert iframe_scrollable.get('result', {}).get('value', False), 'Iframe should be scrollable'
@@ -0,0 +1,83 @@
"""Test that disable_security flag properly merges --disable-features flags without breaking extensions."""
import tempfile
from browser_use.browser.profile import BrowserProfile
class TestBrowserProfileDisableSecurity:
"""Test disable_security flag behavior."""
def test_disable_security_preserves_extension_features(self):
"""Test that disable_security=True doesn't break extension features by properly merging --disable-features flags."""
# Test with disable_security=False (baseline)
profile_normal = BrowserProfile(disable_security=False, user_data_dir=tempfile.mkdtemp(prefix='test-normal-'))
profile_normal.detect_display_configuration()
args_normal = profile_normal.get_args()
# Test with disable_security=True
profile_security_disabled = BrowserProfile(disable_security=True, user_data_dir=tempfile.mkdtemp(prefix='test-security-'))
profile_security_disabled.detect_display_configuration()
args_security_disabled = profile_security_disabled.get_args()
# Extract disable-features args
def extract_disable_features(args):
for arg in args:
if arg.startswith('--disable-features='):
return set(arg.split('=', 1)[1].split(','))
return set()
features_normal = extract_disable_features(args_normal)
features_security_disabled = extract_disable_features(args_security_disabled)
# Check that extension-related features are preserved
extension_features = {
'ExtensionManifestV2Disabled',
'ExtensionDisableUnsupportedDeveloper',
'ExtensionManifestV2Unsupported',
}
security_features = {'IsolateOrigins', 'site-per-process'}
# Verify that security disabled has both extension and security features
missing_extension_features = extension_features - features_security_disabled
missing_security_features = security_features - features_security_disabled
assert not missing_extension_features, (
f'Missing extension features when disable_security=True: {missing_extension_features}'
)
assert not missing_security_features, f'Missing security features when disable_security=True: {missing_security_features}'
# Verify that security disabled profile has more features than normal (due to added security features)
assert len(features_security_disabled) > len(features_normal), (
'Security disabled profile should have more features than normal profile'
)
# Verify all normal features are preserved in security disabled profile
missing_normal_features = features_normal - features_security_disabled
assert not missing_normal_features, f'Normal features missing from security disabled profile: {missing_normal_features}'
def test_disable_features_flag_deduplication(self):
"""Test that duplicate --disable-features values are properly deduplicated."""
profile = BrowserProfile(
disable_security=True,
user_data_dir=tempfile.mkdtemp(prefix='test-dedup-'),
# Add duplicate features to test deduplication
args=['--disable-features=TestFeature1,TestFeature2', '--disable-features=TestFeature2,TestFeature3'],
)
profile.detect_display_configuration()
args = profile.get_args()
# Extract disable-features args
disable_features_args = [arg for arg in args if arg.startswith('--disable-features=')]
# Should only have one consolidated --disable-features flag
assert len(disable_features_args) == 1, f'Expected 1 disable-features flag, got {len(disable_features_args)}'
features = set(disable_features_args[0].split('=', 1)[1].split(','))
# Should have all test features without duplicates
expected_test_features = {'TestFeature1', 'TestFeature2', 'TestFeature3'}
assert expected_test_features.issubset(features), f'Missing test features: {expected_test_features - features}'
@@ -0,0 +1,242 @@
"""
Systematic debugging of the selector map issue.
Test each assumption step by step to isolate the problem.
"""
import pytest
from browser_use.browser import BrowserSession
from browser_use.browser.profile import BrowserProfile
from browser_use.tools.service import Tools
@pytest.fixture
def httpserver(make_httpserver):
"""Create and provide a test HTTP server that serves static content."""
server = make_httpserver
# Add routes for test pages
server.expect_request('/').respond_with_data(
"""<html>
<head><title>Test Home Page</title></head>
<body>
<h1>Test Home Page</h1>
<a href="/page1" id="link1">Link 1</a>
<button id="button1">Button 1</button>
<input type="text" id="input1" />
<div id="div1" class="clickable">Clickable Div</div>
</body>
</html>""",
content_type='text/html',
)
server.expect_request('/page1').respond_with_data(
"""<html>
<head><title>Test Page 1</title></head>
<body>
<h1>Test Page 1</h1>
<p>This is test page 1</p>
<a href="/">Back to home</a>
</body>
</html>""",
content_type='text/html',
)
server.expect_request('/simple').respond_with_data(
"""<html>
<head><title>Simple Page</title></head>
<body>
<h1>Simple Page</h1>
<p>This is a simple test page</p>
<a href="/">Home</a>
</body>
</html>""",
content_type='text/html',
)
return server
@pytest.fixture
async def browser_session():
"""Create a real browser session for testing."""
session = BrowserSession(
browser_profile=BrowserProfile(
user_data_dir=None, # Use temporary profile
headless=True,
)
)
await session.start()
yield session
await session.stop()
@pytest.fixture
def tools():
"""Create a tools instance."""
return Tools()
@pytest.mark.asyncio
async def test_assumption_1_dom_processing_works(browser_session, httpserver):
"""Test assumption 1: DOM processing works and finds elements."""
# Go to a simple page using CDP events
from browser_use.browser.events import NavigateToUrlEvent
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=httpserver.url_for('/')))
await event
await event.event_result(raise_if_any=True, raise_if_none=False)
# Trigger DOM processing
state = await browser_session.get_browser_state_summary(cache_clickable_elements_hashes=False)
print('DOM processing result:')
print(f' - Elements found: {len(state.dom_state.selector_map)}')
print(f' - Element indices: {list(state.dom_state.selector_map.keys())}')
# Verify DOM processing works
assert len(state.dom_state.selector_map) > 0, 'DOM processing should find interactive elements'
@pytest.mark.asyncio
async def test_assumption_2_cached_selector_map_persists(browser_session, httpserver):
"""Test assumption 2: Cached selector map persists after get_state_summary."""
# Go to a simple page using CDP events
from browser_use.browser.events import NavigateToUrlEvent
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=httpserver.url_for('/')))
await event
await event.event_result(raise_if_any=True, raise_if_none=False)
# Trigger DOM processing and cache
state = await browser_session.get_browser_state_summary(cache_clickable_elements_hashes=False)
initial_selector_map = dict(state.dom_state.selector_map)
# Check if cached selector map is still available
cached_selector_map = await browser_session.get_selector_map()
print('Selector map persistence:')
print(f' - Initial elements: {len(initial_selector_map)}')
print(f' - Cached elements: {len(cached_selector_map)}')
print(f' - Maps are identical: {initial_selector_map.keys() == cached_selector_map.keys()}')
# Verify the cached map persists
assert len(cached_selector_map) > 0, 'Cached selector map should persist'
assert initial_selector_map.keys() == cached_selector_map.keys(), 'Cached map should match initial map'
@pytest.mark.asyncio
async def test_assumption_3_action_gets_same_selector_map(browser_session, tools, httpserver):
"""Test assumption 3: Action gets the same selector map as cached."""
# Go to a simple page using CDP events
from browser_use.browser.events import NavigateToUrlEvent
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=httpserver.url_for('/')))
await event
await event.event_result(raise_if_any=True, raise_if_none=False)
# Trigger DOM processing and cache
await browser_session.get_browser_state_summary(cache_clickable_elements_hashes=False)
cached_selector_map = await browser_session.get_selector_map()
print('Pre-action state:')
print(f' - Cached elements: {len(cached_selector_map)}')
print(f' - Element 0 exists in cache: {0 in cached_selector_map}')
# Create a test action that checks the selector map it receives
@tools.registry.action('Test: Check selector map')
async def test_check_selector_map(browser_session: BrowserSession):
from browser_use import ActionResult
action_selector_map = await browser_session.get_selector_map()
return ActionResult(
extracted_content=f'Action sees {len(action_selector_map)} elements, index 0 exists: {0 in action_selector_map}',
include_in_memory=False,
)
# Execute the test action
result = await tools.registry.execute_action('test_check_selector_map', {}, browser_session=browser_session)
print(f'Action result: {result.extracted_content}')
# Verify the action sees the same selector map
assert 'index 0 exists: False' in result.extracted_content, 'Element 0 should not exist (elements start at 1)'
@pytest.mark.asyncio
async def test_assumption_4_click_action_specific_issue(browser_session, tools, httpserver):
"""Test assumption 4: Specific issue with click_element_by_index action."""
# Go to a simple page using CDP events
from browser_use.browser.events import NavigateToUrlEvent
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=httpserver.url_for('/')))
await event
await event.event_result(raise_if_any=True, raise_if_none=False)
# Trigger DOM processing and cache
await browser_session.get_browser_state_summary(cache_clickable_elements_hashes=False)
cached_selector_map = await browser_session.get_selector_map()
print('Pre-click state:')
print(f' - Cached elements: {len(cached_selector_map)}')
print(f' - Element 0 exists: {0 in cached_selector_map}')
# Create a test action that replicates click_element_by_index logic
@tools.registry.action('Test: Debug click logic')
async def test_debug_click_logic(index: int, browser_session: BrowserSession):
from browser_use import ActionResult
# This is the exact logic from click_element_by_index
selector_map = await browser_session.get_selector_map()
print(f' - Action selector map size: {len(selector_map)}')
print(f' - Action selector map keys: {list(selector_map.keys())[:10]}') # First 10
print(f' - Index {index} in selector map: {index in selector_map}')
if index not in selector_map:
return ActionResult(
error=f'Debug: Element with index {index} does not exist in map of size {len(selector_map)}',
include_in_memory=False,
)
return ActionResult(
extracted_content=f'Debug: Element {index} found in map of size {len(selector_map)}', include_in_memory=False
)
# Test with index 1 (elements start at 1, not 0)
result = await tools.registry.execute_action('test_debug_click_logic', {'index': 1}, browser_session=browser_session)
print(f'Debug click result: {result.extracted_content or result.error}')
# This will help us see exactly what the click action sees
if result.error:
pytest.fail(f'Click logic debug failed: {result.error}')
@pytest.mark.asyncio
async def test_assumption_5_multiple_get_selector_map_calls(browser_session, httpserver):
"""Test assumption 5: Multiple calls to get_selector_map return consistent results."""
# Go to a simple page using CDP events
from browser_use.browser.events import NavigateToUrlEvent
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=httpserver.url_for('/')))
await event
await event.event_result(raise_if_any=True, raise_if_none=False)
# Trigger DOM processing and cache
await browser_session.get_browser_state_summary(cache_clickable_elements_hashes=False)
# Call get_selector_map multiple times
map1 = await browser_session.get_selector_map()
map2 = await browser_session.get_selector_map()
map3 = await browser_session.get_selector_map()
print('Multiple selector map calls:')
print(f' - Call 1: {len(map1)} elements')
print(f' - Call 2: {len(map2)} elements')
print(f' - Call 3: {len(map3)} elements')
print(f' - All calls identical: {map1.keys() == map2.keys() == map3.keys()}')
# Verify consistency
assert len(map1) == len(map2) == len(map3), 'Multiple calls should return same size'
assert map1.keys() == map2.keys() == map3.keys(), 'Multiple calls should return same elements'
@@ -0,0 +1,219 @@
"""Test all recording and save functionality for Agent and BrowserSession."""
from pathlib import Path
import pytest
from browser_use import Agent, AgentHistoryList
from browser_use.browser import BrowserProfile, BrowserSession
from tests.ci.conftest import create_mock_llm
@pytest.fixture
def test_dir(tmp_path):
"""Create a test directory that gets cleaned up after each test."""
test_path = tmp_path / 'test_recordings'
test_path.mkdir(exist_ok=True)
yield test_path
@pytest.fixture
async def httpserver_url(httpserver):
"""Simple test page."""
# Use expect_ordered_request with multiple handlers to handle repeated requests
for _ in range(10): # Allow up to 10 requests to the same URL
httpserver.expect_ordered_request('/').respond_with_data(
"""
<!DOCTYPE html>
<html>
<head>
<title>Test Page</title>
</head>
<body>
<h1>Test Recording Page</h1>
<input type="text" id="search" placeholder="Search here" />
<button type="button" id="submit">Submit</button>
</body>
</html>
""",
content_type='text/html',
)
return httpserver.url_for('/')
@pytest.fixture
def llm():
"""Create mocked LLM instance for tests."""
return create_mock_llm()
@pytest.fixture
def interactive_llm(httpserver_url):
"""Create mocked LLM that navigates to page and interacts with elements."""
actions = [
# First action: Navigate to the page
f"""
{{
"thinking": "null",
"evaluation_previous_goal": "Starting the task",
"memory": "Need to navigate to the test page",
"next_goal": "Navigate to the URL",
"action": [
{{
"go_to_url": {{
"url": "{httpserver_url}",
"new_tab": false
}}
}}
]
}}
""",
# Second action: Click in the search box
"""
{
"thinking": "null",
"evaluation_previous_goal": "Successfully navigated to the page",
"memory": "Page loaded, can see search box and submit button",
"next_goal": "Click on the search box to focus it",
"action": [
{
"click_element_by_index": {
"index": 0
}
}
]
}
""",
# Third action: Type text in the search box
"""
{
"thinking": "null",
"evaluation_previous_goal": "Clicked on search box",
"memory": "Search box is focused and ready for input",
"next_goal": "Type 'test' in the search box",
"action": [
{
"input_text": {
"index": 0,
"text": "test"
}
}
]
}
""",
# Fourth action: Click submit button
"""
{
"thinking": "null",
"evaluation_previous_goal": "Typed 'test' in search box",
"memory": "Text 'test' has been entered successfully",
"next_goal": "Click the submit button to complete the task",
"action": [
{
"click_element_by_index": {
"index": 1
}
}
]
}
""",
# Fifth action: Done - task completed
"""
{
"thinking": "null",
"evaluation_previous_goal": "Clicked the submit button",
"memory": "Successfully navigated to the page, typed 'test' in the search box, and clicked submit",
"next_goal": "Task completed",
"action": [
{
"done": {
"text": "Task completed - typed 'test' in search box and clicked submit",
"success": true
}
}
]
}
""",
]
return create_mock_llm(actions)
class TestAgentRecordings:
"""Test Agent save_conversation_path and generate_gif parameters."""
@pytest.mark.parametrize('path_type', ['with_slash', 'without_slash', 'deep_directory'])
async def test_save_conversation_path(self, test_dir, httpserver_url, llm, path_type):
"""Test saving conversation with different path types."""
if path_type == 'with_slash':
conversation_path = test_dir / 'logs' / 'conversation'
elif path_type == 'without_slash':
conversation_path = test_dir / 'logs'
else: # deep_directory
conversation_path = test_dir / 'logs' / 'deep' / 'directory' / 'conversation'
browser_session = BrowserSession(browser_profile=BrowserProfile(headless=True, disable_security=True, user_data_dir=None))
await browser_session.start()
try:
agent = Agent(
task=f'go to {httpserver_url} and type "test" in the search box',
llm=llm,
browser_session=browser_session,
save_conversation_path=str(conversation_path),
)
history: AgentHistoryList = await agent.run(max_steps=2)
result = history.final_result()
assert result is not None
# Check that the conversation directory and files were created
assert conversation_path.exists(), f'{path_type}: conversation directory was not created'
# Files are now always created as conversation_<agent_id>_<step>.txt inside the directory
conversation_files = list(conversation_path.glob('conversation_*.txt'))
assert len(conversation_files) > 0, f'{path_type}: conversation file was not created in {conversation_path}'
finally:
await browser_session.kill()
@pytest.mark.skip(reason='TODO: fix')
@pytest.mark.parametrize('generate_gif', [False, True, 'custom_path'])
async def test_generate_gif(self, test_dir, httpserver_url, llm, generate_gif):
"""Test GIF generation with different settings."""
# Clean up any existing GIFs first
for gif in Path.cwd().glob('agent_*.gif'):
gif.unlink()
gif_param = generate_gif
expected_gif_path = None
if generate_gif == 'custom_path':
expected_gif_path = test_dir / 'custom_agent.gif'
gif_param = str(expected_gif_path)
browser_session = BrowserSession(browser_profile=BrowserProfile(headless=True, disable_security=True, user_data_dir=None))
await browser_session.start()
try:
agent = Agent(
task=f'go to {httpserver_url}',
llm=llm,
browser_session=browser_session,
generate_gif=gif_param,
)
history: AgentHistoryList = await agent.run(max_steps=2)
result = history.final_result()
assert result is not None
# Check GIF creation
if generate_gif is False:
gif_files = list(Path.cwd().glob('*.gif'))
assert len(gif_files) == 0, 'GIF file was created when generate_gif=False'
elif generate_gif is True:
# With mock LLM that doesn't navigate, all screenshots will be about:blank placeholders
# So no GIF will be created (this is expected behavior)
gif_files = list(Path.cwd().glob('agent_history.gif'))
assert len(gif_files) == 0, 'GIF should not be created when all screenshots are placeholders'
else: # custom_path
assert expected_gif_path is not None, 'expected_gif_path should be set for custom_path'
# With mock LLM that doesn't navigate, no GIF will be created
assert not expected_gif_path.exists(), 'GIF should not be created when all screenshots are placeholders'
finally:
await browser_session.kill()
@@ -0,0 +1,113 @@
import asyncio
from typing import Any
import pytest
from browser_use.browser import BrowserProfile, BrowserSession
from browser_use.browser.profile import ProxySettings
from browser_use.config import CONFIG
def test_chromium_args_include_proxy_flags():
profile = BrowserProfile(
headless=True,
user_data_dir=str(CONFIG.BROWSER_USE_PROFILES_DIR / 'proxy-smoke'),
proxy=ProxySettings(
server='http://proxy.local:4242',
bypass='localhost,127.0.0.1',
),
)
args = profile.get_args()
assert any(a == '--proxy-server=http://proxy.local:4242' for a in args), args
assert any(a == '--proxy-bypass-list=localhost,127.0.0.1' for a in args), args
@pytest.mark.asyncio
async def test_cdp_proxy_auth_handler_registers_and_responds():
# Create profile with proxy auth credentials
profile = BrowserProfile(
headless=True,
user_data_dir=str(CONFIG.BROWSER_USE_PROFILES_DIR / 'proxy-smoke'),
proxy=ProxySettings(username='user', password='pass'),
)
session = BrowserSession(browser_profile=profile)
# Stub CDP client with minimal Fetch support
class StubCDP:
def __init__(self) -> None:
self.enabled = False
self.last_auth: dict[str, Any] | None = None
self.last_default: dict[str, Any] | None = None
self.auth_callback = None
self.request_paused_callback = None
class _FetchSend:
def __init__(self, outer: 'StubCDP') -> None:
self._outer = outer
async def enable(self, params: dict, session_id: str | None = None) -> None:
self._outer.enabled = True
async def continueWithAuth(self, params: dict, session_id: str | None = None) -> None:
self._outer.last_auth = {'params': params, 'session_id': session_id}
async def continueRequest(self, params: dict, session_id: str | None = None) -> None:
# no-op; included to mirror CDP API surface used by impl
pass
class _Send:
def __init__(self, outer: 'StubCDP') -> None:
self.Fetch = _FetchSend(outer)
class _FetchRegister:
def __init__(self, outer: 'StubCDP') -> None:
self._outer = outer
def authRequired(self, callback) -> None:
self._outer.auth_callback = callback
def requestPaused(self, callback) -> None:
self._outer.request_paused_callback = callback
class _Register:
def __init__(self, outer: 'StubCDP') -> None:
self.Fetch = _FetchRegister(outer)
self.send = _Send(self)
self.register = _Register(self)
root = StubCDP()
# Attach stubs to session
session._cdp_client_root = root # type: ignore[attr-defined]
# No need to attach a real CDPSession; _setup_proxy_auth works with root client
# Should register Fetch handler and enable auth handling without raising
await session._setup_proxy_auth()
assert root.enabled is True
assert callable(root.auth_callback)
# Simulate proxy auth required event
ev = {'requestId': 'r1', 'authChallenge': {'source': 'Proxy'}}
root.auth_callback(ev, session_id='s1') # type: ignore[misc]
# Let scheduled task run
await asyncio.sleep(0.05)
assert root.last_auth is not None
params = root.last_auth['params']
assert params['authChallengeResponse']['response'] == 'ProvideCredentials'
assert params['authChallengeResponse']['username'] == 'user'
assert params['authChallengeResponse']['password'] == 'pass'
assert root.last_auth['session_id'] == 's1'
# Now simulate a non-proxy auth challenge and ensure default handling
ev2 = {'requestId': 'r2', 'authChallenge': {'source': 'Server'}}
root.auth_callback(ev2, session_id='s2') # type: ignore[misc]
await asyncio.sleep(0.05)
# After non-proxy challenge, last_auth should reflect Default response
assert root.last_auth is not None
params2 = root.last_auth['params']
assert params2['requestId'] == 'r2'
assert params2['authChallengeResponse']['response'] == 'Default'
@@ -0,0 +1,355 @@
"""
Test browser session recent events tracking functionality.
"""
import json
import time
import pytest
from pytest_httpserver import HTTPServer
from werkzeug.wrappers import Response
from browser_use.browser import BrowserProfile, BrowserSession
from browser_use.browser.events import NavigateToUrlEvent, ScreenshotEvent
class TestBrowserRecentEvents:
"""Test recent events tracking functionality"""
async def test_recent_events_on_successful_load(self, httpserver: HTTPServer):
"""Test that recent events shows successful navigation when page loads successfully"""
# Set up a simple page that loads quickly
httpserver.expect_request('/fast').respond_with_data(
'<html><head><title>Fast Page</title></head><body><h1>Quick loading page</h1></body></html>',
content_type='text/html',
)
browser_session = BrowserSession(
browser_profile=BrowserProfile(
headless=True,
user_data_dir=None,
keep_alive=False,
)
)
try:
await browser_session.start()
# Navigate to the fast-loading page
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=httpserver.url_for('/fast')))
await event
await event.event_result(raise_if_any=True, raise_if_none=False)
# Get browser state with recent events
state = await browser_session.get_browser_state_summary(include_recent_events=True)
# Recent events should show successful navigation
assert state.recent_events is not None
# Parse JSON and verify events
events = json.loads(state.recent_events)
event_types = [e.get('event_type') for e in events]
# Should have navigation events
assert 'NavigationCompleteEvent' in event_types, 'Should have NavigationCompleteEvent'
# Check the navigation was successful (no errors)
nav_events = [e for e in events if e.get('event_type') == 'NavigationCompleteEvent']
last_nav = nav_events[-1]
assert last_nav.get('error_message') is None, 'Should not have error message'
# Note: CDP doesn't provide HTTP status directly, so skip status check
finally:
await browser_session.kill()
# async def test_recent_events_tracks_multiple_navigations(self, httpserver: HTTPServer):
# """Test that recent events properly tracks multiple navigations"""
# # Set up pages
# slow_html = """
# <html>
# <head>
# <title>Slow Page</title>
# <script src="/slow.js"></script>
# </head>
# <body><h1>Slow page</h1></body>
# </html>
# """
# httpserver.expect_request('/slow').respond_with_data(slow_html, content_type='text/html')
# def slow_handler(req):
# time.sleep(5)
# return Response('slow')
# httpserver.expect_request('/slow.js').respond_with_handler(slow_handler)
# httpserver.expect_request('/fast').respond_with_data(
# '<html><head><title>Fast Page</title></head><body><h1>Fast page</h1></body></html>',
# content_type='text/html',
# )
# browser_session = BrowserSession(
# browser_profile=BrowserProfile(
# headless=True,
# user_data_dir=None,
# keep_alive=False,
# )
# )
# try:
# await browser_session.start()
# # Navigate to slow page
# event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=httpserver.url_for('/slow')))
# await event
# await event.event_result(raise_if_any=True, raise_if_none=False)
# state1 = await browser_session.get_browser_state_summary(include_recent_events=True)
# assert state1.recent_events is not None
# events1 = json.loads(state1.recent_events)
# event_types1 = [e.get('event_type') for e in events1]
# assert 'NavigationCompleteEvent' in event_types1 or 'NavigateToUrlEvent' in event_types1
# # Navigate to fast page
# event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=httpserver.url_for('/fast')))
# await event
# await event.event_result(raise_if_any=True, raise_if_none=False)
# state2 = await browser_session.get_browser_state_summary()
# # Recent events should show both navigations
# assert state2.recent_events is not None
# events2 = json.loads(state2.recent_events)
# # Count navigation events (last 10 events should include both)
# nav_complete_count = sum(1 for e in events2 if e.get('event_type') == 'NavigationCompleteEvent')
# nav_url_count = sum(1 for e in events2 if e.get('event_type') == 'NavigateToUrlEvent')
# # Should have events from both navigations
# assert nav_complete_count >= 1 or nav_url_count >= 2, 'Recent events should show multiple navigation attempts'
# finally:
# await browser_session.kill()
async def test_recent_events_preserved_in_minimal_state(self, httpserver: HTTPServer):
"""Test that recent events is preserved even when falling back to minimal state"""
# Create a page that causes DOM processing to fail
malformed_html = """
<html>
<head>
<title>Malformed Page</title>
<script src="/slow.js"></script>
<script>
// This might cause DOM processing issues
Object.defineProperty(document, 'querySelectorAll', {
get() { throw new Error('DOM processing blocked'); }
});
</script>
</head>
<body><h1>Page with DOM issues</h1></body>
</html>
"""
httpserver.expect_request('/malformed').respond_with_data(malformed_html, content_type='text/html')
def slow_handler(req):
time.sleep(5)
return Response('slow')
httpserver.expect_request('/slow.js').respond_with_handler(slow_handler)
browser_session = BrowserSession(
browser_profile=BrowserProfile(
headless=True,
user_data_dir=None,
keep_alive=False,
)
)
try:
await browser_session.start()
# Navigate to the malformed page
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=httpserver.url_for('/malformed')))
await event
await event.event_result(raise_if_any=True, raise_if_none=False)
# Get browser state - this might fall back to minimal state
state = await browser_session.get_browser_state_summary(include_recent_events=True)
# Even if we get minimal state, recent events should be preserved
assert state.recent_events is not None
events = json.loads(state.recent_events)
assert len(events) > 0, 'Should have events even in minimal state'
# Should have navigation attempt
event_types = [e.get('event_type') for e in events]
assert 'NavigateToUrlEvent' in event_types or 'NavigationCompleteEvent' in event_types, (
'Should have navigation events even in minimal state'
)
finally:
await browser_session.kill()
@pytest.mark.parametrize('timeout_seconds', [0.5, 1.0, 2.0])
async def test_recent_events_with_different_timeouts(self, httpserver: HTTPServer, timeout_seconds: float):
"""Test that recent events captures navigation with different timeout configurations"""
# Set up a slow page
httpserver.expect_request(f'/timeout_{timeout_seconds}').respond_with_data(
f'<html><head><title>Timeout Test {timeout_seconds}s</title>'
f'<script src="/slow_{timeout_seconds}.js"></script></head>'
f'<body><h1>Testing {timeout_seconds}s timeout</h1></body></html>',
content_type='text/html',
)
def very_slow_handler(req):
time.sleep(10)
return Response('slow')
httpserver.expect_request(f'/slow_{timeout_seconds}.js').respond_with_handler(very_slow_handler)
browser_session = BrowserSession(
browser_profile=BrowserProfile(
headless=True,
user_data_dir=None,
keep_alive=False,
wait_for_network_idle_page_load_time=0.1,
minimum_wait_page_load_time=0.1,
)
)
try:
await browser_session.start()
# Navigate to the page
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=httpserver.url_for(f'/timeout_{timeout_seconds}')))
await event
await event.event_result(raise_if_any=True, raise_if_none=False)
# Get browser state with recent events
state = await browser_session.get_browser_state_summary(include_recent_events=True)
# Verify recent events captured the navigation
assert state.recent_events is not None
events = json.loads(state.recent_events)
event_types = [e.get('event_type') for e in events]
# Should have navigation events regardless of timeout
assert 'NavigateToUrlEvent' in event_types or 'NavigationCompleteEvent' in event_types, (
f'Should have navigation events for {timeout_seconds}s timeout'
)
# If navigation completed with error, it might mention timeout
nav_complete_events = [e for e in events if e.get('event_type') == 'NavigationCompleteEvent']
if nav_complete_events and nav_complete_events[-1].get('error_message'):
print(f'Navigation error for {timeout_seconds}s timeout: {nav_complete_events[-1].get("error_message")}')
finally:
await browser_session.kill()
class TestEventHistoryInfrastructure:
"""Tests for NEW event history tracking infrastructure only."""
async def test_event_bus_history_tracking(self, httpserver: HTTPServer):
"""Test that event bus properly tracks event history."""
browser_session = BrowserSession(browser_profile=BrowserProfile(headless=True, user_data_dir=None, keep_alive=False))
try:
await browser_session.start()
initial_history_count = len(browser_session.event_bus.event_history)
# Set up test page
httpserver.expect_request('/history-test').respond_with_data(
'<html><body><h1>Event History Test</h1></body></html>',
content_type='text/html',
)
# Perform actions that generate events
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=httpserver.url_for('/history-test')))
await event
await event.event_result(raise_if_any=True, raise_if_none=False)
screenshot_event = browser_session.event_bus.dispatch(ScreenshotEvent())
await screenshot_event
await screenshot_event.event_result(raise_if_any=True, raise_if_none=False)
# Verify event history has grown
final_history_count = len(browser_session.event_bus.event_history)
assert final_history_count > initial_history_count, 'Event history should track new events'
# Verify events are stored properly
for event_id, event in browser_session.event_bus.event_history.items():
assert event_id is not None
assert event is not None
assert hasattr(event, 'event_type') or hasattr(event, '__class__')
finally:
await browser_session.kill()
async def test_generate_recent_events_summary_format(self, httpserver: HTTPServer):
"""Test that _generate_recent_events_summary produces valid JSON."""
browser_session = BrowserSession(browser_profile=BrowserProfile(headless=True, user_data_dir=None, keep_alive=False))
try:
await browser_session.start()
# Generate some events
httpserver.expect_request('/json-test').respond_with_data(
'<html><body><h1>JSON Test</h1></body></html>',
content_type='text/html',
)
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=httpserver.url_for('/json-test')))
await event
await event.event_result(raise_if_any=True, raise_if_none=False)
# Test the NEW method _generate_recent_events_summary
recent_events_json = await browser_session.get_browser_state_summary(include_recent_events=True)
recent_events_json = recent_events_json.recent_events
assert recent_events_json is not None
# Should return valid JSON
assert recent_events_json != '[]', 'Should have events'
events = json.loads(recent_events_json) # Should not raise JSON decode error
assert isinstance(events, list), 'Should return a list of events'
# Events should exclude problematic fields like 'state'
for event in events:
assert isinstance(event, dict), 'Each event should be a dict'
assert 'state' not in event, "Event should not contain 'state' field (circular reference)"
finally:
await browser_session.kill()
async def test_event_history_limits(self, httpserver: HTTPServer):
"""Test that event history summary respects max_events parameter."""
browser_session = BrowserSession(browser_profile=BrowserProfile(headless=True, user_data_dir=None, keep_alive=False))
try:
await browser_session.start()
# Generate multiple events
httpserver.expect_request('/limit-test').respond_with_data(
'<html><body><h1>Limit Test</h1></body></html>',
content_type='text/html',
)
# Perform multiple actions to generate events
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=httpserver.url_for('/limit-test')))
await event
await event.event_result(raise_if_any=True, raise_if_none=False)
screenshot_event = browser_session.event_bus.dispatch(ScreenshotEvent())
await screenshot_event
await screenshot_event.event_result(raise_if_any=True, raise_if_none=False)
await browser_session.get_tabs()
# Test recent events summary via BrowserStateSummary
state_with_events = await browser_session.get_browser_state_summary(include_recent_events=True)
assert state_with_events.recent_events is not None
# Parse the JSON events
events = json.loads(state_with_events.recent_events)
# Should have some events
assert len(events) > 0, 'Should have some recent events'
assert isinstance(events, list), 'Events should be a list'
finally:
await browser_session.kill()
@@ -0,0 +1,434 @@
"""
Test script for BrowserSession.start() method to ensure proper initialization,
concurrency handling, and error handling.
Tests cover:
- Calling .start() on a session that's already started
- Simultaneously calling .start() from two parallel coroutines
- Calling .start() on a session that's started but has a closed browser connection
- Calling .close() on a session that hasn't been started yet
"""
import asyncio
import logging
import pytest
from browser_use.browser.profile import (
BROWSERUSE_DEFAULT_CHANNEL,
BrowserChannel,
BrowserProfile,
)
from browser_use.browser.session import BrowserSession
from browser_use.config import CONFIG
# Set up test logging
logger = logging.getLogger('browser_session_start_tests')
# logger.setLevel(logging.DEBUG)
# run with pytest -k test_user_data_dir_not_allowed_to_corrupt_default_profile
class TestBrowserSessionStart:
"""Tests for BrowserSession.start() method initialization and concurrency."""
@pytest.fixture(scope='module')
async def browser_profile(self):
"""Create and provide a BrowserProfile with headless mode."""
profile = BrowserProfile(headless=True, user_data_dir=None, keep_alive=False)
yield profile
@pytest.fixture(scope='function')
async def browser_session(self, browser_profile):
"""Create a BrowserSession instance without starting it."""
session = BrowserSession(browser_profile=browser_profile)
yield session
await session.kill()
async def test_start_already_started_session(self, browser_session):
"""Test calling .start() on a session that's already started."""
# logger.info('Testing start on already started session')
# Start the session for the first time
await browser_session.start()
assert browser_session._cdp_client_root is not None
# Start the session again - should return immediately without re-initialization
await browser_session.start()
assert browser_session._cdp_client_root is not None
# @pytest.mark.skip(reason="Race condition - DOMWatchdog tries to inject scripts into tab that's being closed")
# async def test_page_lifecycle_management(self, browser_session: BrowserSession):
# """Test session handles page lifecycle correctly."""
# # logger.info('Testing page lifecycle management')
# # Start the session and get initial state
# await browser_session.start()
# initial_tabs = await browser_session.get_tabs()
# initial_count = len(initial_tabs)
# # Get current tab info
# current_url = await browser_session.get_current_page_url()
# assert current_url is not None
# # Get current tab ID
# current_tab_id = browser_session.agent_focus.target_id if browser_session.agent_focus else None
# assert current_tab_id is not None
# # Close the current tab using the event system
# from browser_use.browser.events import CloseTabEvent
# close_event = browser_session.event_bus.dispatch(CloseTabEvent(target_id=current_tab_id))
# await close_event
# # Operations should still work - may create new page or use existing
# tabs_after_close = await browser_session.get_tabs()
# assert isinstance(tabs_after_close, list)
# # Create a new tab explicitly
# event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url='about:blank', new_tab=True))
# await event
# await event.event_result(raise_if_any=True, raise_if_none=False)
# # Should have at least one tab now
# final_tabs = await browser_session.get_tabs()
# assert len(final_tabs) >= 1
async def test_user_data_dir_not_allowed_to_corrupt_default_profile(self):
"""Test user_data_dir handling for different browser channels and version mismatches."""
# Test 1: Chromium with default user_data_dir and default channel should work fine
session = BrowserSession(
browser_profile=BrowserProfile(
headless=True,
user_data_dir=CONFIG.BROWSER_USE_DEFAULT_USER_DATA_DIR,
channel=BROWSERUSE_DEFAULT_CHANNEL, # chromium
keep_alive=False,
),
)
try:
await session.start()
assert session._cdp_client_root is not None
# Verify the user_data_dir wasn't changed
assert session.browser_profile.user_data_dir == CONFIG.BROWSER_USE_DEFAULT_USER_DATA_DIR
finally:
await session.kill()
# Test 2: Chrome with default user_data_dir should automatically change dir
profile2 = BrowserProfile(
headless=True,
user_data_dir=CONFIG.BROWSER_USE_DEFAULT_USER_DATA_DIR,
channel=BrowserChannel.CHROME,
keep_alive=False,
)
# The validator should have changed the user_data_dir to avoid corruption
assert profile2.user_data_dir != CONFIG.BROWSER_USE_DEFAULT_USER_DATA_DIR
assert profile2.user_data_dir == CONFIG.BROWSER_USE_DEFAULT_USER_DATA_DIR.parent / 'default-chrome'
# Test 3: Edge with default user_data_dir should also change
profile3 = BrowserProfile(
headless=True,
user_data_dir=CONFIG.BROWSER_USE_DEFAULT_USER_DATA_DIR,
channel=BrowserChannel.MSEDGE,
keep_alive=False,
)
assert profile3.user_data_dir != CONFIG.BROWSER_USE_DEFAULT_USER_DATA_DIR
assert profile3.user_data_dir == CONFIG.BROWSER_USE_DEFAULT_USER_DATA_DIR.parent / 'default-msedge'
class TestBrowserSessionReusePatterns:
"""Tests for all browser re-use patterns documented in docs/customize/real-browser.mdx"""
async def test_sequential_agents_same_profile_different_browser(self, mock_llm):
"""Test Sequential Agents, Same Profile, Different Browser pattern"""
from browser_use import Agent
from browser_use.browser.profile import BrowserProfile
# Create a reusable profile
reused_profile = BrowserProfile(
user_data_dir=None, # Use temp dir for testing
headless=True,
)
# First agent
agent1 = Agent(
task='The first task...',
llm=mock_llm,
browser_profile=reused_profile,
)
await agent1.run()
# Verify first agent's session is closed
assert agent1.browser_session is not None
assert not agent1.browser_session._cdp_client_root is not None
# Second agent with same profile
agent2 = Agent(
task='The second task...',
llm=mock_llm,
browser_profile=reused_profile,
# Disable memory for tests
)
await agent2.run()
# Verify second agent created a new session
assert agent2.browser_session is not None
assert agent1.browser_session is not agent2.browser_session
assert not agent2.browser_session._cdp_client_root is not None
async def test_sequential_agents_same_profile_same_browser(self, mock_llm):
"""Test Sequential Agents, Same Profile, Same Browser pattern"""
from browser_use import Agent, BrowserSession
# Create a reusable session with keep_alive
reused_session = BrowserSession(
browser_profile=BrowserProfile(
user_data_dir=None, # Use temp dir for testing
headless=True,
keep_alive=True, # Don't close browser after agent.run()
),
)
try:
# Start the session manually (agents will reuse this initialized session)
await reused_session.start()
# First agent
agent1 = Agent(
task='The first task...',
llm=mock_llm,
browser_session=reused_session,
# Disable memory for tests
)
await agent1.run()
# Verify session is still alive
assert reused_session._cdp_client_root is not None
# Second agent reusing the same session
agent2 = Agent(
task='The second task...',
llm=mock_llm,
browser_session=reused_session,
# Disable memory for tests
)
await agent2.run()
# Verify same browser was used (using __eq__ to check browser_pid, cdp_url)
assert agent1.browser_session == agent2.browser_session
assert agent1.browser_session == reused_session
assert reused_session._cdp_client_root is not None
finally:
await reused_session.kill()
class TestBrowserSessionEventSystem:
"""Tests for the new event system integration in BrowserSession."""
@pytest.fixture(scope='function')
async def browser_session(self):
"""Create a BrowserSession instance for event system testing."""
profile = BrowserProfile(headless=True, user_data_dir=None, keep_alive=False)
session = BrowserSession(browser_profile=profile)
yield session
await session.kill()
async def test_event_bus_initialization(self, browser_session):
"""Test that event bus is properly initialized with unique name."""
# Event bus should be created during __init__
assert browser_session.event_bus is not None
assert browser_session.event_bus.name.startswith('EventBus_')
# Event bus name format may vary, just check it exists
async def test_event_handlers_registration(self, browser_session: BrowserSession):
"""Test that event handlers are properly registered."""
# Attach all watchdogs to register their handlers
await browser_session.attach_all_watchdogs()
# Check that handlers are registered in the event bus
from browser_use.browser.events import (
BrowserStartEvent,
BrowserStateRequestEvent,
BrowserStopEvent,
ClickElementEvent,
CloseTabEvent,
ScreenshotEvent,
ScrollEvent,
TypeTextEvent,
)
# These event types should have handlers registered
event_types_with_handlers = [
BrowserStartEvent,
BrowserStopEvent,
ClickElementEvent,
TypeTextEvent,
ScrollEvent,
CloseTabEvent,
BrowserStateRequestEvent,
ScreenshotEvent,
]
for event_type in event_types_with_handlers:
handlers = browser_session.event_bus.handlers.get(event_type.__name__, [])
assert len(handlers) > 0, f'No handlers registered for {event_type.__name__}'
async def test_direct_event_dispatching(self, browser_session):
"""Test direct event dispatching without using the public API."""
from browser_use.browser.events import BrowserConnectedEvent, BrowserStartEvent
# Dispatch BrowserStartEvent directly
start_event = browser_session.event_bus.dispatch(BrowserStartEvent())
# Wait for event to complete
await start_event
# Check if BrowserConnectedEvent was dispatched
assert browser_session._cdp_client_root is not None
# Check event history
event_history = list(browser_session.event_bus.event_history.values())
assert len(event_history) >= 2 # BrowserStartEvent + BrowserConnectedEvent + others
# Find the BrowserConnectedEvent in history
started_events = [e for e in event_history if isinstance(e, BrowserConnectedEvent)]
assert len(started_events) >= 1
assert started_events[0].cdp_url is not None
async def test_event_system_error_handling(self, browser_session):
"""Test error handling in event system."""
from browser_use.browser.events import BrowserStartEvent
# Create session with invalid CDP URL to trigger error
error_session = BrowserSession(
browser_profile=BrowserProfile(headless=True),
cdp_url='http://localhost:99999', # Invalid port
)
try:
# Dispatch start event directly - should trigger error handling
start_event = error_session.event_bus.dispatch(BrowserStartEvent())
# The event bus catches and logs the error, but the event awaits successfully
await start_event
# The session should not be initialized due to the error
assert error_session._cdp_client_root is None, 'Session should not be initialized after connection error'
# Verify the error was logged in the event history (good enough for error handling test)
assert len(error_session.event_bus.event_history) > 0, 'Event should be tracked even with errors'
finally:
await error_session.kill()
async def test_concurrent_event_dispatching(self, browser_session: BrowserSession):
"""Test that concurrent events are handled properly."""
from browser_use.browser.events import ScreenshotEvent
# Start browser first
await browser_session.start()
# Dispatch multiple events concurrently
screenshot_event1 = browser_session.event_bus.dispatch(ScreenshotEvent())
screenshot_event2 = browser_session.event_bus.dispatch(ScreenshotEvent())
# Both should complete successfully
results = await asyncio.gather(screenshot_event1, screenshot_event2, return_exceptions=True)
# Check that no exceptions were raised
for result in results:
assert not isinstance(result, Exception), f'Event failed with: {result}'
# async def test_many_parallel_browser_sessions(self):
# """Test spawning 12 parallel browser_sessions with different settings and ensure they all work"""
# from browser_use import BrowserSession
# browser_sessions = []
# for i in range(3):
# browser_sessions.append(
# BrowserSession(
# browser_profile=BrowserProfile(
# user_data_dir=None,
# headless=True,
# keep_alive=True,
# ),
# )
# )
# for i in range(3):
# browser_sessions.append(
# BrowserSession(
# browser_profile=BrowserProfile(
# user_data_dir=Path(tempfile.mkdtemp(prefix=f'browseruse-tmp-{i}')),
# headless=True,
# keep_alive=True,
# ),
# )
# )
# for i in range(3):
# browser_sessions.append(
# BrowserSession(
# browser_profile=BrowserProfile(
# user_data_dir=None,
# headless=True,
# keep_alive=False,
# ),
# )
# )
# for i in range(3):
# browser_sessions.append(
# BrowserSession(
# browser_profile=BrowserProfile(
# user_data_dir=Path(tempfile.mkdtemp(prefix=f'browseruse-tmp-{i}')),
# headless=True,
# keep_alive=False,
# ),
# )
# )
# print('Starting many parallel browser sessions...')
# await asyncio.gather(*[browser_session.start() for browser_session in browser_sessions])
# print('Ensuring all parallel browser sessions are connected and usable...')
# new_tab_tasks = []
# for browser_session in browser_sessions:
# assert browser_session._cdp_client_root is not None
# assert browser_session._cdp_client_root is not None
# new_tab_tasks.append(browser_session.create_new_tab('chrome://version'))
# await asyncio.gather(*new_tab_tasks)
# print('killing every 3rd browser_session to test parallel shutdown')
# kill_tasks = []
# for i in range(0, len(browser_sessions), 3):
# kill_tasks.append(browser_sessions[i].kill())
# browser_sessions[i] = None
# results = await asyncio.gather(*kill_tasks, return_exceptions=True)
# # Check that no exceptions were raised during cleanup
# for i, result in enumerate(results):
# if isinstance(result, Exception):
# print(f'Warning: Browser session kill raised exception: {type(result).__name__}: {result}')
# print('ensuring the remaining browser_sessions are still connected and usable')
# new_tab_tasks = []
# screenshot_tasks = []
# for browser_session in filter(bool, browser_sessions):
# assert browser_session._cdp_client_root is not None
# assert browser_session._cdp_client_root is not None
# new_tab_tasks.append(browser_session.create_new_tab('chrome://version'))
# screenshot_tasks.append(browser_session.take_screenshot())
# await asyncio.gather(*new_tab_tasks)
# await asyncio.gather(*screenshot_tasks)
# kill_tasks = []
# print('killing the remaining browser_sessions')
# for browser_session in filter(bool, browser_sessions):
# kill_tasks.append(browser_session.kill())
# results = await asyncio.gather(*kill_tasks, return_exceptions=True)
# # Check that no exceptions were raised during cleanup
# for i, result in enumerate(results):
# if isinstance(result, Exception):
# print(f'Warning: Browser session kill raised exception: {type(result).__name__}: {result}')
@@ -0,0 +1,316 @@
import asyncio
import logging
import pytest
from dotenv import load_dotenv
from pytest_httpserver import HTTPServer
load_dotenv()
from browser_use.agent.views import ActionModel
from browser_use.browser.events import NavigateToUrlEvent
from browser_use.browser.profile import BrowserProfile
from browser_use.browser.session import BrowserSession
from browser_use.tools.service import Tools
# Set up test logging
logger = logging.getLogger('tab_tests')
# logger.setLevel(logging.DEBUG)
@pytest.fixture(scope='session')
def http_server():
"""Create and provide a test HTTP server that serves static content."""
server = HTTPServer()
server.start()
# Add routes for test pages
server.expect_request('/page1').respond_with_data(
'<html><head><title>Test Page 1</title></head><body><h1>Test Page 1</h1></body></html>', content_type='text/html'
)
server.expect_request('/page2').respond_with_data(
'<html><head><title>Test Page 2</title></head><body><h1>Test Page 2</h1></body></html>', content_type='text/html'
)
server.expect_request('/page3').respond_with_data(
'<html><head><title>Test Page 3</title></head><body><h1>Test Page 3</h1></body></html>', content_type='text/html'
)
server.expect_request('/page4').respond_with_data(
'<html><head><title>Test Page 4</title></head><body><h1>Test Page 4</h1></body></html>', content_type='text/html'
)
yield server
server.stop()
@pytest.fixture(scope='session')
def base_url(http_server):
"""Return the base URL for the test HTTP server."""
return f'http://{http_server.host}:{http_server.port}'
@pytest.fixture(scope='module')
async def browser_session(base_url):
"""Create and provide a BrowserSession instance with a properly initialized tab."""
browser_session = BrowserSession(
browser_profile=BrowserProfile(
user_data_dir=None,
headless=True,
keep_alive=True,
)
)
await browser_session.start()
# Create an initial tab using the navigate method which is more reliable
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/page1', new_tab=True))
await event
await event.event_result(raise_if_any=True, raise_if_none=False)
# Wait for navigation to complete
await asyncio.sleep(1)
# Verify that page is properly set
current_url = await browser_session.get_current_page_url()
assert base_url in current_url
# page might be None initially until user interaction occurs
# This is expected behavior with the new watchdog architecture
yield browser_session
await browser_session.kill()
# Give playwright time to clean up
await asyncio.sleep(0.1)
@pytest.fixture(scope='module')
def tools():
"""Create and provide a Tools instance."""
return Tools()
class TestTabManagement:
"""Tests for the tab management system with separate page and page references."""
# Helper methods
async def _execute_action(self, tools, browser_session: BrowserSession, action_data):
"""Generic helper to execute any action via the tools."""
# Dynamically create an appropriate ActionModel class
action_type = list(action_data.keys())[0]
action_value = action_data[action_type]
# Create the ActionModel with the single action field
class DynamicActionModel(ActionModel):
pass
# Dynamically add the field with the right type annotation
setattr(DynamicActionModel, action_type, type(action_value) | None)
# Execute the action
result = await tools.act(DynamicActionModel(**action_data), browser_session)
# Give the browser a moment to process the action
await asyncio.sleep(0.5)
return result
async def _reset_tab_state(self, browser_session: BrowserSession, base_url: str):
# await browser_session.event_bus.dispatch(CloseTabEvent(target_id=browser_session.agent_focus.target_id))
# TODO: close all tabs using events + create new tab + focus it
pass
# Tab management tests
# async def test_initial_values(self, browser_session, base_url):
# """Test that open_tab correctly updates both tab references."""
# await self._reset_tab_state(browser_session, base_url)
# # Get current tab info using the new API
# current_url = await browser_session.get_current_page_url()
# assert current_url == 'about:blank'
# # Note: browser_session.page property may not exist in new architecture
# # Test that get_current_page works even after closing all tabs
# for page in browser_session._cdp_client_root.pages:
# await page.close()
# # Give time for watchdogs to process tab closure events
# await asyncio.sleep(0.5)
# # should never be none even after all pages are closed - new system auto-creates
# # Check that we can still get current URL (system should auto-create if needed)
# current_url = await browser_session.get_current_page_url()
# assert current_url is not None
# assert current_url == 'about:blank'
# run with pytest -k test_agent_changes_tab
async def test_agent_changes_tab(self, browser_session: BrowserSession, base_url):
"""Test that page changes and page remains the same when a new tab is opened."""
initial_tab = await self._reset_tab_state(browser_session, base_url)
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/page1'))
await event
await event.event_result(raise_if_any=True, raise_if_none=False)
current_url = await browser_session.get_current_page_url()
assert current_url == f'{base_url}/page1'
tabs = await browser_session.get_tabs()
initial_tab_count = len(tabs)
# Debug: Check tab count
print(f'DEBUG: initial_tab_count = {initial_tab_count}')
print(f'DEBUG: browser_session.tabs = {[p.url for p in tabs]}')
# The test expects 1 tab, but if there's more we need to understand why
if initial_tab_count != 1:
print(f'WARNING: Expected 1 tab but found {initial_tab_count} tabs after _reset_tab_state')
# For now, let's adjust the test to work with the actual count
# TODO: fix this initial tab count issue
pytest.skip('Initial tab count issue')
# We expect at least 1 tab but there might be more due to event-driven architecture
assert initial_tab_count >= 1
# test opening a new tab
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/page2', new_tab=True))
await event
await event.event_result(raise_if_any=True, raise_if_none=False)
new_tabs = await browser_session.get_tabs()
new_tab_count = len(new_tabs)
# Debug: Check tab count after new tab creation
print(f'DEBUG: new_tab_count = {new_tab_count}')
print(f'DEBUG: browser_session.tabs count = {len(new_tabs)}')
print(f'DEBUG: browser_session.tabs = {[p.url for p in new_tabs]}')
# After creating a new tab, we should have one more tab than before
expected_new_count = initial_tab_count + 1
assert new_tab_count == expected_new_count
# Give time for watchdogs to process the new tab creation
await asyncio.sleep(1.0)
# test agent open new tab updates agent focus
current_url = await browser_session.get_current_page_url()
assert current_url == f'{base_url}/page2'
# test agent navigation updates agent focus
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/page3'))
await event
await event.event_result(raise_if_any=True, raise_if_none=False)
current_url = await browser_session.get_current_page_url()
assert current_url == f'{base_url}/page3' # agent should now be on the new tab
# async def test_close_tab(self, browser_session, base_url):
# """Test that closing a tab updates references correctly."""
# initial_tab = await self._reset_tab_state(browser_session, base_url)
# event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/page1'))
# await event
# await event.event_result(raise_if_any=True, raise_if_none=False)
# # After navigation, current page should be the correct reference
# current_url = await browser_session.get_current_page_url()
# assert current_url == f'{base_url}/page1'
# # The initial_tab (which was about:blank) should now show the new URL too
# # (can't check old page object anymore, but current URL confirms navigation worked)
# # Create two tabs with different URLs
# event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/page2', new_tab=True))
# await event
# await event.event_result(raise_if_any=True, raise_if_none=False)
# # Verify the second tab is now active
# current_url = await browser_session.get_current_page_url()
# assert current_url == f'{base_url}/page2'
# # Close the second tab using CDP
# tabs = await browser_session.get_tabs()
# second_tab_id = None
# for tab in tabs:
# if f'{base_url}/page2' in tab.url:
# second_tab_id = tab.target_id
# break
# if second_tab_id:
# event = browser_session.event_bus.dispatch(CloseTabEvent(target_id=second_tab_id))
# await event
# await event.event_result(raise_if_any=True, raise_if_none=False)
# await asyncio.sleep(0.5)
# # Agent reference should be auto-updated to the first available tab
# current_url = await browser_session.get_current_page_url()
# assert current_url == f'{base_url}/page1'
# # (can't check old page object anymore, but current URL confirms tab switch worked)
# # close the only remaining tab using CDP
# tabs = await browser_session.get_tabs()
# if tabs:
# first_tab_id = tabs[0].target_id
# event = browser_session.event_bus.dispatch(CloseTabEvent(target_id=first_tab_id))
# await event
# await event.event_result(raise_if_any=True, raise_if_none=False)
# await asyncio.sleep(0.5)
# # close_tab should have called get_current_page, which creates a new about:blank tab if none are left
# current_url = await browser_session.get_current_page_url()
# assert current_url == 'about:blank'
class TestEventDrivenTabOperations:
"""Tests for event-driven tab operations introduced in the session refactor."""
@pytest.fixture(scope='function')
async def browser_session(self):
"""Create a clean BrowserSession for event testing."""
session = BrowserSession(browser_profile=BrowserProfile(headless=True, user_data_dir=None, keep_alive=False))
await session.start()
yield session
await session.kill()
# async def test_switch_tab_event_dispatching(self, browser_session, base_url):
# """Test direct SwitchTabEvent dispatching."""
# # Create multiple tabs
# await browser_session.navigate_to(f'{base_url}/page1')
# await browser_session.create_new_tab(f'{base_url}/page2')
# await browser_session.create_new_tab(f'{base_url}/page3')
# # Switch to tab 0 via direct event
# switch_event = browser_session.event_bus.dispatch(SwitchTabEvent(target_id=browser_session.tabs[0].target_id))
# await switch_event
# # Verify the switch worked
# current_url = await browser_session.get_current_page_url()
# assert f'{base_url}/page1' in current_url
# # Switch to tab 2 via direct event
# switch_event = browser_session.event_bus.dispatch(SwitchTabEvent(target_id=browser_session.tabs[2].target_id))
# await switch_event
# # Verify the switch worked
# current_url = await browser_session.get_current_page_url()
# assert f'{base_url}/page3' in current_url
# async def test_close_tab_event_dispatching(self, browser_session, base_url):
# """Test direct CloseTabEvent dispatching."""
# from browser_use.browser.events import TabClosedEvent
# # Create multiple tabs
# await browser_session.navigate_to(f'{base_url}/page1')
# await browser_session.create_new_tab(f'{base_url}/page2')
# initial_tab_count = len(browser_session.tabs)
# assert initial_tab_count == 2
# # Close tab 1 via direct event
# close_event = browser_session.event_bus.dispatch(CloseTabEvent(target_id=browser_session.tabs[1].target_id))
# await close_event
# # Verify tab was closed
# assert len(browser_session.tabs) == initial_tab_count - 1
# # Check event history for TabClosedEvent
# event_history = list(browser_session.event_bus.event_history.values())
# closed_events = [e for e in event_history if isinstance(e, TabClosedEvent)]
# assert len(closed_events) >= 1
# assert closed_events[-1].target_id == browser_session.tabs[1].target_id
@@ -0,0 +1,33 @@
import pytest
from browser_use.browser.events import NavigateToUrlEvent
from browser_use.browser.profile import BrowserProfile
from browser_use.browser.session import BrowserSession
@pytest.fixture(scope='function')
async def browser_session():
session = BrowserSession(browser_profile=BrowserProfile(headless=True))
await session.start()
yield session
await session.kill()
@pytest.mark.asyncio
async def test_basic_screenshots(browser_session: BrowserSession, httpserver):
"""Navigate to a local page and ensure screenshot helpers return bytes."""
html = """
<html><body><h1 id='title'>Hello</h1><p>Screenshot demo.</p></body></html>
"""
httpserver.expect_request('/demo').respond_with_data(html, content_type='text/html')
url = httpserver.url_for('/demo')
nav = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=url, new_tab=False))
await nav
data = await browser_session.take_screenshot(full_page=False)
assert data, 'Viewport screenshot returned no data'
element = await browser_session.screenshot_element('h1')
assert element, 'Element screenshot returned no data'
@@ -0,0 +1,121 @@
"""Test CDP session handling when creating new tabs."""
import asyncio
import pytest
from browser_use.browser.events import NavigateToUrlEvent, TabCreatedEvent
from browser_use.browser.profile import BrowserProfile, ViewportSize
from browser_use.browser.session import BrowserSession
@pytest.fixture
async def httpserver_url(httpserver):
"""Create a local HTTP server for testing."""
httpserver.expect_request('/').respond_with_data(
"""
<html>
<head><title>Test Page</title></head>
<body>
<h1>Test Page</h1>
<p>This is a test page</p>
</body>
</html>
""",
content_type='text/html',
)
return httpserver.url_for('/')
@pytest.mark.skip(reason='TODO: fix')
async def test_new_tab_cdp_session_attachment(httpserver_url):
"""Test that CDP session is properly attached when creating new tabs."""
browser = BrowserSession(browser_profile=BrowserProfile(headless=True, viewport=ViewportSize(width=800, height=600)))
tab_created_events = []
# Track TabCreatedEvent to verify it's dispatched correctly
browser.event_bus.on(TabCreatedEvent, lambda event: tab_created_events.append(event))
try:
await browser.start()
# Navigate to initial page
nav_event = browser.event_bus.dispatch(NavigateToUrlEvent(url=httpserver_url))
await nav_event
# Clear any initial tab created events
tab_created_events.clear()
# Now create a new tab - this should trigger the CDP error if not fixed
new_tab_event = browser.event_bus.dispatch(NavigateToUrlEvent(url=httpserver_url, new_tab=True))
await new_tab_event
# Wait a bit for all events to process
await asyncio.sleep(1)
# Verify that TabCreatedEvent was dispatched
assert len(tab_created_events) == 1, f'Expected 1 TabCreatedEvent, got {len(tab_created_events)}'
assert tab_created_events[0].url == httpserver_url
# Verify we have 2 tabs now
tabs = await browser.get_tabs()
assert len(tabs) == 2, f'Expected 2 tabs, got {len(tabs)}'
# Verify the CDP session is attached to the new tab
assert browser.agent_focus is not None
assert browser.agent_focus.target_id is not None
# Try to execute a CDP command on the new tab to verify it works
cdp_session = await browser.get_or_create_cdp_session()
result = await cdp_session.cdp_client.send.Runtime.evaluate(
params={'expression': 'document.title'}, session_id=cdp_session.session_id
)
assert result['result'].get('value') == 'Test Page'
# Get browser state to verify DOM can be built on new tab
from browser_use.browser.events import BrowserStateRequestEvent
state_event = browser.event_bus.dispatch(BrowserStateRequestEvent())
state = await state_event.event_result()
# Verify state was retrieved without errors
assert state is not None
assert state.dom_state is not None
finally:
await browser.stop()
async def test_multiple_new_tabs_cdp_session(httpserver_url):
"""Test creating multiple new tabs in succession."""
browser = BrowserSession(browser_profile=BrowserProfile(headless=True, viewport=ViewportSize(width=800, height=600)))
try:
await browser.start()
# Navigate to initial page
nav_event = browser.event_bus.dispatch(NavigateToUrlEvent(url=httpserver_url))
await nav_event
# Create multiple new tabs quickly
for i in range(3):
new_tab_event = browser.event_bus.dispatch(NavigateToUrlEvent(url=f'{httpserver_url}?tab={i}', new_tab=True))
await new_tab_event
# Wait for events to process
await asyncio.sleep(1)
# Verify we have 4 tabs total (1 initial + 3 new)
tabs = await browser.get_tabs()
assert len(tabs) == 4, f'Expected 4 tabs, got {len(tabs)}'
# Verify CDP commands work on the current tab
cdp_session = await browser.get_or_create_cdp_session()
result = await cdp_session.cdp_client.send.Runtime.evaluate(
params={'expression': 'window.location.href'}, session_id=cdp_session.session_id
)
assert 'tab=2' in result['result'].get('value', ''), 'Should be on the last created tab'
finally:
await browser.stop()
@@ -0,0 +1,21 @@
async def test_proxy_settings_pydantic_model():
"""
Test that ProxySettings as a Pydantic model is correctly converted to a dictionary when used.
"""
# Create ProxySettings with Pydantic model
proxy_settings = dict(server='http://example.proxy:4242', bypass='localhost', username='testuser', password='testpass')
# Verify the model has correct dict-like access
assert proxy_settings['server'] == 'http://example.proxy:4242'
assert proxy_settings.get('bypass') == 'localhost'
assert proxy_settings.get('nonexistent', 'default') == 'default'
# Verify model_dump works correctly
proxy_dict = dict(proxy_settings)
assert isinstance(proxy_dict, dict)
assert proxy_dict['server'] == 'http://example.proxy:4242'
assert proxy_dict['bypass'] == 'localhost'
assert proxy_dict['username'] == 'testuser'
assert proxy_dict['password'] == 'testpass'
# We don't launch the actual browser - we just verify the model itself works as expected
@@ -0,0 +1,328 @@
"""Test CrashWatchdog functionality."""
import asyncio
from typing import cast
import pytest
from browser_use.browser.events import (
BrowserConnectedEvent,
BrowserErrorEvent,
BrowserStartEvent,
BrowserStopEvent,
BrowserStoppedEvent,
NavigateToUrlEvent,
)
from browser_use.browser.profile import BrowserProfile
from browser_use.browser.session import BrowserSession
from browser_use.utils import logger
@pytest.mark.asyncio
@pytest.mark.skip('CrashWatchdog not implemented in current CDP architecture')
async def test_crash_watchdog_network_timeout():
"""Test that CrashWatchdog detects network timeouts by monitoring actual network requests."""
# Create browser session
profile = BrowserProfile(headless=True)
session = BrowserSession(browser_profile=profile)
try:
# Start browser using event system
session.event_bus.dispatch(BrowserStartEvent())
await session.event_bus.expect(BrowserConnectedEvent, timeout=10.0)
logger.info('[TEST] Browser started, configuring watchdog timeout')
# Configure crash watchdog with very short timeout for testing
if hasattr(session, '_crash_watchdog') and session._crash_watchdog:
session._crash_watchdog.network_timeout_seconds = 1.0 # Very short timeout
session._crash_watchdog.check_interval_seconds = 0.2 # Check frequently
# Try to navigate to a non-existent slow server that will hang
# This will create a real network request that will timeout
slow_url = 'http://192.0.2.1:4242/timeout-test' # RFC5737 TEST-NET-1 - non-routable
logger.info(f'[TEST] Navigating to non-routable URL via events: {slow_url}')
session.event_bus.dispatch(NavigateToUrlEvent(url=slow_url))
# Wait for the network timeout error via event bus
logger.info('[TEST] Waiting for NetworkTimeout event via event bus...')
try:
timeout_error = cast(
BrowserErrorEvent,
await session.event_bus.expect(
BrowserErrorEvent, predicate=lambda e: cast(BrowserErrorEvent, e).error_type == 'NetworkTimeout', timeout=8.0
),
)
# Verify the timeout event details
assert 'timeout-test' in timeout_error.details['url']
assert timeout_error.details['elapsed_seconds'] >= 0.8 # Should be at least close to our timeout
assert timeout_error.message.startswith('Network request timed out after')
logger.info(f'[TEST] Successfully detected network timeout: {timeout_error.message}')
except TimeoutError:
# Network timeout detection can be flaky in test environment
logger.warning('[TEST] NetworkTimeout event not received - this is expected in some test environments')
# Verify the crash watchdog is running and configured correctly
assert session._crash_watchdog is not None, 'CrashWatchdog should exist'
assert session._crash_watchdog.network_timeout_seconds == 1.0, 'Network timeout should be configured'
assert session._crash_watchdog._monitoring_task is not None, 'Monitoring task should be running'
assert not session._crash_watchdog._monitoring_task.done(), 'Monitoring task should still be active'
logger.info('[TEST] Crash watchdog is properly configured and running - test passes')
finally:
# Clean shutdown
try:
session.event_bus.dispatch(BrowserStopEvent())
await session.event_bus.expect(BrowserStoppedEvent, timeout=3.0)
except Exception:
# If graceful shutdown fails, force cleanup
await session.kill()
@pytest.mark.asyncio
@pytest.mark.skip('CrashWatchdog not implemented in current CDP architecture')
async def test_crash_watchdog_browser_disconnect():
"""Test that CrashWatchdog detects browser disconnection through monitoring."""
profile = BrowserProfile(headless=True)
session = BrowserSession(browser_profile=profile)
try:
# Start browser
start_event = session.event_bus.dispatch(BrowserStartEvent())
await start_event
# Ensure any exceptions from the event handler are propagated
await start_event.event_result(raise_if_any=True, raise_if_none=False)
# Wait for browser to be fully started
await session.event_bus.expect(BrowserConnectedEvent, timeout=5.0)
# Browser disconnection detection is now handled by the crash watchdog
# No configuration needed
# Mock browser disconnection by overriding is_connected
# This simulates what would happen if the browser process crashed
if session._cdp_client_root:
# Simulate disconnection by setting _cdp_client_root to None
original_cdp_client = session._cdp_client_root
session._cdp_client_root = None
try:
# Wait for watchdog to detect disconnection
disconnect_error: BrowserErrorEvent = cast(
BrowserErrorEvent,
await session.event_bus.expect(
BrowserErrorEvent,
predicate=lambda e: cast(BrowserErrorEvent, e).error_type == 'BrowserDisconnected',
timeout=2.0,
),
)
assert 'disconnected unexpectedly' in disconnect_error.message
finally:
# Restore original CDP client
session._cdp_client_root = original_cdp_client
finally:
# Force stop even if browser is marked as disconnected
try:
session.event_bus.dispatch(BrowserStopEvent(force=True))
await asyncio.sleep(0.5) # Give it time to stop
except Exception:
pass # Browser might already be stopped
@pytest.mark.asyncio
@pytest.mark.skip('CrashWatchdog not implemented in current CDP architecture')
async def test_crash_watchdog_lifecycle():
"""Test that CrashWatchdog starts and stops with browser session."""
profile = BrowserProfile(headless=True)
session = BrowserSession(browser_profile=profile)
# Start browser via event and wait for BrowserConnectedEvent
start_event = session.event_bus.dispatch(BrowserStartEvent())
await start_event # Wait for the event and all handlers to complete
started_event: BrowserConnectedEvent = cast(
BrowserConnectedEvent, await session.event_bus.expect(BrowserConnectedEvent, timeout=5.0)
)
assert started_event.cdp_url is not None
# Verify crash watchdog is running
assert hasattr(session, '_crash_watchdog'), 'CrashWatchdog should be created'
assert session._crash_watchdog is not None, 'CrashWatchdog should not be None'
# Check monitoring task is active
assert session._crash_watchdog._monitoring_task is not None
assert not session._crash_watchdog._monitoring_task.done()
# Stop browser via event
session.event_bus.dispatch(BrowserStopEvent())
# Wait for browser stopped event
try:
stopped_event: BrowserStoppedEvent = cast(
BrowserStoppedEvent, await session.event_bus.expect(BrowserStoppedEvent, timeout=3.0)
)
assert stopped_event.reason is not None
except TimeoutError:
# Browser stop can be flaky in test environment
logger.warning('[TEST] BrowserStoppedEvent timeout - this is expected in some test environments')
# Just verify the crash watchdog exists
assert session._crash_watchdog is not None
# Verify monitoring task was stopped
await asyncio.sleep(0.1) # Give it a moment to clean up
if session._crash_watchdog._monitoring_task:
assert session._crash_watchdog._monitoring_task.done()
@pytest.mark.asyncio
@pytest.mark.skip(reason='Browser initialization timeout in test environment - timing issue')
async def test_infinite_loop_page_blocking():
"""Test that pages with infinite JavaScript loops are detected as unresponsive."""
from pytest_httpserver import HTTPServer
# Create HTTP server with blocking page
httpserver = HTTPServer()
httpserver.start()
# Add route that serves permanently blocking JavaScript
httpserver.expect_request('/infinite-loop').respond_with_data(
'<html><body><h1>Loading...</h1><script>while(true){}</script></body></html>', content_type='text/html'
)
profile = BrowserProfile(headless=True)
session = BrowserSession(browser_profile=profile)
try:
# Start browser
session.event_bus.dispatch(BrowserStartEvent())
await session.event_bus.expect(BrowserConnectedEvent, timeout=5.0)
# Navigate to blocking page
blocking_url = httpserver.url_for('/infinite-loop')
session.event_bus.dispatch(NavigateToUrlEvent(url=blocking_url))
# The navigation should timeout or trigger an error
# We don't expect NavigationCompleteEvent since the page blocks
await asyncio.sleep(2) # Give it time to detect the issue
# Try to interact with the page via CDP - should still work at protocol level
cdp_session = await session.get_or_create_cdp_session()
# CDP commands should still work even if page is blocked
version_result = await session.cdp_client.send.Browser.getVersion()
assert version_result is not None
# Close the blocking tab to recover
await session.cdp_client.send.Target.closeTarget(params={'targetId': cdp_session.target_id})
finally:
httpserver.stop()
session.event_bus.dispatch(BrowserStopEvent())
await asyncio.sleep(0.5)
# @pytest.mark.asyncio
# async def test_transient_blocking_recovery():
# """Test recovery from temporarily blocking JavaScript."""
# from pytest_httpserver import HTTPServer
# httpserver = HTTPServer()
# httpserver.start()
# # Page that blocks for 1 second then recovers
# httpserver.expect_request('/transient-block').respond_with_data(
# """<html><body>
# <h1 id="status">Blocking...</h1>
# <script>
# const start = Date.now();
# while (Date.now() - start < 1000) {} // Block for 1 second
# document.getElementById('status').textContent = 'Recovered!';
# </script>
# </body></html>""",
# content_type='text/html',
# )
# profile = BrowserProfile(headless=True)
# session = BrowserSession(browser_profile=profile)
# try:
# # Start browser
# session.event_bus.dispatch(BrowserStartEvent())
# await session.event_bus.expect(BrowserConnectedEvent, timeout=5.0)
# # Navigate to transiently blocking page
# url = httpserver.url_for('/transient-block')
# session.event_bus.dispatch(NavigateToUrlEvent(url=url))
# # Wait for the blocking to end
# await asyncio.sleep(2)
# # Verify page recovered and we can interact with it
# cdp_session = await session.get_or_create_cdp_session()
# result = await session.cdp_client.send.Runtime.evaluate(
# params={'expression': 'document.getElementById("status").textContent', 'returnByValue': True},
# session_id=cdp_session.session_id,
# )
# status_text = result.get('result', {}).get('value', '')
# assert status_text == 'Recovered!', f"Expected 'Recovered!' but got '{status_text}'"
# finally:
# httpserver.stop()
# session.event_bus.dispatch(BrowserStopEvent())
# await asyncio.sleep(0.5)
@pytest.mark.asyncio
@pytest.mark.skip(reason='Browser initialization timeout in test environment - timing issue')
async def test_browser_process_kill_detection():
"""Test that killing the browser process is detected."""
import os
import signal
profile = BrowserProfile(headless=True)
session = BrowserSession(browser_profile=profile)
try:
# Start browser
session.event_bus.dispatch(BrowserStartEvent())
await session.event_bus.expect(BrowserConnectedEvent, timeout=5.0)
# Get browser process PID
browser_pid = None
if session._local_browser_watchdog and session._local_browser_watchdog._subprocess:
browser_pid = session._local_browser_watchdog._subprocess.pid
if browser_pid:
# Kill the browser process
try:
os.kill(browser_pid, signal.SIGKILL)
except ProcessLookupError:
pass # Process might already be gone
# Wait for crash detection
try:
error_event = cast(
BrowserErrorEvent,
await session.event_bus.expect(
BrowserErrorEvent,
predicate=lambda e: 'disconnect' in cast(BrowserErrorEvent, e).message.lower(),
timeout=5.0,
),
)
assert error_event is not None
except TimeoutError:
# Crash detection might not trigger in all environments
pass
finally:
# Force cleanup
try:
await session.kill()
except Exception:
pass
@@ -0,0 +1,265 @@
"""Test downloads watchdog functionality."""
import asyncio
import tempfile
from pathlib import Path
import pytest
from pytest_httpserver import HTTPServer
from browser_use.browser import BrowserSession
from browser_use.browser.events import (
BrowserConnectedEvent,
BrowserStartEvent,
BrowserStopEvent,
BrowserStoppedEvent,
NavigateToUrlEvent,
)
from browser_use.browser.profile import BrowserProfile
@pytest.mark.skip(reason='TODO: fix')
async def test_downloads_watchdog_lifecycle():
"""Test that DownloadsWatchdog starts and stops with browser session."""
# Create temp directory for downloads
with tempfile.TemporaryDirectory() as temp_dir:
downloads_path = Path(temp_dir)
profile = BrowserProfile(headless=True, downloads_path=downloads_path)
session = BrowserSession(browser_profile=profile)
# Check that downloads watchdog is None initially
assert session._downloads_watchdog is None
try:
# Start browser
session.event_bus.dispatch(BrowserStartEvent())
await session.event_bus.expect(BrowserConnectedEvent, timeout=5.0)
# Check that downloads watchdog is attached
assert session._downloads_watchdog is not None
# Verify watchdog has proper session reference
assert session._downloads_watchdog.browser_session is session
finally:
# Clean shutdown
try:
session.event_bus.dispatch(BrowserStopEvent())
await session.event_bus.expect(BrowserStoppedEvent, timeout=3.0)
except Exception:
# If graceful shutdown fails, force cleanup
await session.kill()
# Always stop event bus to prevent hanging
await session.event_bus.stop(clear=True, timeout=5)
@pytest.mark.skip(reason='TODO: fix')
async def test_downloads_watchdog_file_detection(download_test_server):
"""Test that DownloadsWatchdog detects file downloads."""
# Create temp directory for downloads
with tempfile.TemporaryDirectory() as temp_dir:
downloads_path = Path(temp_dir)
profile = BrowserProfile(headless=True, downloads_path=downloads_path)
session = BrowserSession(browser_profile=profile)
try:
# Start browser
session.event_bus.dispatch(BrowserStartEvent())
await session.event_bus.expect(BrowserConnectedEvent, timeout=5.0)
# Navigate to test page
test_url = download_test_server.url_for('/')
await session.event_bus.dispatch(NavigateToUrlEvent(url=test_url))
# Skip complex element selection for now - would need to implement selector-to-index conversion
pytest.skip('Complex element selection needs refactoring for CDP events')
# Wait for download to complete
await asyncio.sleep(2.0)
# Verify file was downloaded
downloaded_files = list(downloads_path.glob('*'))
assert len(downloaded_files) == 1, f'Expected 1 file, got {len(downloaded_files)}: {downloaded_files}'
# Verify file content
downloaded_file = downloaded_files[0]
assert downloaded_file.name == 'test.pdf'
assert downloaded_file.read_bytes() == b'PDF content'
finally:
# Clean shutdown
try:
session.event_bus.dispatch(BrowserStopEvent())
await session.event_bus.expect(BrowserStoppedEvent, timeout=3.0)
except Exception:
# If graceful shutdown fails, force cleanup
await session.kill()
# Always stop event bus to prevent hanging
await session.event_bus.stop(clear=True, timeout=5)
@pytest.fixture
def comprehensive_download_test_server():
"""Create a test server with downloadable files."""
httpserver = HTTPServer(host='127.0.0.1', port=0)
httpserver.start()
# Serve a main page with download links
main_page_html = """
<!DOCTYPE html>
<html>
<head>
<title>Download Test Page</title>
</head>
<body>
<h1>Download Test Page</h1>
<a href="/download/test.pdf" download="test.pdf">Download PDF</a>
<br>
<a href="/download/test.txt" download="test.txt">Download Text</a>
</body>
</html>
"""
httpserver.expect_request('/').respond_with_data(main_page_html, content_type='text/html')
# PDF handler
httpserver.expect_request('/download/test.pdf').respond_with_data(
b'PDF content', status=200, headers={'Content-Type': 'application/pdf'}
)
# Text handler
httpserver.expect_request('/download/test.txt').respond_with_data(
b'Text content', status=200, headers={'Content-Type': 'text/plain'}
)
yield httpserver
httpserver.stop()
# @pytest.mark.asyncio
# async def test_downloads_watchdog_page_attachment():
# """Test that DownloadsWatchdog attaches to pages properly."""
# # Create temp directory for downloads
# with tempfile.TemporaryDirectory() as temp_dir:
# downloads_path = Path(temp_dir)
# profile = BrowserProfile(headless=True, downloads_path=downloads_path)
# session = BrowserSession(browser_profile=profile)
# try:
# # Start browser
# session.event_bus.dispatch(BrowserStartEvent())
# await session.event_bus.expect(BrowserConnectedEvent, timeout=5.0)
# # Get downloads watchdog
# downloads_watchdog = session._downloads_watchdog
# assert downloads_watchdog is not None
# # Navigate to create a new page
# event = session.event_bus.dispatch(NavigateToUrlEvent(url='data:text/html,<h1>Test Page</h1>'))
# await event
# await event.event_result(raise_if_any=True, raise_if_none=False)
# # Verify watchdog has pages with listeners
# assert hasattr(downloads_watchdog, '_pages_with_listeners')
# # Give it a moment for page attachment
# await asyncio.sleep(0.2)
# # The watchdog should have attached to at least one page
# # Note: We can't easily verify the internal WeakSet without accessing private attrs
# finally:
# # Clean shutdown
# try:
# session.event_bus.dispatch(BrowserStopEvent())
# await session.event_bus.expect(BrowserStoppedEvent, timeout=3.0)
# except Exception:
# # If graceful shutdown fails, force cleanup
# await session.kill()
# # Always stop event bus to prevent hanging
# await session.event_bus.stop(clear=True, timeout=5)
# @pytest.mark.asyncio
# async def test_downloads_watchdog_default_downloads_path():
# """Test that DownloadsWatchdog works with default downloads path."""
# # Don't specify downloads path - should use default
# profile = BrowserProfile(headless=True)
# session = BrowserSession(browser_profile=profile)
# try:
# # Start browser
# session.event_bus.dispatch(BrowserStartEvent())
# await session.event_bus.expect(BrowserConnectedEvent, timeout=5.0)
# # Verify downloads watchdog is attached
# assert session._downloads_watchdog is not None
# # The default downloads path should be set
# # Note: We can't easily test the actual download without complex setup
# finally:
# # Clean shutdown
# try:
# session.event_bus.dispatch(BrowserStopEvent())
# await session.event_bus.expect(BrowserStoppedEvent, timeout=3.0)
# except Exception:
# # If graceful shutdown fails, force cleanup
# await session.kill()
# # Always stop event bus to prevent hanging
# await session.event_bus.stop(clear=True, timeout=5)
# @pytest.mark.asyncio
# async def test_unique_downloads_directories():
# """Test that different browser profiles get unique downloads directories."""
# # Create temp directory for downloads
# with tempfile.TemporaryDirectory() as temp_dir:
# downloads_path_1 = Path(temp_dir) / 'downloads1'
# downloads_path_2 = Path(temp_dir) / 'downloads2'
# downloads_path_1.mkdir()
# downloads_path_2.mkdir()
# profile1 = BrowserProfile(headless=True, downloads_path=downloads_path_1)
# profile2 = BrowserProfile(headless=True, downloads_path=downloads_path_2)
# session1 = BrowserSession(browser_profile=profile1)
# session2 = BrowserSession(browser_profile=profile2)
# try:
# # Start both browsers
# session1.event_bus.dispatch(BrowserStartEvent())
# await session1.event_bus.expect(BrowserConnectedEvent, timeout=5.0)
# session2.event_bus.dispatch(BrowserStartEvent())
# await session2.event_bus.expect(BrowserConnectedEvent, timeout=5.0)
# # Verify downloads watchdogs are attached
# assert session1._downloads_watchdog is not None
# assert session2._downloads_watchdog is not None
# # Verify they have different downloads paths
# assert session1.browser_profile.downloads_path != session2.browser_profile.downloads_path
# finally:
# # Clean shutdown both sessions
# for session in [session1, session2]:
# try:
# session.event_bus.dispatch(BrowserStopEvent())
# await session.event_bus.expect(BrowserStoppedEvent, timeout=3.0)
# except Exception:
# # If graceful shutdown fails, force cleanup
# await session.kill()
# # Always stop event bus to prevent hanging
# await session.event_bus.stop(clear=True, timeout=5)
# Removed test_downloads_watchdog_actual_download_detection - complex Playwright patterns not suitable for CDP
@@ -0,0 +1,33 @@
"""Test simple download functionality."""
import pytest
# Skip Playwright imports - removed dependency
from pytest_httpserver import HTTPServer
async def test_simple_playwright_download():
"""Test basic Playwright download functionality without browser-use - this just validates the browser setup"""
# Skip Playwright usage - removed dependency
pytest.skip('Playwright dependency removed')
@pytest.fixture(scope='function')
def http_server():
"""Create a test HTTP server with a downloadable file."""
server = HTTPServer()
server.start()
# Serve a simple text file for download
server.expect_request('/download/test.txt').respond_with_data(
'Hello World from HTTP Server', status=200, headers={'Content-Type': 'text/plain'}
)
yield server
server.stop()
async def test_browser_use_download_with_http_server(http_server):
"""Test browser-use download with HTTP server and event coordination"""
# Skip complex element selection for now - would need to implement selector-to-index conversion
pytest.skip('Complex element selection needs refactoring for CDP events')
@@ -0,0 +1,367 @@
"""Test full circle: download a file and then upload it back, verifying hash matches"""
import asyncio
import hashlib
import tempfile
from pathlib import Path
import pytest
from pytest_httpserver import HTTPServer
from browser_use.agent.views import ActionModel
from browser_use.browser import BrowserSession
from browser_use.browser.events import BrowserStateRequestEvent, FileDownloadedEvent
from browser_use.browser.profile import BrowserProfile
from browser_use.filesystem.file_system import FileSystem
from browser_use.tools.service import Tools
from browser_use.tools.views import ClickElementAction, GoToUrlAction, UploadFileAction
@pytest.fixture(scope='function')
def download_upload_server():
"""Create a test HTTP server with download and upload endpoints."""
server = HTTPServer()
server.start()
# Test file content and hash
test_content = b'This is a test file for download-upload verification. Random: 12345'
test_hash = hashlib.sha256(test_content).hexdigest()
# Store uploaded files data
uploaded_files = []
# Add download endpoint
server.expect_request('/download/test-file.txt').respond_with_data(
test_content, content_type='text/plain', headers={'Content-Disposition': 'attachment; filename="test-file.txt"'}
)
# Add upload page
upload_page_html = """
<!DOCTYPE html>
<html>
<head>
<title>Upload Test Page</title>
</head>
<body>
<h1>File Upload Test</h1>
<form id="uploadForm" action="/upload" method="POST" enctype="multipart/form-data">
<input type="file" id="fileInput" name="file" />
<button type="submit" id="submitButton">Upload File</button>
</form>
<div id="result"></div>
<script>
document.getElementById('uploadForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const file = formData.get('file');
if (file) {
// Read file content
const content = await file.text();
// Calculate SHA256 hash
const encoder = new TextEncoder();
const data = encoder.encode(content);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
// Display result
document.getElementById('result').innerHTML = `
<p>File uploaded successfully!</p>
<p>Filename: <span id="uploadedFileName">${file.name}</span></p>
<p>Size: <span id="uploadedFileSize">${file.size}</span> bytes</p>
<p>SHA256: <span id="uploadedFileHash">${hashHex}</span></p>
`;
// Send to server for verification
fetch('/upload', {
method: 'POST',
body: formData
});
}
});
</script>
</body>
</html>
"""
server.expect_request('/upload-page').respond_with_data(upload_page_html, content_type='text/html')
# Handle upload POST request (for server-side verification)
def handle_upload(request):
# Store uploaded file info for verification
if request.files and 'file' in request.files:
file_data = request.files['file'][0]
uploaded_files.append(
{
'filename': file_data['filename'],
'content': file_data['body'],
'hash': hashlib.sha256(file_data['body']).hexdigest(),
}
)
return request.make_response({'status': 'ok'})
server.expect_request('/upload', method='POST').respond_with_handler(handle_upload)
# Add download page with link
download_page_html = f"""
<!DOCTYPE html>
<html>
<head>
<title>Download Test Page</title>
</head>
<body>
<h1>File Download Test</h1>
<a id="downloadLink" href="/download/test-file.txt">Download Test File</a>
<p>Original file SHA256: <span id="originalHash">{test_hash}</span></p>
</body>
</html>
"""
server.expect_request('/download-page').respond_with_data(download_page_html, content_type='text/html')
# stop
server.stop()
# Skip complex HTTPServer attribute assignment - not supported
pytest.skip('Complex HTTPServer attribute assignment not supported')
yield server
server.stop()
class TestDownloadUploadFullCircle:
"""Test full circle: download a file and then upload it back"""
async def test_download_then_upload_with_hash_verification(self, download_upload_server):
"""Download a file, then upload it to another page, verify hash matches"""
# Create temporary directory for downloads
with tempfile.TemporaryDirectory() as tmpdir:
downloads_path = Path(tmpdir) / 'downloads'
downloads_path.mkdir()
# Create browser session with downloads enabled
browser_session = BrowserSession(
browser_profile=BrowserProfile(
headless=True,
downloads_path=str(downloads_path),
user_data_dir=None,
)
)
await browser_session.start()
# Create tools and file system
tools = Tools()
file_system = FileSystem(base_dir=tmpdir)
try:
base_url = f'http://{download_upload_server.host}:{download_upload_server.port}'
# Step 1: Navigate to download page
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
result = await tools.act(
GoToUrlActionModel(go_to_url=GoToUrlAction(url=f'{base_url}/download-page', new_tab=False)), browser_session
)
assert result.error is None, f'Navigation to download page failed: {result.error}'
await asyncio.sleep(0.5)
# Get browser state to find download link
event = browser_session.event_bus.dispatch(BrowserStateRequestEvent())
state_result = await event.event_result()
assert state_result is not None
assert state_result.dom_state is not None
assert state_result.dom_state.selector_map is not None
# Find download link
download_link_index = None
for idx, element in state_result.dom_state.selector_map.items():
if element.attributes and element.attributes.get('id') == 'downloadLink':
download_link_index = idx
break
assert download_link_index is not None, 'Download link not found'
# Step 2: Click download link and wait for download
class ClickActionModel(ActionModel):
click_element_by_index: ClickElementAction | None = None
# Click the download link
result = await tools.act(
ClickActionModel(click_element_by_index=ClickElementAction(index=download_link_index)), browser_session
)
assert result.error is None, f'Click on download link failed: {result.error}'
# Wait for the download event
try:
download_event = await browser_session.event_bus.expect(FileDownloadedEvent, timeout=10.0)
downloaded_file_path = download_event.path
except TimeoutError:
pytest.fail('Download did not complete within timeout')
assert downloaded_file_path is not None, 'Downloaded file path is None'
assert Path(downloaded_file_path).exists(), f'Downloaded file does not exist: {downloaded_file_path}'
# Verify download is tracked by browser_session
assert downloaded_file_path in browser_session.downloaded_files, (
f'Downloaded file not tracked by browser_session: {downloaded_file_path}'
)
# Calculate hash of downloaded file
downloaded_content = Path(downloaded_file_path).read_bytes()
downloaded_hash = hashlib.sha256(downloaded_content).hexdigest()
print(f'✅ File downloaded: {downloaded_file_path}')
print(f' Original hash: {download_upload_server.test_hash}')
print(f' Downloaded hash: {downloaded_hash}')
assert downloaded_hash == download_upload_server.test_hash, "Downloaded file hash doesn't match original"
# Step 3: Navigate to upload page in a new tab
print(f'\n🔄 Opening upload page in new tab: {base_url}/upload-page')
# Debug: Check how many tabs we have before navigation
tabs_before = await browser_session.get_tabs()
print(f'📑 Tabs before navigation: {len(tabs_before)} tabs')
for i, tab in enumerate(tabs_before):
print(f' Tab {i}: {tab.url}')
result = await tools.act(
GoToUrlActionModel(go_to_url=GoToUrlAction(url=f'{base_url}/upload-page', new_tab=True)), browser_session
)
assert result.error is None, f'Navigation to upload page failed: {result.error}'
print(f'✅ Navigation result: {result.extracted_content}')
# The new tab should be automatically focused after opening
await asyncio.sleep(2.0) # Give more time for the new tab to load and focus
# Debug: Get all tabs
tabs = await browser_session.get_tabs()
print('\n📑 All tabs after opening upload page:')
for i, tab in enumerate(tabs):
print(f' Tab {i}: {tab.url} - {tab.title}')
# Get browser state to find file input
event = browser_session.event_bus.dispatch(BrowserStateRequestEvent())
await event
state_result = await event.event_result(raise_if_any=True, raise_if_none=False)
assert state_result is not None
assert state_result.dom_state is not None
assert state_result.dom_state.selector_map is not None
# Debug: print page URL and title
print('\n🔍 Getting DOM state:')
print(f' Current page URL: {state_result.url}')
print(f' Current page title: {state_result.title}')
# Find file input
file_input_index = None
input_elements = []
for idx, element in state_result.dom_state.selector_map.items():
if element.tag_name and element.tag_name.lower() == 'input':
input_elements.append((idx, element.attributes))
if element.attributes and element.attributes.get('type') == 'file':
file_input_index = idx
break
print(f'Found {len(input_elements)} input elements: {input_elements}')
assert file_input_index is not None, 'File input not found'
# Step 4: Upload the downloaded file
class UploadActionModel(ActionModel):
upload_file_to_element: UploadFileAction | None = None
# The downloaded file should be automatically available for upload
result = await tools.act(
UploadActionModel(upload_file_to_element=UploadFileAction(index=file_input_index, path=downloaded_file_path)),
browser_session,
available_file_paths=[], # Empty, but file is in downloaded_files
file_system=file_system,
)
assert result.error is None, f'File upload failed: {result.error}'
# Step 4b: Click the submit button to trigger the form submission
# Get browser state to find submit button
event = browser_session.event_bus.dispatch(BrowserStateRequestEvent())
state_result = await event.event_result()
# Find submit button
submit_button_index = None
if state_result and state_result.dom_state:
for idx, element in state_result.dom_state.selector_map.items():
if (
element.tag_name
and element.tag_name.lower() == 'button'
and element.attributes
and element.attributes.get('id') == 'submitButton'
):
submit_button_index = idx
break
assert submit_button_index is not None, 'Submit button not found'
# Click the submit button
result = await tools.act(
ClickActionModel(click_element_by_index=ClickElementAction(index=submit_button_index)), browser_session
)
assert result.error is None, f'Click on submit button failed: {result.error}'
# Wait for JavaScript to process the upload
await asyncio.sleep(1.0)
# Step 5: Verify upload via JavaScript (client-side hash)
cdp_session = await browser_session.get_or_create_cdp_session()
# Get uploaded file details from the page
upload_verification = await browser_session.cdp_client.send.Runtime.evaluate(
params={
'expression': """
(() => {
const fileName = document.getElementById('uploadedFileName')?.textContent;
const fileSize = document.getElementById('uploadedFileSize')?.textContent;
const fileHash = document.getElementById('uploadedFileHash')?.textContent;
return {
fileName: fileName || null,
fileSize: fileSize || null,
fileHash: fileHash || null,
hasResult: !!document.getElementById('result').textContent.includes('successfully')
};
})()
""",
'returnByValue': True,
},
session_id=cdp_session.session_id,
)
upload_info = upload_verification.get('result', {}).get('value', {})
# Verify upload was successful
assert upload_info.get('hasResult') is True, 'Upload result not displayed'
assert upload_info.get('fileName') == 'test-file.txt', (
f'Uploaded filename mismatch: {upload_info.get("fileName")}'
)
assert upload_info.get('fileHash') == download_upload_server.test_hash, (
f'Uploaded file hash mismatch. Expected: {download_upload_server.test_hash}, Got: {upload_info.get("fileHash")}'
)
print('✅ File uploaded successfully!')
print(f' Uploaded filename: {upload_info.get("fileName")}')
print(f' Uploaded hash: {upload_info.get("fileHash")}')
print(f' Hash matches original: {upload_info.get("fileHash") == download_upload_server.test_hash}')
# Step 6: Verify server-side upload (if needed)
if download_upload_server.uploaded_files:
server_file = download_upload_server.uploaded_files[0]
assert server_file['hash'] == download_upload_server.test_hash, (
f'Server-side hash mismatch. Expected: {download_upload_server.test_hash}, Got: {server_file["hash"]}'
)
print('✅ Server-side verification passed!')
print('\n🎉 Full circle test passed: Download → Upload with hash verification!')
finally:
await browser_session.stop()
@@ -0,0 +1,403 @@
"""
Test that screenshots work correctly in headless browser mode.
"""
import asyncio
import base64
import time
import pytest
from browser_use.browser import BrowserProfile, BrowserSession
from browser_use.browser.events import NavigateToUrlEvent, ScreenshotEvent
class TestHeadlessScreenshots:
"""Test screenshot functionality specifically in headless browsers"""
@pytest.mark.skip(reason='TODO: fix')
async def test_screenshot_works_in_headless_mode(self, httpserver):
"""Explicitly test that screenshots can be captured in headless=True mode"""
# Create a browser session with headless=True
browser_session = BrowserSession(
browser_profile=BrowserProfile(
headless=True, # Explicitly set headless mode
user_data_dir=None,
keep_alive=False,
)
)
try:
# Start the session
await browser_session.start()
assert browser_session._cdp_client_root is not None
# Set up test page with visible content
httpserver.expect_request('/').respond_with_data(
"""<html>
<head><title>Headless Screenshot Test</title></head>
<body style="background: white; padding: 20px;">
<h1 style="color: black;">This is a test page</h1>
<p style="color: blue;">Testing screenshot capture in headless mode</p>
<div style="width: 200px; height: 100px; background: red;">Red Box</div>
</body>
</html>""",
content_type='text/html',
)
# Navigate to test page
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=httpserver.url_for('/')))
await event
await event.event_result(raise_if_any=True, raise_if_none=False)
# Take screenshot
screenshot_event = browser_session.event_bus.dispatch(ScreenshotEvent())
await screenshot_event
screenshot_b64 = await screenshot_event.event_result(raise_if_any=True, raise_if_none=False)
# Verify screenshot was captured
assert screenshot_b64 is not None
assert isinstance(screenshot_b64, str)
assert len(screenshot_b64) > 0
# Decode and validate the screenshot
screenshot_bytes = base64.b64decode(screenshot_b64)
# Verify PNG signature
assert screenshot_bytes.startswith(b'\x89PNG\r\n\x1a\n')
# Should be a reasonable size (not just a blank image)
assert len(screenshot_bytes) > 5000, f'Screenshot too small: {len(screenshot_bytes)} bytes'
# Test full page screenshot
screenshot_event = browser_session.event_bus.dispatch(ScreenshotEvent(full_page=True))
await screenshot_event
full_page_screenshot = await screenshot_event.event_result(raise_if_any=True, raise_if_none=False)
assert full_page_screenshot is not None
full_page_bytes = base64.b64decode(full_page_screenshot)
assert full_page_bytes.startswith(b'\x89PNG\r\n\x1a\n')
assert len(full_page_bytes) > 5000
finally:
await browser_session.kill()
@pytest.mark.skip(reason='TODO: fix')
async def test_screenshot_with_state_summary_in_headless(self, httpserver):
"""Test that get_state_summary includes screenshots in headless mode"""
browser_session = BrowserSession(
browser_profile=BrowserProfile(
headless=True,
user_data_dir=None,
keep_alive=False,
)
)
try:
await browser_session.start()
# Set up test page
httpserver.expect_request('/').respond_with_data(
'<html><body><h1>State Summary Test</h1></body></html>',
content_type='text/html',
)
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=httpserver.url_for('/')))
await event
await event.event_result(raise_if_any=True, raise_if_none=False)
# Get state summary
state = await browser_session.get_browser_state_summary(cache_clickable_elements_hashes=False)
# Verify screenshot is included
assert state.screenshot is not None
assert isinstance(state.screenshot, str)
assert len(state.screenshot) > 0
# Decode and validate
screenshot_bytes = base64.b64decode(state.screenshot)
assert screenshot_bytes.startswith(b'\x89PNG\r\n\x1a\n')
assert len(screenshot_bytes) > 1000
finally:
await browser_session.kill()
@pytest.mark.skip(reason='TODO: fix')
async def test_screenshot_graceful_handling_in_headless(self, httpserver):
"""Test that screenshot handling works correctly in headless mode even with closed pages"""
# Set up test page
httpserver.expect_request('/test').respond_with_data(
'<html><body><h1>Test Page</h1></body></html>', content_type='text/html'
)
browser_session = BrowserSession(
browser_profile=BrowserProfile(
headless=True,
user_data_dir=None,
keep_alive=False,
)
)
try:
await browser_session.start()
# Skip complex page manipulation - CDP doesn't have direct pages access
pytest.skip('CDP pages access pattern needs refactoring')
# Browser should auto-create a new page on about:blank with animation
# With AboutBlankWatchdog, about:blank pages now have animated content, so they should have screenshots
state = await browser_session.get_browser_state_summary(cache_clickable_elements_hashes=False)
assert state.screenshot is not None, 'Screenshot should not be None for animated about:blank pages'
assert state.url == 'about:blank' or state.url.startswith('chrome://'), f'Expected empty page but got {state.url}'
# Now navigate to a real page and verify screenshot works
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=httpserver.url_for('/test')))
await event
await event.event_result(raise_if_any=True, raise_if_none=False)
# Get state with screenshot
state = await browser_session.get_browser_state_summary(cache_clickable_elements_hashes=False)
# Should have a screenshot now
assert state.screenshot is not None, 'Screenshot should not be None for real pages'
assert isinstance(state.screenshot, str)
assert len(state.screenshot) > 100, 'Screenshot should have substantial content'
assert 'test' in state.url.lower()
finally:
await browser_session.kill()
@pytest.mark.skip(reason='TODO: fix')
async def test_parallel_screenshots_long_page(self, httpserver):
"""Test screenshots in a highly parallel environment with a very long page"""
# Generate a very long page (50,000px+)
long_content = []
long_content.append('<html><head><title>Very Long Page</title></head>')
long_content.append('<body style="margin: 0; padding: 0;">')
# Add many div elements to create a 50,000px+ long page
# Each div is 500px tall, so we need 100+ divs
for i in range(120):
color = f'rgb({i % 256}, {(i * 2) % 256}, {(i * 3) % 256})'
long_content.append(
f'<div style="height: 500px; background: {color}; '
f'display: flex; align-items: center; justify-content: center; '
f'font-size: 48px; color: white; text-shadow: 2px 2px 4px rgba(0,0,0,0.5);">'
f'Section {i + 1} - Testing Parallel Screenshots'
f'</div>'
)
long_content.append('</body></html>')
html_content = ''.join(long_content)
# Set up the test page
httpserver.expect_request('/longpage').respond_with_data(
html_content,
content_type='text/html',
)
test_url = httpserver.url_for('/longpage')
# Create 10 browser sessions
browser_sessions = []
for i in range(10):
session = BrowserSession(
browser_profile=BrowserProfile(
headless=True,
user_data_dir=None,
keep_alive=False,
)
)
browser_sessions.append(session)
try:
# Start all sessions sequentially to avoid playwright_global_object semaphore contention
# The playwright global object semaphore only allows 1 concurrent initialization
print('Starting 10 browser sessions sequentially...')
for i, session in enumerate(browser_sessions):
print(f'Starting session {i + 1}/10...')
await session.start()
# Navigate all sessions to the long page in parallel
print('Navigating all sessions to the long test page...')
await asyncio.gather(*[session.navigate(test_url) for session in browser_sessions])
# Take screenshots from all sessions
# Due to semaphore_limit=1, these will execute sequentially
print('Taking screenshots from all 10 sessions...')
start_time = time.time()
screenshot_tasks = [session.take_screenshot() for session in browser_sessions]
# Use return_exceptions=True to handle any failures gracefully
results = await asyncio.gather(*screenshot_tasks, return_exceptions=True)
total_time = time.time() - start_time
# Verify timing - with semaphore_limit=1, screenshots execute sequentially
# Each screenshot should take ~1.5s, so 10 × 1.5s = 15s, allow up to 30s for overhead
assert total_time < 30, f'Screenshots took too long: {total_time:.1f}s (should be < 30s)'
print(f'All screenshot attempts completed in {total_time:.1f}s')
# Separate successful screenshots from failures
screenshots = []
failures = []
for i, result in enumerate(results):
if isinstance(result, Exception):
failures.append((i, result))
print(f'Session {i} failed: {type(result).__name__}: {result}')
else:
screenshots.append(result)
print(f'Session {i} screenshot completed successfully')
# ALL screenshots must succeed
assert len(failures) == 0, (
f'{len(failures)} screenshots failed: {[(i, type(e).__name__, str(e)) for i, e in failures]}'
)
assert len(screenshots) == 10, f'Expected 10 successful screenshots, got {len(screenshots)}'
print('✅ All 10 screenshots captured successfully!')
# Verify all screenshots are valid
print('Verifying all 10 screenshots...')
for i, screenshot in enumerate(screenshots):
# Should not be None
assert screenshot is not None, f'Screenshot {i} returned None'
assert isinstance(screenshot, str), f'Screenshot {i} is not a string'
assert len(screenshot) > 0, f'Screenshot {i} is empty'
# Decode and validate
try:
screenshot_bytes = base64.b64decode(screenshot)
except Exception as e:
raise AssertionError(f'Screenshot {i} is not valid base64: {e}')
# Verify PNG signature
assert screenshot_bytes.startswith(b'\x89PNG\r\n\x1a\n'), f'Screenshot {i} is not a valid PNG'
# Full page screenshot should be reasonably large
# Due to our 6,000px height limit, expect at least 5KB
assert len(screenshot_bytes) > 20, f'Screenshot {i} too small: {len(screenshot_bytes)} bytes'
if len(screenshot_bytes) < 500:
print(
f'⚠️ Screenshot {i} failed to be taken in time, it returned a blank image instead: {len(screenshot_bytes)} bytes, perhaps the page failed to load in time?'
)
print('✅ All 10 screenshots validated successfully!')
# Also test taking regular (viewport) screenshots
print('\nTaking viewport screenshots from all sessions...')
start_time = time.time()
viewport_results = await asyncio.gather(
*[session.take_screenshot() for session in browser_sessions], return_exceptions=True
)
viewport_time = time.time() - start_time
assert viewport_time < 30, f'Viewport screenshots took too long: {viewport_time:.1f}s (should be < 30s)'
print(f'All viewport screenshot attempts completed in {viewport_time:.1f}s')
# Check for failures
viewport_screenshots = []
viewport_failures = []
for i, result in enumerate(viewport_results):
if isinstance(result, Exception):
viewport_failures.append((i, result))
print(f'Session {i} viewport failed: {type(result).__name__}: {result}')
else:
viewport_screenshots.append(result)
print(f'Session {i} viewport screenshot completed successfully')
# ALL viewport screenshots must succeed
assert len(viewport_failures) == 0, (
f'{len(viewport_failures)} viewport screenshots failed: {[(i, type(e).__name__, str(e)) for i, e in viewport_failures]}'
)
assert len(viewport_screenshots) == 10, (
f'Expected 10 successful viewport screenshots, got {len(viewport_screenshots)}'
)
print('✅ All 10 viewport screenshots captured successfully!')
# Verify all 10 viewport screenshots
print('Verifying all 10 viewport screenshots...')
for i, screenshot in enumerate(viewport_screenshots):
assert screenshot is not None, f'Viewport screenshot {i} is None'
screenshot_bytes = base64.b64decode(screenshot)
assert screenshot_bytes.startswith(b'\x89PNG\r\n\x1a\n'), f'Viewport screenshot {i} is not a valid PNG'
# Viewport screenshots should be reasonably sized
assert len(screenshot_bytes) > 10, f'Viewport screenshot {i} too small: {len(screenshot_bytes)} bytes'
print('✅ All 10 viewport screenshots validated successfully!')
finally:
# Kill all sessions in parallel
print('Killing all browser sessions...')
# Use return_exceptions=True to prevent one failed kill from affecting others
# This prevents "Future exception was never retrieved" errors
results = await asyncio.gather(*[session.kill() for session in browser_sessions], return_exceptions=True)
# Check that no exceptions were raised during cleanup
for i, result in enumerate(results):
if isinstance(result, Exception):
print(f'Warning: Session {i} kill raised exception: {type(result).__name__}: {result}')
@pytest.mark.skip(reason='TODO: fix')
async def test_screenshot_at_bottom_of_page(self, httpserver):
"""Test screenshot capture when scrolled to bottom of page (regression test for clipping issue)"""
browser_session = BrowserSession(
browser_profile=BrowserProfile(
headless=True,
user_data_dir=None,
keep_alive=False,
)
)
try:
await browser_session.start()
# Create a page with scrollable content
httpserver.expect_request('/scrollable').respond_with_data(
"""<html>
<head><title>Scrollable Page Test</title></head>
<body style="margin: 0; padding: 0;">
<div style="height: 3000px; background: linear-gradient(to bottom, red, yellow, green, blue);">
<div style="position: absolute; top: 0; left: 10px; font-size: 24px;">Top of page</div>
<div style="position: absolute; top: 50%; left: 10px; font-size: 24px;">Middle of page</div>
<div style="position: absolute; bottom: 10px; left: 10px; font-size: 24px;">Bottom of page</div>
</div>
</body>
</html>""",
content_type='text/html',
)
# Navigate to test page
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=httpserver.url_for('/scrollable')))
await event
await event.event_result(raise_if_any=True, raise_if_none=False)
# Skip - get_current_tab doesn't exist in CDP session
pytest.skip('get_current_tab method not available in CDP session')
finally:
await browser_session.kill()
class TestScreenshotEventSystem:
"""Tests for NEW event-driven screenshot infrastructure only."""
@pytest.mark.skip(reason='TODO: fix')
async def test_screenshot_response_event_dispatching(self, httpserver):
"""Test that ScreenshotResponseEvent is properly dispatched by event handlers."""
from browser_use.browser.events import ScreenshotEvent
browser_session = BrowserSession(browser_profile=BrowserProfile(headless=True, user_data_dir=None, keep_alive=False))
try:
await browser_session.start()
# Set up test page
httpserver.expect_request('/event-test').respond_with_data(
'<html><body><h1>Screenshot Event Test</h1></body></html>',
content_type='text/html',
)
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=httpserver.url_for('/event-test')))
await event
await event.event_result(raise_if_any=True, raise_if_none=False)
# Test the NEW event-driven path: direct event dispatching
event = browser_session.event_bus.dispatch(ScreenshotEvent(full_page=False))
screenshot_b64 = await event.event_result()
assert screenshot_b64 is not None
assert isinstance(screenshot_b64, str)
assert len(base64.b64decode(screenshot_b64)) > 5000
finally:
await browser_session.kill()
@@ -0,0 +1,435 @@
from browser_use.browser import BrowserProfile, BrowserSession
class TestUrlAllowlistSecurity:
"""Tests for URL allowlist security bypass prevention and URL allowlist glob pattern matching."""
def test_authentication_bypass_prevention(self):
"""Test that the URL allowlist cannot be bypassed using authentication credentials."""
from bubus import EventBus
from browser_use.browser.watchdogs.security_watchdog import SecurityWatchdog
# Create a context config with a sample allowed domain
browser_profile = BrowserProfile(allowed_domains=['example.com'], headless=True, user_data_dir=None)
browser_session = BrowserSession(browser_profile=browser_profile)
event_bus = EventBus()
watchdog = SecurityWatchdog(browser_session=browser_session, event_bus=event_bus)
# Security vulnerability test cases
# These should all be detected as malicious despite containing "example.com"
assert watchdog._is_url_allowed('https://example.com:password@malicious.com') is False
assert watchdog._is_url_allowed('https://example.com@malicious.com') is False
assert watchdog._is_url_allowed('https://example.com%20@malicious.com') is False
assert watchdog._is_url_allowed('https://example.com%3A@malicious.com') is False
# Make sure legitimate auth credentials still work
assert watchdog._is_url_allowed('https://user:password@example.com') is True
def test_glob_pattern_matching(self):
"""Test that glob patterns in allowed_domains work correctly."""
from bubus import EventBus
from browser_use.browser.watchdogs.security_watchdog import SecurityWatchdog
# Test *.example.com pattern (should match subdomains and main domain)
browser_profile = BrowserProfile(allowed_domains=['*.example.com'], headless=True, user_data_dir=None)
browser_session = BrowserSession(browser_profile=browser_profile)
event_bus = EventBus()
watchdog = SecurityWatchdog(browser_session=browser_session, event_bus=event_bus)
# Should match subdomains
assert watchdog._is_url_allowed('https://sub.example.com') is True
assert watchdog._is_url_allowed('https://deep.sub.example.com') is True
# Should also match main domain
assert watchdog._is_url_allowed('https://example.com') is True
# Should not match other domains
assert watchdog._is_url_allowed('https://notexample.com') is False
assert watchdog._is_url_allowed('https://example.org') is False
# Test more complex glob patterns
browser_profile = BrowserProfile(
allowed_domains=[
'*.google.com',
'https://wiki.org',
'https://good.com',
'https://*.test.com',
'chrome://version',
'brave://*',
],
headless=True,
user_data_dir=None,
)
browser_session = BrowserSession(browser_profile=browser_profile)
event_bus = EventBus()
watchdog = SecurityWatchdog(browser_session=browser_session, event_bus=event_bus)
# Should match domains ending with google.com
assert watchdog._is_url_allowed('https://google.com') is True
assert watchdog._is_url_allowed('https://www.google.com') is True
assert (
watchdog._is_url_allowed('https://evilgood.com') is False
) # make sure we dont allow *good.com patterns, only *.good.com
# Should match domains starting with wiki
assert watchdog._is_url_allowed('http://wiki.org') is False
assert watchdog._is_url_allowed('https://wiki.org') is True
# Should not match internal domains because scheme was not provided
assert watchdog._is_url_allowed('chrome://google.com') is False
assert watchdog._is_url_allowed('chrome://abc.google.com') is False
# Test browser internal URLs
assert watchdog._is_url_allowed('chrome://settings') is False
assert watchdog._is_url_allowed('chrome://version') is True
assert watchdog._is_url_allowed('chrome-extension://version/') is False
assert watchdog._is_url_allowed('brave://anything/') is True
assert watchdog._is_url_allowed('about:blank') is True
assert watchdog._is_url_allowed('chrome://new-tab-page/') is True
assert watchdog._is_url_allowed('chrome://new-tab-page') is True
# Test security for glob patterns (authentication credentials bypass attempts)
# These should all be detected as malicious despite containing allowed domain patterns
assert watchdog._is_url_allowed('https://allowed.example.com:password@notallowed.com') is False
assert watchdog._is_url_allowed('https://subdomain.example.com@evil.com') is False
assert watchdog._is_url_allowed('https://sub.example.com%20@malicious.org') is False
assert watchdog._is_url_allowed('https://anygoogle.com@evil.org') is False
# Test pattern matching
assert watchdog._is_url_allowed('https://www.test.com') is True
assert watchdog._is_url_allowed('https://www.testx.com') is False
def test_glob_pattern_edge_cases(self):
"""Test edge cases for glob pattern matching to ensure proper behavior."""
from bubus import EventBus
from browser_use.browser.watchdogs.security_watchdog import SecurityWatchdog
# Test with domains containing glob pattern in the middle
browser_profile = BrowserProfile(allowed_domains=['*.google.com', 'https://wiki.org'], headless=True, user_data_dir=None)
browser_session = BrowserSession(browser_profile=browser_profile)
event_bus = EventBus()
watchdog = SecurityWatchdog(browser_session=browser_session, event_bus=event_bus)
# Verify that 'wiki*' pattern doesn't match domains that merely contain 'wiki' in the middle
assert watchdog._is_url_allowed('https://notawiki.com') is False
assert watchdog._is_url_allowed('https://havewikipages.org') is False
assert watchdog._is_url_allowed('https://my-wiki-site.com') is False
# Verify that '*google.com' doesn't match domains that have 'google' in the middle
assert watchdog._is_url_allowed('https://mygoogle.company.com') is False
# Create context with potentially risky glob pattern that demonstrates security concerns
browser_profile = BrowserProfile(allowed_domains=['*.google.com', '*.google.co.uk'], headless=True, user_data_dir=None)
browser_session = BrowserSession(browser_profile=browser_profile)
event_bus = EventBus()
watchdog = SecurityWatchdog(browser_session=browser_session, event_bus=event_bus)
# Should match legitimate Google domains
assert watchdog._is_url_allowed('https://www.google.com') is True
assert watchdog._is_url_allowed('https://mail.google.co.uk') is True
# Shouldn't match potentially malicious domains with a similar structure
# This demonstrates why the previous pattern was risky and why it's now rejected
assert watchdog._is_url_allowed('https://www.google.evil.com') is False
def test_automatic_www_subdomain_addition(self):
"""Test that root domains automatically allow www subdomain."""
from bubus import EventBus
from browser_use.browser.watchdogs.security_watchdog import SecurityWatchdog
# Test with simple root domains
browser_profile = BrowserProfile(allowed_domains=['example.com', 'test.org'], headless=True, user_data_dir=None)
browser_session = BrowserSession(browser_profile=browser_profile)
event_bus = EventBus()
watchdog = SecurityWatchdog(browser_session=browser_session, event_bus=event_bus)
# Root domain should allow itself
assert watchdog._is_url_allowed('https://example.com') is True
assert watchdog._is_url_allowed('https://test.org') is True
# Root domain should automatically allow www subdomain
assert watchdog._is_url_allowed('https://www.example.com') is True
assert watchdog._is_url_allowed('https://www.test.org') is True
# Should not allow other subdomains
assert watchdog._is_url_allowed('https://mail.example.com') is False
assert watchdog._is_url_allowed('https://sub.test.org') is False
# Should not allow unrelated domains
assert watchdog._is_url_allowed('https://notexample.com') is False
assert watchdog._is_url_allowed('https://www.notexample.com') is False
def test_www_subdomain_not_added_for_country_tlds(self):
"""Test www subdomain is NOT automatically added for country-specific TLDs (2+ dots)."""
from bubus import EventBus
from browser_use.browser.watchdogs.security_watchdog import SecurityWatchdog
# Test with country-specific TLDs - these should NOT get automatic www
browser_profile = BrowserProfile(
allowed_domains=['example.co.uk', 'test.com.au', 'site.co.jp'], headless=True, user_data_dir=None
)
browser_session = BrowserSession(browser_profile=browser_profile)
event_bus = EventBus()
watchdog = SecurityWatchdog(browser_session=browser_session, event_bus=event_bus)
# Root domains should work exactly as specified
assert watchdog._is_url_allowed('https://example.co.uk') is True
assert watchdog._is_url_allowed('https://test.com.au') is True
assert watchdog._is_url_allowed('https://site.co.jp') is True
# www subdomains should NOT work automatically (user must specify explicitly)
assert watchdog._is_url_allowed('https://www.example.co.uk') is False
assert watchdog._is_url_allowed('https://www.test.com.au') is False
assert watchdog._is_url_allowed('https://www.site.co.jp') is False
# Other subdomains should not work
assert watchdog._is_url_allowed('https://mail.example.co.uk') is False
assert watchdog._is_url_allowed('https://api.test.com.au') is False
def test_www_subdomain_not_added_for_existing_subdomains(self):
"""Test that www is not automatically added for domains that already have subdomains."""
from bubus import EventBus
from browser_use.browser.watchdogs.security_watchdog import SecurityWatchdog
# Test with existing subdomains - should NOT get automatic www
browser_profile = BrowserProfile(allowed_domains=['mail.example.com', 'api.test.org'], headless=True, user_data_dir=None)
browser_session = BrowserSession(browser_profile=browser_profile)
event_bus = EventBus()
watchdog = SecurityWatchdog(browser_session=browser_session, event_bus=event_bus)
# Exact subdomain should work
assert watchdog._is_url_allowed('https://mail.example.com') is True
assert watchdog._is_url_allowed('https://api.test.org') is True
# www should NOT be automatically added to subdomains
assert watchdog._is_url_allowed('https://www.mail.example.com') is False
assert watchdog._is_url_allowed('https://www.api.test.org') is False
# Root domains should not work either
assert watchdog._is_url_allowed('https://example.com') is False
assert watchdog._is_url_allowed('https://test.org') is False
def test_www_subdomain_not_added_for_wildcard_patterns(self):
"""Test that www is not automatically added for wildcard patterns."""
from bubus import EventBus
from browser_use.browser.watchdogs.security_watchdog import SecurityWatchdog
# Test with wildcard patterns - should NOT get automatic www logic
browser_profile = BrowserProfile(allowed_domains=['*.example.com'], headless=True, user_data_dir=None)
browser_session = BrowserSession(browser_profile=browser_profile)
event_bus = EventBus()
watchdog = SecurityWatchdog(browser_session=browser_session, event_bus=event_bus)
# Wildcard should match everything including root and www
assert watchdog._is_url_allowed('https://example.com') is True
assert watchdog._is_url_allowed('https://www.example.com') is True
assert watchdog._is_url_allowed('https://mail.example.com') is True
def test_www_subdomain_not_added_for_url_patterns(self):
"""Test that www is not automatically added for full URL patterns."""
from bubus import EventBus
from browser_use.browser.watchdogs.security_watchdog import SecurityWatchdog
# Test with full URL patterns - should NOT get automatic www logic
browser_profile = BrowserProfile(
allowed_domains=['https://example.com', 'http://test.org'], headless=True, user_data_dir=None
)
browser_session = BrowserSession(browser_profile=browser_profile)
event_bus = EventBus()
watchdog = SecurityWatchdog(browser_session=browser_session, event_bus=event_bus)
# Exact URL should work
assert watchdog._is_url_allowed('https://example.com/path') is True
assert watchdog._is_url_allowed('http://test.org/page') is True
# www should NOT be automatically added for full URL patterns
assert watchdog._is_url_allowed('https://www.example.com') is False
assert watchdog._is_url_allowed('http://www.test.org') is False
def test_is_root_domain_helper(self):
"""Test the _is_root_domain helper method logic."""
from bubus import EventBus
from browser_use.browser.watchdogs.security_watchdog import SecurityWatchdog
browser_profile = BrowserProfile(allowed_domains=['example.com'], headless=True, user_data_dir=None)
browser_session = BrowserSession(browser_profile=browser_profile)
event_bus = EventBus()
watchdog = SecurityWatchdog(browser_session=browser_session, event_bus=event_bus)
# Simple root domains (1 dot) - should return True
assert watchdog._is_root_domain('example.com') is True
assert watchdog._is_root_domain('test.org') is True
assert watchdog._is_root_domain('site.net') is True
# Subdomains (more than 1 dot) - should return False
assert watchdog._is_root_domain('www.example.com') is False
assert watchdog._is_root_domain('mail.example.com') is False
assert watchdog._is_root_domain('example.co.uk') is False
assert watchdog._is_root_domain('test.com.au') is False
# Wildcards - should return False
assert watchdog._is_root_domain('*.example.com') is False
assert watchdog._is_root_domain('*example.com') is False
# Full URLs - should return False
assert watchdog._is_root_domain('https://example.com') is False
assert watchdog._is_root_domain('http://test.org') is False
# Invalid domains - should return False
assert watchdog._is_root_domain('example') is False
assert watchdog._is_root_domain('') is False
class TestUrlProhibitlistSecurity:
"""Tests for URL prohibitlist (blocked domains) behavior and matching semantics."""
def test_simple_prohibited_domains(self):
"""Domain-only patterns block exact host and www, but not other subdomains."""
from bubus import EventBus
from browser_use.browser.watchdogs.security_watchdog import SecurityWatchdog
browser_profile = BrowserProfile(prohibited_domains=['example.com', 'test.org'], headless=True, user_data_dir=None)
browser_session = BrowserSession(browser_profile=browser_profile)
event_bus = EventBus()
watchdog = SecurityWatchdog(browser_session=browser_session, event_bus=event_bus)
# Block exact and www
assert watchdog._is_url_allowed('https://example.com') is False
assert watchdog._is_url_allowed('https://www.example.com') is False
assert watchdog._is_url_allowed('https://test.org') is False
assert watchdog._is_url_allowed('https://www.test.org') is False
# Allow other subdomains when only root is prohibited
assert watchdog._is_url_allowed('https://mail.example.com') is True
assert watchdog._is_url_allowed('https://api.test.org') is True
# Allow unrelated domains
assert watchdog._is_url_allowed('https://notexample.com') is True
def test_glob_pattern_prohibited(self):
"""Wildcard patterns block subdomains and main domain for http/https only."""
from bubus import EventBus
from browser_use.browser.watchdogs.security_watchdog import SecurityWatchdog
browser_profile = BrowserProfile(prohibited_domains=['*.example.com'], headless=True, user_data_dir=None)
browser_session = BrowserSession(browser_profile=browser_profile)
event_bus = EventBus()
watchdog = SecurityWatchdog(browser_session=browser_session, event_bus=event_bus)
# Block subdomains and main domain
assert watchdog._is_url_allowed('https://example.com') is False
assert watchdog._is_url_allowed('https://www.example.com') is False
assert watchdog._is_url_allowed('https://mail.example.com') is False
# Allow other domains
assert watchdog._is_url_allowed('https://notexample.com') is True
# Wildcard with domain-only should not apply to non-http(s)
assert watchdog._is_url_allowed('chrome://abc.example.com') is True
def test_full_url_prohibited_patterns(self):
"""Full URL patterns block only matching scheme/host/prefix."""
from bubus import EventBus
from browser_use.browser.watchdogs.security_watchdog import SecurityWatchdog
browser_profile = BrowserProfile(prohibited_domains=['https://wiki.org', 'brave://*'], headless=True, user_data_dir=None)
browser_session = BrowserSession(browser_profile=browser_profile)
event_bus = EventBus()
watchdog = SecurityWatchdog(browser_session=browser_session, event_bus=event_bus)
# Scheme-specific blocking
assert watchdog._is_url_allowed('http://wiki.org') is True
assert watchdog._is_url_allowed('https://wiki.org') is False
assert watchdog._is_url_allowed('https://wiki.org/path') is False
# Internal URL prefix blocking
assert watchdog._is_url_allowed('brave://anything/') is False
assert watchdog._is_url_allowed('chrome://settings') is True
def test_internal_urls_allowed_even_when_prohibited(self):
"""Internal new-tab/blank URLs are always allowed regardless of prohibited list."""
from bubus import EventBus
from browser_use.browser.watchdogs.security_watchdog import SecurityWatchdog
browser_profile = BrowserProfile(prohibited_domains=['*'], headless=True, user_data_dir=None)
browser_session = BrowserSession(browser_profile=browser_profile)
event_bus = EventBus()
watchdog = SecurityWatchdog(browser_session=browser_session, event_bus=event_bus)
assert watchdog._is_url_allowed('about:blank') is True
assert watchdog._is_url_allowed('chrome://new-tab-page/') is True
assert watchdog._is_url_allowed('chrome://new-tab-page') is True
assert watchdog._is_url_allowed('chrome://newtab/') is True
def test_prohibited_ignored_when_allowlist_present(self):
"""When allowlist is set, prohibited list is ignored by design."""
from bubus import EventBus
from browser_use.browser.watchdogs.security_watchdog import SecurityWatchdog
browser_profile = BrowserProfile(
allowed_domains=['*.example.com'],
prohibited_domains=['https://example.com'],
headless=True,
user_data_dir=None,
)
browser_session = BrowserSession(browser_profile=browser_profile)
event_bus = EventBus()
watchdog = SecurityWatchdog(browser_session=browser_session, event_bus=event_bus)
# Allowed by allowlist even though exact URL is in prohibited list
assert watchdog._is_url_allowed('https://example.com') is True
assert watchdog._is_url_allowed('https://www.example.com') is True
# Not in allowlist => blocked (prohibited list is not consulted in this mode)
assert watchdog._is_url_allowed('https://api.example.com') is True # wildcard allowlist includes this
# A domain outside the allowlist should be blocked
assert watchdog._is_url_allowed('https://notexample.com') is False
def test_auth_credentials_do_not_cause_false_block(self):
"""Credentials injection with prohibited domain in username should not block unrelated hosts."""
from bubus import EventBus
from browser_use.browser.watchdogs.security_watchdog import SecurityWatchdog
browser_profile = BrowserProfile(prohibited_domains=['example.com'], headless=True, user_data_dir=None)
browser_session = BrowserSession(browser_profile=browser_profile)
event_bus = EventBus()
watchdog = SecurityWatchdog(browser_session=browser_session, event_bus=event_bus)
# Host is malicious.com, should not be blocked just because username contains example.com
assert watchdog._is_url_allowed('https://example.com:password@malicious.com') is True
assert watchdog._is_url_allowed('https://example.com@malicious.com') is True
assert watchdog._is_url_allowed('https://example.com%20@malicious.com') is True
assert watchdog._is_url_allowed('https://example.com%3A@malicious.com') is True
# Legitimate credentials to a prohibited host should be blocked
assert watchdog._is_url_allowed('https://user:password@example.com') is False
def test_case_insensitive_prohibited_domains(self):
"""Prohibited domain matching should be case-insensitive."""
from bubus import EventBus
from browser_use.browser.watchdogs.security_watchdog import SecurityWatchdog
browser_profile = BrowserProfile(prohibited_domains=['Example.COM'], headless=True, user_data_dir=None)
browser_session = BrowserSession(browser_profile=browser_profile)
event_bus = EventBus()
watchdog = SecurityWatchdog(browser_session=browser_session, event_bus=event_bus)
assert watchdog._is_url_allowed('https://example.com') is False
assert watchdog._is_url_allowed('https://WWW.EXAMPLE.COM') is False
assert watchdog._is_url_allowed('https://mail.example.com') is True
@@ -0,0 +1,333 @@
"""Tests for cloud browser functionality."""
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from browser_use.browser.cloud import (
CloudBrowserAuthError,
CloudBrowserClient,
CloudBrowserError,
get_cloud_browser_cdp_url,
stop_cloud_browser_session,
)
from browser_use.browser.profile import BrowserProfile
from browser_use.browser.session import BrowserSession
from browser_use.sync.auth import CloudAuthConfig
@pytest.fixture
def temp_config_dir(monkeypatch):
"""Create temporary config directory."""
with tempfile.TemporaryDirectory() as tmpdir:
temp_dir = Path(tmpdir) / '.config' / 'browseruse'
temp_dir.mkdir(parents=True, exist_ok=True)
# Use monkeypatch to set the environment variable
monkeypatch.setenv('BROWSER_USE_CONFIG_DIR', str(temp_dir))
yield temp_dir
@pytest.fixture
def mock_auth_config(temp_config_dir):
"""Create a mock auth config with valid token."""
auth_config = CloudAuthConfig(api_token='test-token', user_id='test-user-id', authorized_at=None)
auth_config.save_to_file()
return auth_config
class TestCloudBrowserClient:
"""Test CloudBrowserClient class."""
async def test_create_browser_success(self, mock_auth_config):
"""Test successful cloud browser creation."""
# Mock response data matching the API
mock_response_data = {
'id': 'test-browser-id',
'status': 'active',
'liveUrl': 'https://live.browser-use.com?wss=test',
'cdpUrl': 'wss://test.proxy.daytona.works',
'timeoutAt': '2025-09-17T04:35:36.049892',
'startedAt': '2025-09-17T03:35:36.049974',
'finishedAt': None,
}
# Mock the httpx client
with patch('httpx.AsyncClient') as mock_client_class:
mock_response = AsyncMock()
mock_response.status_code = 201
mock_response.is_success = True
mock_response.json = lambda: mock_response_data
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client_class.return_value = mock_client
client = CloudBrowserClient()
client.client = mock_client
result = await client.create_browser()
assert result.id == 'test-browser-id'
assert result.status == 'active'
assert result.cdpUrl == 'wss://test.proxy.daytona.works'
# Verify auth headers were included
mock_client.post.assert_called_once()
call_args = mock_client.post.call_args
assert 'X-Browser-Use-API-Key' in call_args.kwargs['headers']
assert call_args.kwargs['headers']['X-Browser-Use-API-Key'] == 'test-token'
async def test_create_browser_auth_error(self, temp_config_dir):
"""Test cloud browser creation with auth error."""
# Don't create auth config - should trigger auth error
with patch('httpx.AsyncClient') as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value = mock_client
client = CloudBrowserClient()
client.client = mock_client
with pytest.raises(CloudBrowserAuthError) as exc_info:
await client.create_browser()
assert 'BROWSER_USE_API_KEY environment variable' in str(exc_info.value)
async def test_create_browser_http_401(self, mock_auth_config):
"""Test cloud browser creation with HTTP 401 response."""
with patch('httpx.AsyncClient') as mock_client_class:
mock_response = AsyncMock()
mock_response.status_code = 401
mock_response.is_success = False
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client_class.return_value = mock_client
client = CloudBrowserClient()
client.client = mock_client
with pytest.raises(CloudBrowserAuthError) as exc_info:
await client.create_browser()
assert 'Authentication failed' in str(exc_info.value)
async def test_create_browser_with_env_var(self, temp_config_dir, monkeypatch):
"""Test cloud browser creation using BROWSER_USE_API_KEY environment variable."""
# Set environment variable
monkeypatch.setenv('BROWSER_USE_API_KEY', 'env-test-token')
# Mock response data matching the API
mock_response_data = {
'id': 'test-browser-id',
'status': 'active',
'liveUrl': 'https://live.browser-use.com?wss=test',
'cdpUrl': 'wss://test.proxy.daytona.works',
'timeoutAt': '2025-09-17T04:35:36.049892',
'startedAt': '2025-09-17T03:35:36.049974',
'finishedAt': None,
}
with patch('httpx.AsyncClient') as mock_client_class:
mock_response = AsyncMock()
mock_response.status_code = 201
mock_response.is_success = True
mock_response.json = lambda: mock_response_data
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client_class.return_value = mock_client
client = CloudBrowserClient()
client.client = mock_client
result = await client.create_browser()
assert result.id == 'test-browser-id'
assert result.status == 'active'
assert result.cdpUrl == 'wss://test.proxy.daytona.works'
# Verify environment variable was used
mock_client.post.assert_called_once()
call_args = mock_client.post.call_args
assert 'X-Browser-Use-API-Key' in call_args.kwargs['headers']
assert call_args.kwargs['headers']['X-Browser-Use-API-Key'] == 'env-test-token'
async def test_stop_browser_success(self, mock_auth_config):
"""Test successful cloud browser session stop."""
# Mock response data for stop
mock_response_data = {
'id': 'test-browser-id',
'status': 'stopped',
'liveUrl': 'https://live.browser-use.com?wss=test',
'cdpUrl': 'wss://test.proxy.daytona.works',
'timeoutAt': '2025-09-17T04:35:36.049892',
'startedAt': '2025-09-17T03:35:36.049974',
'finishedAt': '2025-09-17T04:35:36.049892',
}
with patch('httpx.AsyncClient') as mock_client_class:
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.is_success = True
mock_response.json = lambda: mock_response_data
mock_client = AsyncMock()
mock_client.patch.return_value = mock_response
mock_client_class.return_value = mock_client
client = CloudBrowserClient()
client.client = mock_client
client.current_session_id = 'test-browser-id'
result = await client.stop_browser()
assert result.id == 'test-browser-id'
assert result.status == 'stopped'
assert result.finishedAt is not None
# Verify correct API call
mock_client.patch.assert_called_once()
call_args = mock_client.patch.call_args
assert 'test-browser-id' in call_args.args[0] # URL contains session ID
assert call_args.kwargs['json'] == {'action': 'stop'}
assert 'X-Browser-Use-API-Key' in call_args.kwargs['headers']
async def test_stop_browser_session_not_found(self, mock_auth_config):
"""Test stopping a browser session that doesn't exist."""
with patch('httpx.AsyncClient') as mock_client_class:
mock_response = AsyncMock()
mock_response.status_code = 404
mock_response.is_success = False
mock_client = AsyncMock()
mock_client.patch.return_value = mock_response
mock_client_class.return_value = mock_client
client = CloudBrowserClient()
client.client = mock_client
with pytest.raises(CloudBrowserError) as exc_info:
await client.stop_browser('nonexistent-session')
assert 'not found' in str(exc_info.value)
class TestBrowserSessionCloudIntegration:
"""Test BrowserSession integration with cloud browsers."""
async def test_cloud_browser_profile_property(self):
"""Test that cloud_browser property works correctly."""
profile = BrowserProfile(use_cloud=True)
session = BrowserSession(browser_profile=profile)
assert session.cloud_browser is True
assert session.browser_profile.use_cloud is True
async def test_browser_session_cloud_browser_logic(self, mock_auth_config):
"""Test that cloud browser profile settings work correctly."""
# Test cloud browser profile creation
profile = BrowserProfile(use_cloud=True)
assert profile.use_cloud is True
# Test that BrowserSession respects cloud_browser setting
session = BrowserSession(browser_profile=profile)
assert session.cloud_browser is True
# Test that get_cloud_browser_cdp_url works with mocked API
with patch('browser_use.browser.cloud.get_cloud_browser_cdp_url') as mock_get_cdp_url:
mock_get_cdp_url.return_value = 'wss://test.proxy.daytona.works'
cdp_url = await mock_get_cdp_url()
assert cdp_url == 'wss://test.proxy.daytona.works'
mock_get_cdp_url.assert_called_once()
async def test_get_cloud_browser_cdp_url_function(mock_auth_config):
"""Test the get_cloud_browser_cdp_url convenience function."""
mock_response_data = {
'id': 'test-browser-id',
'status': 'active',
'liveUrl': 'https://live.browser-use.com?wss=test',
'cdpUrl': 'wss://test.proxy.daytona.works',
'timeoutAt': '2025-09-17T04:35:36.049892',
'startedAt': '2025-09-17T03:35:36.049974',
'finishedAt': None,
}
with patch('httpx.AsyncClient') as mock_client_class:
mock_response = AsyncMock()
mock_response.status_code = 201
mock_response.is_success = True
mock_response.json = lambda: mock_response_data
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client_class.return_value = mock_client
cdp_url = await get_cloud_browser_cdp_url()
assert cdp_url == 'wss://test.proxy.daytona.works'
async def test_cloud_browser_auth_error_no_fallback(temp_config_dir):
"""Test that cloud browser throws error when auth fails (no fallback)."""
# Don't create auth config to trigger auth error
profile = BrowserProfile(use_cloud=True)
# Test that cloud browser client raises error without fallback
with patch('browser_use.browser.cloud.get_cloud_browser_cdp_url') as mock_cloud_cdp:
mock_cloud_cdp.side_effect = CloudBrowserAuthError('No auth token')
# Verify that the cloud browser client raises the expected error
with pytest.raises(CloudBrowserAuthError) as exc_info:
await get_cloud_browser_cdp_url()
assert 'BROWSER_USE_API_KEY environment variable' in str(exc_info.value)
# Verify profile state unchanged (no fallback)
assert profile.use_cloud is True
assert profile.is_local is False
async def test_stop_cloud_browser_session_function(mock_auth_config):
"""Test the stop_cloud_browser_session convenience function."""
mock_response_data = {
'id': 'test-browser-id',
'status': 'stopped',
'liveUrl': 'https://live.browser-use.com?wss=test',
'cdpUrl': 'wss://test.proxy.daytona.works',
'timeoutAt': '2025-09-17T04:35:36.049892',
'startedAt': '2025-09-17T03:35:36.049974',
'finishedAt': '2025-09-17T04:35:36.049892',
}
with patch('httpx.AsyncClient') as mock_client_class:
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.is_success = True
mock_response.json = lambda: mock_response_data
mock_client = AsyncMock()
mock_client.patch.return_value = mock_response
mock_client_class.return_value = mock_client
result = await stop_cloud_browser_session('test-browser-id')
assert result.id == 'test-browser-id'
assert result.status == 'stopped'
@@ -0,0 +1,120 @@
"""Tests for lazy loading configuration system."""
import os
from browser_use.config import CONFIG
class TestLazyConfig:
"""Test lazy loading of environment variables through CONFIG object."""
def test_config_reads_env_vars_lazily(self):
"""Test that CONFIG reads environment variables each time they're accessed."""
# Set an env var
original_value = os.environ.get('BROWSER_USE_LOGGING_LEVEL', '')
try:
os.environ['BROWSER_USE_LOGGING_LEVEL'] = 'debug'
assert CONFIG.BROWSER_USE_LOGGING_LEVEL == 'debug'
# Change the env var
os.environ['BROWSER_USE_LOGGING_LEVEL'] = 'info'
assert CONFIG.BROWSER_USE_LOGGING_LEVEL == 'info'
# Delete the env var to test default
del os.environ['BROWSER_USE_LOGGING_LEVEL']
assert CONFIG.BROWSER_USE_LOGGING_LEVEL == 'info' # default value
finally:
# Restore original value
if original_value:
os.environ['BROWSER_USE_LOGGING_LEVEL'] = original_value
else:
os.environ.pop('BROWSER_USE_LOGGING_LEVEL', None)
def test_boolean_env_vars(self):
"""Test boolean environment variables are parsed correctly."""
original_value = os.environ.get('ANONYMIZED_TELEMETRY', '')
try:
# Test true values
for true_val in ['true', 'True', 'TRUE', 'yes', 'Yes', '1']:
os.environ['ANONYMIZED_TELEMETRY'] = true_val
assert CONFIG.ANONYMIZED_TELEMETRY is True, f'Failed for value: {true_val}'
# Test false values
for false_val in ['false', 'False', 'FALSE', 'no', 'No', '0']:
os.environ['ANONYMIZED_TELEMETRY'] = false_val
assert CONFIG.ANONYMIZED_TELEMETRY is False, f'Failed for value: {false_val}'
finally:
if original_value:
os.environ['ANONYMIZED_TELEMETRY'] = original_value
else:
os.environ.pop('ANONYMIZED_TELEMETRY', None)
def test_api_keys_lazy_loading(self):
"""Test API keys are loaded lazily."""
original_value = os.environ.get('OPENAI_API_KEY', '')
try:
# Test empty default
os.environ.pop('OPENAI_API_KEY', None)
assert CONFIG.OPENAI_API_KEY == ''
# Set a value
os.environ['OPENAI_API_KEY'] = 'test-key-123'
assert CONFIG.OPENAI_API_KEY == 'test-key-123'
# Change the value
os.environ['OPENAI_API_KEY'] = 'new-key-456'
assert CONFIG.OPENAI_API_KEY == 'new-key-456'
finally:
if original_value:
os.environ['OPENAI_API_KEY'] = original_value
else:
os.environ.pop('OPENAI_API_KEY', None)
def test_path_configuration(self):
"""Test path configuration variables."""
original_value = os.environ.get('XDG_CACHE_HOME', '')
try:
# Test custom path
test_path = '/tmp/test-cache'
os.environ['XDG_CACHE_HOME'] = test_path
# Use Path().resolve() to handle symlinks (e.g., /tmp -> /private/tmp on macOS)
from pathlib import Path
assert CONFIG.XDG_CACHE_HOME == Path(test_path).resolve()
# Test default path expansion
os.environ.pop('XDG_CACHE_HOME', None)
assert '/.cache' in str(CONFIG.XDG_CACHE_HOME)
finally:
if original_value:
os.environ['XDG_CACHE_HOME'] = original_value
else:
os.environ.pop('XDG_CACHE_HOME', None)
def test_cloud_sync_inherits_telemetry(self):
"""Test BROWSER_USE_CLOUD_SYNC inherits from ANONYMIZED_TELEMETRY when not set."""
telemetry_original = os.environ.get('ANONYMIZED_TELEMETRY', '')
sync_original = os.environ.get('BROWSER_USE_CLOUD_SYNC', '')
try:
# When BROWSER_USE_CLOUD_SYNC is not set, it should inherit from ANONYMIZED_TELEMETRY
os.environ['ANONYMIZED_TELEMETRY'] = 'true'
os.environ.pop('BROWSER_USE_CLOUD_SYNC', None)
assert CONFIG.BROWSER_USE_CLOUD_SYNC is True
os.environ['ANONYMIZED_TELEMETRY'] = 'false'
os.environ.pop('BROWSER_USE_CLOUD_SYNC', None)
assert CONFIG.BROWSER_USE_CLOUD_SYNC is False
# When explicitly set, it should use its own value
os.environ['ANONYMIZED_TELEMETRY'] = 'false'
os.environ['BROWSER_USE_CLOUD_SYNC'] = 'true'
assert CONFIG.BROWSER_USE_CLOUD_SYNC is True
finally:
if telemetry_original:
os.environ['ANONYMIZED_TELEMETRY'] = telemetry_original
else:
os.environ.pop('ANONYMIZED_TELEMETRY', None)
if sync_original:
os.environ['BROWSER_USE_CLOUD_SYNC'] = sync_original
else:
os.environ.pop('BROWSER_USE_CLOUD_SYNC', None)
@@ -0,0 +1,937 @@
"""Tests for the FileSystem class and related file operations."""
import asyncio
import tempfile
from pathlib import Path
import pytest
from browser_use.filesystem.file_system import (
DEFAULT_FILE_SYSTEM_PATH,
INVALID_FILENAME_ERROR_MESSAGE,
CsvFile,
FileSystem,
FileSystemState,
JsonFile,
MarkdownFile,
TxtFile,
)
class TestBaseFile:
"""Test the BaseFile abstract base class and its implementations."""
def test_markdown_file_creation(self):
"""Test MarkdownFile creation and basic properties."""
md_file = MarkdownFile(name='test', content='# Hello World')
assert md_file.name == 'test'
assert md_file.content == '# Hello World'
assert md_file.extension == 'md'
assert md_file.full_name == 'test.md'
assert md_file.get_size == 13
assert md_file.get_line_count == 1
def test_txt_file_creation(self):
"""Test TxtFile creation and basic properties."""
txt_file = TxtFile(name='notes', content='Hello\nWorld')
assert txt_file.name == 'notes'
assert txt_file.content == 'Hello\nWorld'
assert txt_file.extension == 'txt'
assert txt_file.full_name == 'notes.txt'
assert txt_file.get_size == 11
assert txt_file.get_line_count == 2
def test_json_file_creation(self):
"""Test JsonFile creation and basic properties."""
json_content = '{"name": "John", "age": 30, "city": "New York"}'
json_file = JsonFile(name='data', content=json_content)
assert json_file.name == 'data'
assert json_file.content == json_content
assert json_file.extension == 'json'
assert json_file.full_name == 'data.json'
assert json_file.get_size == len(json_content)
assert json_file.get_line_count == 1
def test_csv_file_creation(self):
"""Test CsvFile creation and basic properties."""
csv_content = 'name,age,city\nJohn,30,New York\nJane,25,London'
csv_file = CsvFile(name='users', content=csv_content)
assert csv_file.name == 'users'
assert csv_file.content == csv_content
assert csv_file.extension == 'csv'
assert csv_file.full_name == 'users.csv'
assert csv_file.get_size == len(csv_content)
assert csv_file.get_line_count == 3
def test_file_content_operations(self):
"""Test content update and append operations."""
file_obj = TxtFile(name='test')
# Initial content
assert file_obj.content == ''
assert file_obj.get_size == 0
# Write content
file_obj.write_file_content('First line')
assert file_obj.content == 'First line'
assert file_obj.get_size == 10
# Append content
file_obj.append_file_content('\nSecond line')
assert file_obj.content == 'First line\nSecond line'
assert file_obj.get_line_count == 2
# Update content
file_obj.update_content('New content')
assert file_obj.content == 'New content'
async def test_file_disk_operations(self):
"""Test file sync to disk operations."""
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir)
file_obj = MarkdownFile(name='test', content='# Test Content')
# Test sync to disk
await file_obj.sync_to_disk(tmp_path)
# Verify file was created on disk
file_path = tmp_path / 'test.md'
assert file_path.exists()
assert file_path.read_text() == '# Test Content'
# Test write operation
await file_obj.write('# New Content', tmp_path)
assert file_path.read_text() == '# New Content'
assert file_obj.content == '# New Content'
# Test append operation
await file_obj.append('\n## Section 2', tmp_path)
expected_content = '# New Content\n## Section 2'
assert file_path.read_text() == expected_content
assert file_obj.content == expected_content
async def test_json_file_disk_operations(self):
"""Test JSON file sync to disk operations."""
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir)
json_content = '{"users": [{"name": "John", "age": 30}]}'
json_file = JsonFile(name='data', content=json_content)
# Test sync to disk
await json_file.sync_to_disk(tmp_path)
# Verify file was created on disk
file_path = tmp_path / 'data.json'
assert file_path.exists()
assert file_path.read_text() == json_content
# Test write operation
new_content = '{"users": [{"name": "Jane", "age": 25}]}'
await json_file.write(new_content, tmp_path)
assert file_path.read_text() == new_content
assert json_file.content == new_content
# Test append operation
await json_file.append(', {"name": "Bob", "age": 35}', tmp_path)
expected_content = new_content + ', {"name": "Bob", "age": 35}'
assert file_path.read_text() == expected_content
assert json_file.content == expected_content
async def test_csv_file_disk_operations(self):
"""Test CSV file sync to disk operations."""
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir)
csv_content = 'name,age,city\nJohn,30,New York'
csv_file = CsvFile(name='users', content=csv_content)
# Test sync to disk
await csv_file.sync_to_disk(tmp_path)
# Verify file was created on disk
file_path = tmp_path / 'users.csv'
assert file_path.exists()
assert file_path.read_text() == csv_content
# Test write operation
new_content = 'name,age,city\nJane,25,London'
await csv_file.write(new_content, tmp_path)
assert file_path.read_text() == new_content
assert csv_file.content == new_content
# Test append operation
await csv_file.append('\nBob,35,Paris', tmp_path)
expected_content = new_content + '\nBob,35,Paris'
assert file_path.read_text() == expected_content
assert csv_file.content == expected_content
def test_file_sync_to_disk_sync(self):
"""Test synchronous disk sync operation."""
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir)
file_obj = TxtFile(name='sync_test', content='Sync content')
# Test synchronous sync
file_obj.sync_to_disk_sync(tmp_path)
# Verify file was created
file_path = tmp_path / 'sync_test.txt'
assert file_path.exists()
assert file_path.read_text() == 'Sync content'
class TestFileSystem:
"""Test the FileSystem class functionality."""
@pytest.fixture
def temp_filesystem(self):
"""Create a temporary FileSystem for testing."""
with tempfile.TemporaryDirectory() as tmp_dir:
fs = FileSystem(base_dir=tmp_dir, create_default_files=True)
yield fs
try:
fs.nuke()
except Exception:
pass # Directory might already be cleaned up
@pytest.fixture
def empty_filesystem(self):
"""Create a temporary FileSystem without default files."""
with tempfile.TemporaryDirectory() as tmp_dir:
fs = FileSystem(base_dir=tmp_dir, create_default_files=False)
yield fs
try:
fs.nuke()
except Exception:
pass
def test_filesystem_initialization(self, temp_filesystem):
"""Test FileSystem initialization with default files."""
fs = temp_filesystem
# Check that base directory and data directory exist
assert fs.base_dir.exists()
assert fs.data_dir.exists()
assert fs.data_dir.name == DEFAULT_FILE_SYSTEM_PATH
# Check default files are created
assert 'todo.md' in fs.files
assert len(fs.files) == 1
# Check files exist on disk
todo_path = fs.data_dir / 'todo.md'
assert todo_path.exists()
def test_filesystem_without_default_files(self, empty_filesystem):
"""Test FileSystem initialization without default files."""
fs = empty_filesystem
assert fs.base_dir.exists()
assert fs.data_dir.exists()
assert len(fs.files) == 0
def test_get_allowed_extensions(self, temp_filesystem):
"""Test getting allowed file extensions."""
fs = temp_filesystem
extensions = fs.get_allowed_extensions()
assert 'md' in extensions
assert 'txt' in extensions
assert 'json' in extensions
assert 'csv' in extensions
def test_filename_validation(self, temp_filesystem):
"""Test filename validation."""
fs = temp_filesystem
# Valid filenames
assert fs._is_valid_filename('test.md') is True
assert fs._is_valid_filename('my_file.txt') is True
assert fs._is_valid_filename('file-name.md') is True
assert fs._is_valid_filename('file123.txt') is True
assert fs._is_valid_filename('data.json') is True
assert fs._is_valid_filename('users.csv') is True
# Invalid filenames
assert fs._is_valid_filename('test.doc') is False # wrong extension
assert fs._is_valid_filename('test') is False # no extension
assert fs._is_valid_filename('test.md.txt') is False # multiple extensions
assert fs._is_valid_filename('test with spaces.md') is False # spaces
assert fs._is_valid_filename('test@file.md') is False # special chars
assert fs._is_valid_filename('.md') is False # no name
assert fs._is_valid_filename('.json') is False # no name
assert fs._is_valid_filename('.csv') is False # no name
def test_filename_parsing(self, temp_filesystem):
"""Test filename parsing into name and extension."""
fs = temp_filesystem
name, ext = fs._parse_filename('test.md')
assert name == 'test'
assert ext == 'md'
name, ext = fs._parse_filename('my_file.TXT')
assert name == 'my_file'
assert ext == 'txt' # Should be lowercased
name, ext = fs._parse_filename('data.json')
assert name == 'data'
assert ext == 'json'
name, ext = fs._parse_filename('users.CSV')
assert name == 'users'
assert ext == 'csv' # Should be lowercased
def test_get_file(self, temp_filesystem):
"""Test getting files from the filesystem."""
fs = temp_filesystem
# Get non-existent file
non_existent = fs.get_file('nonexistent.md')
assert non_existent is None
# Get file with invalid name
invalid = fs.get_file('invalid@name.md')
assert invalid is None
def test_list_files(self, temp_filesystem):
"""Test listing files in the filesystem."""
fs = temp_filesystem
files = fs.list_files()
assert 'todo.md' in files
assert len(files) == 1
def test_display_file(self, temp_filesystem):
"""Test displaying file content."""
fs = temp_filesystem
# Display existing file
content = fs.display_file('todo.md')
assert content == '' # Default files are empty
# Display non-existent file
content = fs.display_file('nonexistent.md')
assert content is None
# Display file with invalid name
content = fs.display_file('invalid@name.md')
assert content is None
async def test_read_file(self, temp_filesystem: FileSystem):
"""Test reading file content with proper formatting."""
fs: FileSystem = temp_filesystem
# Read existing empty file
result = await fs.read_file('todo.md')
expected = 'Read from file todo.md.\n<content>\n\n</content>'
assert result == expected
# Read non-existent file
result = await fs.read_file('nonexistent.md')
assert result == "File 'nonexistent.md' not found."
# Read file with invalid name
result = await fs.read_file('invalid@name.md')
assert result == INVALID_FILENAME_ERROR_MESSAGE
async def test_write_file(self, temp_filesystem):
"""Test writing content to files."""
fs = temp_filesystem
# Write to existing file
result = await fs.write_file('results.md', '# Test Results\nThis is a test.')
assert result == 'Data written to file results.md successfully.'
# Verify content was written
content = await fs.read_file('results.md')
assert '# Test Results\nThis is a test.' in content
# Write to new file
result = await fs.write_file('new_file.txt', 'New file content')
assert result == 'Data written to file new_file.txt successfully.'
assert 'new_file.txt' in fs.files
assert fs.get_file('new_file.txt').content == 'New file content'
# Write with invalid filename
result = await fs.write_file('invalid@name.md', 'content')
assert result == INVALID_FILENAME_ERROR_MESSAGE
# Write with invalid extension
result = await fs.write_file('test.doc', 'content')
assert result == INVALID_FILENAME_ERROR_MESSAGE
async def test_write_json_file(self, temp_filesystem):
"""Test writing JSON files."""
fs = temp_filesystem
# Write valid JSON content
json_content = '{"users": [{"name": "John", "age": 30}, {"name": "Jane", "age": 25}]}'
result = await fs.write_file('data.json', json_content)
assert result == 'Data written to file data.json successfully.'
# Verify content was written
content = await fs.read_file('data.json')
assert json_content in content
# Verify file object was created
assert 'data.json' in fs.files
file_obj = fs.get_file('data.json')
assert file_obj is not None
assert isinstance(file_obj, JsonFile)
assert file_obj.content == json_content
# Write to new JSON file
result = await fs.write_file('config.json', '{"debug": true, "port": 4242}')
assert result == 'Data written to file config.json successfully.'
assert 'config.json' in fs.files
async def test_write_csv_file(self, temp_filesystem):
"""Test writing CSV files."""
fs = temp_filesystem
# Write valid CSV content
csv_content = 'name,age,city\nJohn,30,New York\nJane,25,London\nBob,35,Paris'
result = await fs.write_file('users.csv', csv_content)
assert result == 'Data written to file users.csv successfully.'
# Verify content was written
content = await fs.read_file('users.csv')
assert csv_content in content
# Verify file object was created
assert 'users.csv' in fs.files
file_obj = fs.get_file('users.csv')
assert file_obj is not None
assert isinstance(file_obj, CsvFile)
assert file_obj.content == csv_content
# Write to new CSV file
result = await fs.write_file('products.csv', 'id,name,price\n1,Laptop,999.99\n2,Mouse,29.99')
assert result == 'Data written to file products.csv successfully.'
assert 'products.csv' in fs.files
async def test_append_file(self, temp_filesystem):
"""Test appending content to files."""
fs = temp_filesystem
# First write some content
await fs.write_file('test.md', '# Title')
# Append content
result = await fs.append_file('test.md', '\n## Section 1')
assert result == 'Data appended to file test.md successfully.'
# Verify content was appended
content = fs.get_file('test.md').content
assert content == '# Title\n## Section 1'
# Append to non-existent file
result = await fs.append_file('nonexistent.md', 'content')
assert result == "File 'nonexistent.md' not found."
# Append with invalid filename
result = await fs.append_file('invalid@name.md', 'content')
assert result == INVALID_FILENAME_ERROR_MESSAGE
async def test_append_json_file(self, temp_filesystem):
"""Test appending content to JSON files."""
fs = temp_filesystem
# First write some JSON content
await fs.write_file('data.json', '{"users": [{"name": "John", "age": 30}]}')
# Append additional JSON content (note: this creates invalid JSON, but tests the append functionality)
result = await fs.append_file('data.json', ', {"name": "Jane", "age": 25}')
assert result == 'Data appended to file data.json successfully.'
# Verify content was appended
file_obj = fs.get_file('data.json')
assert file_obj is not None
expected_content = '{"users": [{"name": "John", "age": 30}]}, {"name": "Jane", "age": 25}'
assert file_obj.content == expected_content
async def test_append_csv_file(self, temp_filesystem):
"""Test appending content to CSV files."""
fs = temp_filesystem
# First write some CSV content
await fs.write_file('users.csv', 'name,age,city\nJohn,30,New York')
# Append additional CSV row
result = await fs.append_file('users.csv', '\nJane,25,London')
assert result == 'Data appended to file users.csv successfully.'
# Verify content was appended
file_obj = fs.get_file('users.csv')
assert file_obj is not None
expected_content = 'name,age,city\nJohn,30,New York\nJane,25,London'
assert file_obj.content == expected_content
# Append another row
await fs.append_file('users.csv', '\nBob,35,Paris')
expected_content = 'name,age,city\nJohn,30,New York\nJane,25,London\nBob,35,Paris'
assert file_obj.content == expected_content
async def test_save_extracted_content(self, temp_filesystem):
"""Test saving extracted content with auto-numbering."""
fs = temp_filesystem
# Save first extracted content
result = await fs.save_extracted_content('First extracted content')
assert result == 'Extracted content saved to file extracted_content_0.md successfully.'
assert 'extracted_content_0.md' in fs.files
assert fs.extracted_content_count == 1
# Save second extracted content
result = await fs.save_extracted_content('Second extracted content')
assert result == 'Extracted content saved to file extracted_content_1.md successfully.'
assert 'extracted_content_1.md' in fs.files
assert fs.extracted_content_count == 2
# Verify content
content1 = fs.get_file('extracted_content_0.md').content
content2 = fs.get_file('extracted_content_1.md').content
assert content1 == 'First extracted content'
assert content2 == 'Second extracted content'
async def test_describe_with_content(self, temp_filesystem):
"""Test describing filesystem with files containing content."""
fs = temp_filesystem
# Add content to files
await fs.write_file('results.md', '# Results\nTest results here.')
await fs.write_file('notes.txt', 'These are my notes.')
description = fs.describe()
# Should contain file information
assert 'results.md' in description
assert 'notes.txt' in description
assert '# Results' in description
assert 'These are my notes.' in description
assert 'lines' in description
async def test_describe_large_files(self, temp_filesystem):
"""Test describing filesystem with large files (truncated content)."""
fs = temp_filesystem
# Create a large file
large_content = '\n'.join([f'Line {i}' for i in range(100)])
await fs.write_file('large.md', large_content)
description = fs.describe()
# Should be truncated with "more lines" indicator
assert 'large.md' in description
assert 'more lines' in description
assert 'Line 0' in description # Start should be shown
assert 'Line 99' in description # End should be shown
def test_get_todo_contents(self, temp_filesystem):
"""Test getting todo file contents."""
fs = temp_filesystem
# Initially empty
todo_content = fs.get_todo_contents()
assert todo_content == ''
# Add content to todo
fs.get_file('todo.md').update_content('- [ ] Task 1\n- [ ] Task 2')
todo_content = fs.get_todo_contents()
assert '- [ ] Task 1' in todo_content
def test_get_state(self, temp_filesystem):
"""Test getting filesystem state."""
fs = temp_filesystem
state = fs.get_state()
assert isinstance(state, FileSystemState)
assert state.base_dir == str(fs.base_dir)
assert state.extracted_content_count == 0
assert 'todo.md' in state.files
async def test_from_state(self, temp_filesystem):
"""Test restoring filesystem from state."""
fs = temp_filesystem
# Add some content
await fs.write_file('results.md', '# Original Results')
await fs.write_file('custom.txt', 'Custom content')
await fs.save_extracted_content('Extracted data')
# Get state
state = fs.get_state()
# Create new filesystem from state
fs2 = FileSystem.from_state(state)
# Verify restoration
assert fs2.base_dir == fs.base_dir
assert fs2.extracted_content_count == fs.extracted_content_count
assert len(fs2.files) == len(fs.files)
# Verify file contents
file_obj = fs2.get_file('results.md')
assert file_obj is not None
assert file_obj.content == '# Original Results'
file_obj = fs2.get_file('custom.txt')
assert file_obj is not None
assert file_obj.content == 'Custom content'
file_obj = fs2.get_file('extracted_content_0.md')
assert file_obj is not None
assert file_obj.content == 'Extracted data'
# Verify files exist on disk
assert (fs2.data_dir / 'results.md').exists()
assert (fs2.data_dir / 'custom.txt').exists()
assert (fs2.data_dir / 'extracted_content_0.md').exists()
# Clean up second filesystem
fs2.nuke()
async def test_complete_workflow_with_json_csv(self):
"""Test a complete filesystem workflow with JSON and CSV files."""
with tempfile.TemporaryDirectory() as tmp_dir:
# Create filesystem
fs = FileSystem(base_dir=tmp_dir, create_default_files=True)
# Write JSON configuration file
config_json = '{"app": {"name": "TestApp", "version": "1.0"}, "database": {"host": "localhost", "port": 5432}}'
await fs.write_file('config.json', config_json)
# Write CSV data file
users_csv = 'id,name,email,age\n1,John Doe,john@example.com,30\n2,Jane Smith,jane@example.com,25'
await fs.write_file('users.csv', users_csv)
# Append more data to CSV
await fs.append_file('users.csv', '\n3,Bob Johnson,bob@example.com,35')
# Update JSON configuration
updated_config = '{"app": {"name": "TestApp", "version": "1.1"}, "database": {"host": "localhost", "port": 5432}, "features": {"logging": true}}'
await fs.write_file('config.json', updated_config)
# Create another JSON file for API responses
api_response = '{"status": "success", "data": [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}]}'
await fs.write_file('api_response.json', api_response)
# Create a products CSV file
products_csv = (
'sku,name,price,category\nLAP001,Gaming Laptop,1299.99,Electronics\nMOU001,Wireless Mouse,29.99,Accessories'
)
await fs.write_file('products.csv', products_csv)
# Verify file listing
files = fs.list_files()
expected_files = ['todo.md', 'config.json', 'users.csv', 'api_response.json', 'products.csv']
assert len(files) == len(expected_files)
for expected_file in expected_files:
assert expected_file in files
# Verify JSON file contents
config_file = fs.get_file('config.json')
assert config_file is not None
assert isinstance(config_file, JsonFile)
assert config_file.content == updated_config
api_file = fs.get_file('api_response.json')
assert api_file is not None
assert isinstance(api_file, JsonFile)
assert api_file.content == api_response
# Verify CSV file contents
users_file = fs.get_file('users.csv')
assert users_file is not None
assert isinstance(users_file, CsvFile)
expected_users_content = 'id,name,email,age\n1,John Doe,john@example.com,30\n2,Jane Smith,jane@example.com,25\n3,Bob Johnson,bob@example.com,35'
assert users_file.content == expected_users_content
products_file = fs.get_file('products.csv')
assert products_file is not None
assert isinstance(products_file, CsvFile)
assert products_file.content == products_csv
# Test state persistence with JSON and CSV files
state = fs.get_state()
fs.nuke()
# Restore from state
fs2 = FileSystem.from_state(state)
# Verify restoration
assert len(fs2.files) == len(expected_files)
# Verify JSON files were restored correctly
restored_config = fs2.get_file('config.json')
assert restored_config is not None
assert isinstance(restored_config, JsonFile)
assert restored_config.content == updated_config
restored_api = fs2.get_file('api_response.json')
assert restored_api is not None
assert isinstance(restored_api, JsonFile)
assert restored_api.content == api_response
# Verify CSV files were restored correctly
restored_users = fs2.get_file('users.csv')
assert restored_users is not None
assert isinstance(restored_users, CsvFile)
assert restored_users.content == expected_users_content
restored_products = fs2.get_file('products.csv')
assert restored_products is not None
assert isinstance(restored_products, CsvFile)
assert restored_products.content == products_csv
# Verify files exist on disk
for filename in expected_files:
if filename != 'todo.md': # Skip todo.md as it's already tested
assert (fs2.data_dir / filename).exists()
fs2.nuke()
async def test_from_state_with_json_csv_files(self, temp_filesystem):
"""Test restoring filesystem from state with JSON and CSV files."""
fs = temp_filesystem
# Add JSON and CSV content
await fs.write_file('data.json', '{"version": "1.0", "users": [{"name": "John", "age": 30}]}')
await fs.write_file('users.csv', 'name,age,city\nJohn,30,New York\nJane,25,London')
await fs.write_file('config.json', '{"debug": true, "port": 4242}')
await fs.write_file('products.csv', 'id,name,price\n1,Laptop,999.99\n2,Mouse,29.99')
# Get state
state = fs.get_state()
# Create new filesystem from state
fs2 = FileSystem.from_state(state)
# Verify restoration
assert fs2.base_dir == fs.base_dir
assert len(fs2.files) == len(fs.files)
# Verify JSON file contents
json_file = fs2.get_file('data.json')
assert json_file is not None
assert isinstance(json_file, JsonFile)
assert json_file.content == '{"version": "1.0", "users": [{"name": "John", "age": 30}]}'
config_file = fs2.get_file('config.json')
assert config_file is not None
assert isinstance(config_file, JsonFile)
assert config_file.content == '{"debug": true, "port": 4242}'
# Verify CSV file contents
csv_file = fs2.get_file('users.csv')
assert csv_file is not None
assert isinstance(csv_file, CsvFile)
assert csv_file.content == 'name,age,city\nJohn,30,New York\nJane,25,London'
products_file = fs2.get_file('products.csv')
assert products_file is not None
assert isinstance(products_file, CsvFile)
assert products_file.content == 'id,name,price\n1,Laptop,999.99\n2,Mouse,29.99'
# Verify files exist on disk
assert (fs2.data_dir / 'data.json').exists()
assert (fs2.data_dir / 'users.csv').exists()
assert (fs2.data_dir / 'config.json').exists()
assert (fs2.data_dir / 'products.csv').exists()
# Verify disk contents match
assert (fs2.data_dir / 'data.json').read_text() == '{"version": "1.0", "users": [{"name": "John", "age": 30}]}'
assert (fs2.data_dir / 'users.csv').read_text() == 'name,age,city\nJohn,30,New York\nJane,25,London'
# Clean up second filesystem
fs2.nuke()
def test_nuke(self, empty_filesystem):
"""Test filesystem destruction."""
fs = empty_filesystem
# Create a file to ensure directory has content
fs.data_dir.mkdir(exist_ok=True)
test_file = fs.data_dir / 'test.txt'
test_file.write_text('test')
assert test_file.exists()
# Nuke the filesystem
fs.nuke()
# Verify directory is removed
assert not fs.data_dir.exists()
def test_get_dir(self, temp_filesystem):
"""Test getting the filesystem directory."""
fs = temp_filesystem
directory = fs.get_dir()
assert directory == fs.data_dir
assert directory.exists()
assert directory.name == DEFAULT_FILE_SYSTEM_PATH
class TestFileSystemEdgeCases:
"""Test edge cases and error handling."""
def test_filesystem_with_string_path(self):
"""Test FileSystem creation with string path."""
with tempfile.TemporaryDirectory() as tmp_dir:
fs = FileSystem(base_dir=tmp_dir, create_default_files=False)
assert isinstance(fs.base_dir, Path)
assert fs.base_dir.exists()
fs.nuke()
def test_filesystem_with_path_object(self):
"""Test FileSystem creation with Path object."""
with tempfile.TemporaryDirectory() as tmp_dir:
path_obj = Path(tmp_dir)
fs = FileSystem(base_dir=path_obj, create_default_files=False)
assert isinstance(fs.base_dir, Path)
assert fs.base_dir == path_obj
fs.nuke()
def test_filesystem_recreates_data_dir(self):
"""Test that FileSystem recreates data directory if it exists."""
with tempfile.TemporaryDirectory() as tmp_dir:
# Create filesystem
fs1 = FileSystem(base_dir=tmp_dir, create_default_files=True)
data_dir = fs1.data_dir
# Add a custom file
custom_file = data_dir / 'custom.txt'
custom_file.write_text('custom content')
assert custom_file.exists()
# Create another filesystem with same base_dir (should clean data_dir)
fs2 = FileSystem(base_dir=tmp_dir, create_default_files=True)
# Custom file should be gone, default files should exist
assert not custom_file.exists()
assert (fs2.data_dir / 'todo.md').exists()
fs2.nuke()
async def test_write_file_exception_handling(self):
"""Test exception handling in write_file."""
with tempfile.TemporaryDirectory() as tmp_dir:
fs = FileSystem(base_dir=tmp_dir, create_default_files=False)
# Test with invalid extension
result = await fs.write_file('test.invalid', 'content')
assert result == INVALID_FILENAME_ERROR_MESSAGE
fs.nuke()
def test_from_state_with_unknown_file_type(self):
"""Test restoring state with unknown file types (should skip them)."""
with tempfile.TemporaryDirectory() as tmp_dir:
# Create a state with unknown file type
state = FileSystemState(
files={
'test.md': {'type': 'MarkdownFile', 'data': {'name': 'test', 'content': 'test content'}},
'unknown.txt': {'type': 'UnknownFileType', 'data': {'name': 'unknown', 'content': 'unknown content'}},
},
base_dir=tmp_dir,
extracted_content_count=0,
)
# Restore from state
fs = FileSystem.from_state(state)
# Should only have the known file type
assert 'test.md' in fs.files
assert 'unknown.txt' not in fs.files
assert len(fs.files) == 1
fs.nuke()
class TestFileSystemIntegration:
"""Integration tests for FileSystem with real file operations."""
async def test_complete_workflow(self):
"""Test a complete filesystem workflow."""
with tempfile.TemporaryDirectory() as tmp_dir:
# Create filesystem
fs = FileSystem(base_dir=tmp_dir, create_default_files=True)
# Write to results file
await fs.write_file('results.md', '# Test Results\n## Section 1\nInitial results.')
# Append more content
await fs.append_file('results.md', '\n## Section 2\nAdditional findings.')
# Create a notes file
await fs.write_file('notes.txt', 'Important notes:\n- Note 1\n- Note 2')
# Save extracted content
await fs.save_extracted_content('Extracted data from web page')
await fs.save_extracted_content('Second extraction')
# Verify file listing
files = fs.list_files()
assert len(files) == 5 # results.md, todo.md, notes.txt, 2 extracted files
# Verify content
file_obj = fs.get_file('results.md')
assert file_obj is not None
results_content = file_obj.content
assert '# Test Results' in results_content
assert '## Section 1' in results_content
assert '## Section 2' in results_content
assert 'Additional findings.' in results_content
# Test state persistence
state = fs.get_state()
fs.nuke()
# Restore from state
fs2 = FileSystem.from_state(state)
# Verify restoration
assert len(fs2.files) == 5
file_obj = fs2.get_file('results.md')
assert file_obj is not None
assert file_obj.content == results_content
file_obj = fs2.get_file('notes.txt')
assert file_obj is not None
assert file_obj.content == 'Important notes:\n- Note 1\n- Note 2'
assert fs2.extracted_content_count == 2
# Verify files exist on disk
for filename in files:
assert (fs2.data_dir / filename).exists()
fs2.nuke()
async def test_concurrent_operations(self):
"""Test concurrent file operations."""
with tempfile.TemporaryDirectory() as tmp_dir:
fs = FileSystem(base_dir=tmp_dir, create_default_files=False)
# Create multiple files concurrently
tasks = []
for i in range(5):
tasks.append(fs.write_file(f'file_{i}.md', f'Content for file {i}'))
# Wait for all operations to complete
results = await asyncio.gather(*tasks)
# Verify all operations succeeded
for result in results:
assert 'successfully' in result
# Verify all files were created
assert len(fs.files) == 5
for i in range(5):
assert f'file_{i}.md' in fs.files
file_obj = fs.get_file(f'file_{i}.md')
assert file_obj is not None
assert file_obj.content == f'Content for file {i}'
fs.nuke()
@@ -0,0 +1,74 @@
"""Test for handling Anthropic 502 errors"""
import pytest
from anthropic import APIStatusError
from browser_use.llm.anthropic.chat import ChatAnthropic
from browser_use.llm.exceptions import ModelProviderError
from browser_use.llm.messages import BaseMessage, UserMessage
@pytest.mark.asyncio
async def test_anthropic_502_error_handling(monkeypatch):
"""Test that ChatAnthropic properly handles 502 errors from the API"""
# Create a ChatAnthropic instance
chat = ChatAnthropic(model='claude-3-5-sonnet-20240620', api_key='test-key')
# Create test messages
messages: list[BaseMessage] = [UserMessage(content='Test message')]
# Mock the client to raise a 502 error
class MockClient:
class Messages:
async def create(self, **kwargs):
# Simulate a 502 error from Anthropic API
import httpx
request = httpx.Request('POST', 'https://api.anthropic.com/v1/messages')
response = httpx.Response(status_code=502, headers={}, content=b'Bad Gateway', request=request)
raise APIStatusError(
message='Bad Gateway', response=response, body={'error': {'message': 'Bad Gateway', 'type': 'server_error'}}
)
messages = Messages()
# Replace the client with our mock
monkeypatch.setattr(chat, 'get_client', lambda: MockClient())
# Test that the error is properly caught and re-raised as ModelProviderError
with pytest.raises(ModelProviderError) as exc_info:
await chat.ainvoke(messages)
# Verify the error details
assert exc_info.value.args[0] == 'Bad Gateway'
assert exc_info.value.args[1] == 502
assert str(exc_info.value) == "('Bad Gateway', 502)"
@pytest.mark.asyncio
async def test_anthropic_error_does_not_access_usage(monkeypatch):
"""Test that error handling doesn't try to access usage attribute on error responses"""
chat = ChatAnthropic(model='claude-3-5-sonnet-20240620', api_key='test-key')
messages: list[BaseMessage] = [UserMessage(content='Test message')]
# Mock the client to return a string instead of a proper response
class MockClient:
class Messages:
async def create(self, **kwargs):
# This simulates what might happen if the API returns an unexpected response
# that gets parsed as a string
return 'Error: Bad Gateway'
messages = Messages()
monkeypatch.setattr(chat, 'get_client', lambda: MockClient())
# This should raise a ModelProviderError with a clear message
with pytest.raises(ModelProviderError) as exc_info:
await chat.ainvoke(messages)
# The error should be about unexpected response type, not missing 'usage' attribute
assert "'str' object has no attribute 'usage'" not in str(exc_info.value)
assert 'Unexpected response type from Anthropic API' in str(exc_info.value)
assert exc_info.value.args[1] == 502
@@ -0,0 +1,90 @@
import json
import os
import tiktoken
from browser_use.agent.views import AgentOutput
from browser_use.llm.schema import SchemaOptimizer
from browser_use.tools.service import Tools
def test_optimized_schema():
"""Test the optimized schema generation and save to file."""
# Create tools and get all registered actions
tools = Tools()
ActionModel = tools.registry.create_action_model()
# Create the agent output model with custom actions
agent_output_model = AgentOutput.type_with_custom_actions(ActionModel)
# Get original schema for comparison
original_schema = agent_output_model.model_json_schema()
# Create the optimized schema
optimized_schema = SchemaOptimizer.create_optimized_json_schema(agent_output_model)
# Create tmp directory if it doesn't exist
os.makedirs('./tmp', exist_ok=True)
# Save optimized schema
with open('./tmp/optimized_schema.json', 'w') as f:
json.dump(optimized_schema, f, separators=(',', ':'), indent=2)
print('✅ Optimized schema generated and saved to ./tmp/optimized_schema.json')
# Compare token counts of both
try:
enc = tiktoken.encoding_for_model('gpt-4o')
except KeyError:
enc = tiktoken.get_encoding('cl100k_base')
original_tokens = len(enc.encode(json.dumps(original_schema)))
optimized_tokens = len(enc.encode(json.dumps(optimized_schema, separators=(',', ':'))))
savings = original_tokens - optimized_tokens
savings_percentage = (savings / original_tokens * 100) if original_tokens > 0 else 0
print('\n📊 Token Count Comparison:')
print(f' Original schema: {original_tokens:,} tokens')
print(f' Optimized schema: {optimized_tokens:,} tokens')
print(f' Token savings: {savings:,} tokens ({savings_percentage:.1f}% reduction)')
# Count tokens per action in optimized schema
print('\n🔍 Tokens per Action in Optimized Schema:')
if 'properties' in optimized_schema and 'action' in optimized_schema['properties']:
action_prop = optimized_schema['properties']['action']
if 'items' in action_prop and 'anyOf' in action_prop['items']:
actions = action_prop['items']['anyOf']
total_action_tokens = 0
for i, action in enumerate(actions):
action_json = json.dumps(action, separators=(',', ':'))
action_tokens = len(enc.encode(action_json))
total_action_tokens += action_tokens
# Try to get action name from the schema
action_name = 'Unknown'
if 'properties' in action:
# Get the first property that's not common ones like 'index', 'reasoning'
for prop_name in action['properties'].keys():
if prop_name not in ['index', 'reasoning']:
action_name = prop_name
break
print(f' Action {i + 1} ({action_name}): {action_tokens:,} tokens')
print('\n📈 Summary:')
print(f' Total actions: {len(actions)}')
print(f' Total action tokens: {total_action_tokens:,} tokens')
print(f' Average tokens per action: {total_action_tokens // len(actions):,} tokens')
print(f' Action tokens as % of total: {(total_action_tokens / optimized_tokens * 100):.1f}%')
else:
print(' No actions found in expected schema structure')
else:
print(' No action property found in optimized schema')
if __name__ == '__main__':
test_optimized_schema()
@@ -0,0 +1,107 @@
"""
Test to reproduce and verify fix for GitHub issue #2470:
"Python field with name 'type' handled differently between Gemini and OpenAI GPT"
"""
from browser_use.llm.google.chat import ChatGoogle
from browser_use.llm.schema import SchemaOptimizer
class TestGeminiTypeFieldHandling:
"""Test class for reproducing the type field issue with Gemini schema processing."""
def test_gemini_schema_with_dict_type_field(self):
"""
Test that Gemini schema processing handles dict 'type' field gracefully.
Reproduces the AttributeError: 'dict' object has no attribute 'upper'
"""
chat_google = ChatGoogle(model='gemini-2.0-flash-exp')
# Schema with dict instead of string in type field
problematic_schema = {'type': {'malformed': 'dict_type'}, 'properties': {}}
result = chat_google._fix_gemini_schema(problematic_schema)
assert result is not None
assert isinstance(result, dict)
assert result['type'] == {'malformed': 'dict_type'}
def test_gemini_schema_with_nested_dict_type_field(self):
"""
Test that nested dict 'type' fields are handled gracefully.
"""
chat_google = ChatGoogle(model='gemini-2.0-flash-exp')
# Schema with nested dict type field
problematic_schema = {
'type': 'object',
'properties': {'nested_field': {'type': {'malformed': 'dict_instead_of_string'}, 'properties': {}}},
}
result = chat_google._fix_gemini_schema(problematic_schema)
assert result is not None
assert isinstance(result, dict)
nested_type = result['properties']['nested_field']['type']
assert nested_type == {'malformed': 'dict_instead_of_string'}
def test_gemini_schema_with_none_type_field(self):
"""Test handling of None type field."""
chat_google = ChatGoogle(model='gemini-2.0-flash-exp')
problematic_schema = {'type': 'object', 'properties': {'nested_field': {'type': None, 'properties': {}}}}
result = chat_google._fix_gemini_schema(problematic_schema)
assert result is not None
def test_gemini_schema_with_valid_string_type(self):
"""Test that valid string type fields work correctly."""
chat_google = ChatGoogle(model='gemini-2.0-flash-exp')
valid_schema = {'type': 'object', 'properties': {'nested_field': {'type': 'object', 'properties': {}}}}
# Should work without issues
result = chat_google._fix_gemini_schema(valid_schema)
assert result is not None
assert isinstance(result, dict)
def test_gemini_schema_with_empty_properties_object(self):
"""Test handling of empty properties in object type."""
chat_google = ChatGoogle(model='gemini-2.0-flash-exp')
schema_with_empty_props = {
'type': 'object',
'properties': {
'empty_object': {
'type': 'object',
'properties': {}, # Empty properties should get placeholder
}
},
}
result = chat_google._fix_gemini_schema(schema_with_empty_props)
nested_props = result['properties']['empty_object']['properties']
assert '_placeholder' in nested_props
assert nested_props['_placeholder']['type'] == 'string'
def test_consistency_between_providers(self):
"""
Test that both Gemini and OpenAI handle schemas consistently.
The original issue was that Gemini would fail where OpenAI succeeded.
"""
from pydantic import BaseModel, Field
# Create a test model that generates a schema with dict type
class TestModel(BaseModel):
field_with_dict_type: dict = Field(default_factory=dict)
# OpenAI uses SchemaOptimizer directly
openai_schema = SchemaOptimizer.create_optimized_json_schema(TestModel)
assert openai_schema is not None
# Gemini processes the schema through _fix_gemini_schema
chat_google = ChatGoogle(model='gemini-2.0-flash-exp')
gemini_result = chat_google._fix_gemini_schema(openai_schema)
assert gemini_result is not None
# Both should handle the schema without errors
# This demonstrates that the fix makes Gemini consistent with OpenAI
@@ -0,0 +1,66 @@
"""
Tests for the SchemaOptimizer to ensure it correctly processes and
optimizes the schemas for agent actions without losing information.
"""
from pydantic import BaseModel
from browser_use.agent.views import AgentOutput
from browser_use.llm.schema import SchemaOptimizer
from browser_use.tools.service import Tools
class ProductInfo(BaseModel):
"""A sample structured output model with multiple fields."""
price: str
title: str
rating: float | None = None
def test_optimizer_preserves_all_fields_in_structured_done_action():
"""
Ensures the SchemaOptimizer does not drop fields from a custom structured
output model when creating the schema for the 'done' action.
This test specifically checks for a bug where fields were being lost
during the optimization process.
"""
# 1. Setup a tools with a custom output model, simulating an Agent
# being created with an `output_model_schema`.
tools = Tools(output_model=ProductInfo)
# 2. Get the dynamically created AgentOutput model, which includes all registered actions.
ActionModel = tools.registry.create_action_model()
agent_output_model = AgentOutput.type_with_custom_actions(ActionModel)
# 3. Run the schema optimizer on the agent's output model.
optimized_schema = SchemaOptimizer.create_optimized_json_schema(agent_output_model)
# 4. Find the 'done' action schema within the optimized output.
# The path is properties -> action -> items -> anyOf -> [schema with 'done'].
done_action_schema = None
actions_schemas = optimized_schema.get('properties', {}).get('action', {}).get('items', {}).get('anyOf', [])
for action_schema in actions_schemas:
if 'done' in action_schema.get('properties', {}):
done_action_schema = action_schema
break
# 5. Assert that the 'done' action schema was successfully found.
assert done_action_schema is not None, "Could not find 'done' action in the optimized schema."
# 6. Navigate to the schema for our custom data model within the 'done' action.
# The path is properties -> done -> properties -> data -> properties.
done_params_schema = done_action_schema.get('properties', {}).get('done', {})
structured_data_schema = done_params_schema.get('properties', {}).get('data', {})
final_properties = structured_data_schema.get('properties', {})
# 7. Assert that the set of fields in the optimized schema matches the original model's fields.
original_fields = set(ProductInfo.model_fields.keys())
optimized_fields = set(final_properties.keys())
assert original_fields == optimized_fields, (
f"Field mismatch between original and optimized structured 'done' action schema.\n"
f'Missing from optimized: {original_fields - optimized_fields}\n'
f'Unexpected in optimized: {optimized_fields - original_fields}'
)
@@ -0,0 +1,106 @@
<!DOCTYPE html>
<html>
<head>
<title>Radio Button Test</title>
</head>
<body>
<h1>Radio Button Test Page</h1>
<form>
<fieldset>
<legend>Select your favorite color:</legend>
<label>
<input type="radio" name="color" value="red" id="radio-red">
Red
</label>
<br>
<label>
<input type="radio" name="color" value="blue" id="radio-blue">
Blue
</label>
<br>
<label>
<input type="radio" name="color" value="green" id="radio-green">
Green
</label>
<br>
</fieldset>
<fieldset>
<legend>Select your favorite animal:</legend>
<label>
<input type="radio" name="animal" value="cat" id="radio-cat">
Cat
</label>
<br>
<label>
<input type="radio" name="animal" value="dog" id="radio-dog">
Dog
</label>
<br>
<label>
<input type="radio" name="animal" value="bird" id="radio-bird">
Bird
</label>
<br>
</fieldset>
<div id="result-message" style="margin-top: 20px; padding: 10px; background-color: #f0f0f0; display: none;">
<p id="secret-text"></p>
</div>
</form>
<script>
function checkSelection() {
const colorRadios = document.querySelectorAll('input[name="color"]');
const animalRadios = document.querySelectorAll('input[name="animal"]');
let selectedColor = null;
let selectedAnimal = null;
// Get selected color
for (const radio of colorRadios) {
if (radio.checked) {
selectedColor = radio.value;
break;
}
}
// Get selected animal
for (const radio of animalRadios) {
if (radio.checked) {
selectedAnimal = radio.value;
break;
}
}
const resultDiv = document.getElementById('result-message');
const secretText = document.getElementById('secret-text');
// Show secret if both Blue and Dog are selected
if (selectedColor === 'blue' && selectedAnimal === 'dog') {
secretText.textContent = 'SECRET_SUCCESS_12345: Blue dog combination unlocked!';
resultDiv.style.display = 'block';
resultDiv.style.backgroundColor = '#d4edda';
} else if (selectedColor && selectedAnimal) {
secretText.textContent = `Selected: ${selectedColor} ${selectedAnimal}`;
resultDiv.style.display = 'block';
resultDiv.style.backgroundColor = '#f8d7da';
} else {
resultDiv.style.display = 'none';
}
}
// Add event listeners to all radio buttons
document.querySelectorAll('input[type="radio"]').forEach(radio => {
radio.addEventListener('change', checkSelection);
});
</script>
</body>
</html>
@@ -0,0 +1,105 @@
# @file purpose: Test radio button interactions and serialization in browser-use
"""
Test file for verifying radio button clicking functionality and DOM serialization.
This test creates a simple HTML page with radio buttons, sends an agent to click them,
and logs the final agent message to show how radio buttons are represented in the serializer.
The serialization shows radio buttons as:
[index]<input type=radio name=groupname value=optionvalue checked=true/false />
Usage:
uv run pytest tests/ci/test_radio_buttons.py -v -s
Note: This test requires a real LLM API key and is skipped in CI environments.
"""
import os
from pathlib import Path
import pytest
from pytest_httpserver import HTTPServer
from browser_use.agent.service import Agent
from browser_use.browser import BrowserSession
from browser_use.browser.profile import BrowserProfile
@pytest.fixture(scope='session')
def http_server():
"""Create and provide a test HTTP server that serves static content."""
server = HTTPServer()
server.start()
# Read the HTML file content
html_file = Path(__file__).parent / 'test_radio_buttons.html'
with open(html_file) as f:
html_content = f.read()
# Add route for radio buttons test page
server.expect_request('/radio-test').respond_with_data(
html_content,
content_type='text/html',
)
yield server
server.stop()
@pytest.fixture(scope='session')
def base_url(http_server):
"""Return the base URL for the test HTTP server."""
return f'http://{http_server.host}:{http_server.port}'
@pytest.fixture(scope='module')
async def browser_session():
"""Create and provide a Browser instance with security disabled."""
browser_session = BrowserSession(
browser_profile=BrowserProfile(
headless=True,
user_data_dir=None,
keep_alive=True,
)
)
await browser_session.start()
yield browser_session
await browser_session.kill()
@pytest.mark.skipif(
os.getenv('CI') == 'true' or os.getenv('GITHUB_ACTIONS') == 'true',
reason='Skipped in CI: requires real LLM API key which blocks other tests',
)
class TestRadioButtons:
"""Test cases for radio button interactions."""
async def test_radio_button_clicking(self, browser_session, base_url):
"""Test that agent can click radio buttons by checking for secret message."""
task = f"Go to {base_url}/radio-test and click on the 'Blue' radio button and the 'Dog' radio button. After clicking both buttons, look for any text message that appears on the page and report exactly what you see."
agent = Agent(
task=task,
browser_session=browser_session,
max_actions_per_step=5,
flash_mode=True,
)
# Run the agent
history = await agent.run(max_steps=8)
# Check if the secret message appears in the final response
secret_found = False
final_response = history.final_result()
if final_response and 'SECRET_SUCCESS_12345' in final_response:
secret_found = True
print('\n✅ SUCCESS: Secret message found! Radio buttons were clicked correctly.')
assert secret_found, (
"Secret message 'SECRET_SUCCESS_12345' should be present, indicating both Blue and Dog radio buttons were clicked. Actual response: "
+ str(final_response)
)
print(f'\n🎉 Test completed successfully! Agent completed {len(history)} steps and found the secret message.')
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,398 @@
import asyncio
import base64
import socketserver
import pytest
from pytest_httpserver import HTTPServer
from browser_use.browser import BrowserProfile, BrowserSession
# Fix for httpserver hanging on shutdown - prevent blocking on socket close
socketserver.ThreadingMixIn.block_on_close = False
socketserver.ThreadingMixIn.daemon_threads = True
class TestBrowserContext:
"""Tests for browser context functionality using real browser instances."""
@pytest.fixture(scope='session')
def http_server(self):
"""Create and provide a test HTTP server that serves static content."""
server = HTTPServer()
server.start()
# Add routes for test pages
server.expect_request('/').respond_with_data(
'<html><head><title>Test Home Page</title></head><body><h1>Test Home Page</h1><p>Welcome to the test site</p></body></html>',
content_type='text/html',
)
server.expect_request('/scroll_test').respond_with_data(
"""
<html>
<head>
<title>Scroll Test</title>
<style>
body { height: 3000px; }
.marker { position: absolute; }
#top { top: 0; }
#middle { top: 1000px; }
#bottom { top: 2000px; }
</style>
</head>
<body>
<div id="top" class="marker">Top of the page</div>
<div id="middle" class="marker">Middle of the page</div>
<div id="bottom" class="marker">Bottom of the page</div>
</body>
</html>
""",
content_type='text/html',
)
yield server
server.stop()
@pytest.fixture(scope='session')
def base_url(self, http_server):
"""Return the base URL for the test HTTP server."""
return f'http://{http_server.host}:{http_server.port}'
@pytest.fixture(scope='module')
async def browser_session(self):
"""Create and provide a BrowserSession instance with security disabled."""
browser_session = BrowserSession(
browser_profile=BrowserProfile(
headless=True,
user_data_dir=None,
keep_alive=True,
)
)
await browser_session.start()
yield browser_session
await browser_session.kill()
# Ensure event bus is properly stopped
await browser_session.event_bus.stop(clear=True, timeout=5)
@pytest.mark.skip(reason='TODO: fix')
def test_is_url_allowed(self):
"""
Test the _is_url_allowed method to verify that it correctly checks URLs against
the allowed domains configuration.
"""
# Scenario 1: allowed_domains is None, any URL should be allowed.
from bubus import EventBus
from browser_use.browser.watchdogs.security_watchdog import SecurityWatchdog
config1 = BrowserProfile(allowed_domains=None, headless=True, user_data_dir=None)
context1 = BrowserSession(browser_profile=config1)
event_bus1 = EventBus()
watchdog1 = SecurityWatchdog(browser_session=context1, event_bus=event_bus1)
assert watchdog1._is_url_allowed('http://anydomain.com') is True
assert watchdog1._is_url_allowed('https://anotherdomain.org/path') is True
# Scenario 2: allowed_domains is provided.
# Note: match_url_with_domain_pattern defaults to https:// scheme when none is specified
allowed = ['https://example.com', 'http://example.com', 'http://*.mysite.org', 'https://*.mysite.org']
config2 = BrowserProfile(allowed_domains=allowed, headless=True, user_data_dir=None)
context2 = BrowserSession(browser_profile=config2)
event_bus2 = EventBus()
watchdog2 = SecurityWatchdog(browser_session=context2, event_bus=event_bus2)
# URL exactly matching
assert watchdog2._is_url_allowed('http://example.com') is True
# URL with subdomain (should not be allowed)
assert watchdog2._is_url_allowed('http://sub.example.com/path') is False
# URL with subdomain for wildcard pattern (should be allowed)
assert watchdog2._is_url_allowed('http://sub.mysite.org') is True
# URL that matches second allowed domain
assert watchdog2._is_url_allowed('https://mysite.org/page') is True
# URL with port number, still allowed (port is stripped)
assert watchdog2._is_url_allowed('http://example.com:4242') is True
assert watchdog2._is_url_allowed('https://example.com:443') is True
# Scenario 3: Malformed URL or empty domain
# urlparse will return an empty netloc for some malformed URLs.
assert watchdog2._is_url_allowed('notaurl') is False
def test_convert_simple_xpath_to_css_selector(self):
"""
Test removed: _convert_simple_xpath_to_css_selector method no longer exists.
"""
pass # Method was removed from BrowserSession
def test_enhanced_css_selector_for_element(self):
"""
Test removed: _enhanced_css_selector_for_element method no longer exists.
"""
pass # Method was removed from BrowserSession
@pytest.mark.asyncio
@pytest.mark.skip(reason='TODO: fix')
async def test_navigate_and_get_current_page(self, browser_session, base_url):
"""Test that navigate method changes the URL and get_current_page returns the proper page."""
# Navigate to the test page
from browser_use.browser.events import NavigateToUrlEvent
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/'))
await event
# Get the current page
url = await browser_session.get_current_page_url()
# Verify the page URL matches what we navigated to
assert f'{base_url}/' in url
# Verify the page title
title = await browser_session.get_current_page_title()
assert title == 'Test Home Page'
@pytest.mark.asyncio
@pytest.mark.skip(reason='TODO: fix')
async def test_refresh_page(self, browser_session, base_url):
"""Test that refresh_page correctly reloads the current page."""
# Navigate to the test page
from browser_use.browser.events import NavigateToUrlEvent
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/'))
await event
# Get the current page info before refresh
url_before = await browser_session.get_current_page_url()
title_before = await browser_session.get_current_page_title()
# Refresh the page
await browser_session.refresh()
# Get the current page info after refresh
url_after = await browser_session.get_current_page_url()
title_after = await browser_session.get_current_page_title()
# Verify it's still on the same URL
assert url_after == url_before
# Verify the page title is still correct
assert title_after == 'Test Home Page'
@pytest.mark.asyncio
@pytest.mark.skip(reason='TODO: fix')
async def test_execute_javascript(self, browser_session, base_url):
"""Test that execute_javascript correctly executes JavaScript in the current page."""
# Navigate to a test page
from browser_use.browser.events import NavigateToUrlEvent
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/'))
await event
# Execute a simple JavaScript snippet that returns a value
result = await browser_session.execute_javascript('document.title')
# Verify the result
assert result == 'Test Home Page'
# Execute JavaScript that modifies the page
await browser_session.execute_javascript("document.body.style.backgroundColor = 'red'")
# Verify the change by reading back the value
bg_color = await browser_session.execute_javascript('document.body.style.backgroundColor')
assert bg_color == 'red'
@pytest.mark.asyncio
@pytest.mark.skip(reason='TODO: fix')
@pytest.mark.skip(reason='get_scroll_info API changed - depends on page object that no longer exists')
async def test_get_scroll_info(self, browser_session, base_url):
"""Test that get_scroll_info returns the correct scroll position information."""
# Navigate to the scroll test page
from browser_use.browser.events import NavigateToUrlEvent
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/scroll_test'))
await event
page = await browser_session.get_current_page()
# Get initial scroll info
pixels_above_initial, pixels_below_initial = await browser_session.get_scroll_info(page)
# Verify initial scroll position
assert pixels_above_initial == 0, 'Initial scroll position should be at the top'
assert pixels_below_initial > 0, 'There should be content below the viewport'
# Scroll down the page
await browser_session.execute_javascript('window.scrollBy(0, 500)')
await asyncio.sleep(0.2) # Brief delay for scroll to complete
# Get new scroll info
pixels_above_after_scroll, pixels_below_after_scroll = await browser_session.get_scroll_info(page)
# Verify new scroll position
assert pixels_above_after_scroll > 0, 'Page should be scrolled down'
assert pixels_above_after_scroll >= 400, 'Page should be scrolled down at least 400px'
assert pixels_below_after_scroll < pixels_below_initial, 'Less content should be below viewport after scrolling'
@pytest.mark.asyncio
@pytest.mark.skip(reason='TODO: fix')
async def test_take_screenshot(self, browser_session, base_url):
"""Test that take_screenshot returns a valid base64 encoded image."""
# Navigate to the test page
from browser_use.browser.events import NavigateToUrlEvent
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/'))
await event
# Take a screenshot
screenshot_base64 = await browser_session.take_screenshot()
# Verify the screenshot is a valid base64 string
assert isinstance(screenshot_base64, str)
assert len(screenshot_base64) > 0
# Verify it can be decoded as base64
try:
image_data = base64.b64decode(screenshot_base64)
# Verify the data starts with a valid image signature (PNG file header)
assert image_data[:8] == b'\x89PNG\r\n\x1a\n', 'Screenshot is not a valid PNG image'
except Exception as e:
pytest.fail(f'Failed to decode screenshot as base64: {e}')
@pytest.mark.asyncio
@pytest.mark.skip(reason='TODO: fix')
async def test_switch_tab_operations(self, browser_session, base_url):
"""Test tab creation, switching, and closing operations."""
# Navigate to home page in first tab
from browser_use.browser.events import NavigateToUrlEvent
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/'))
await event
# Create a new tab
await browser_session.create_new_tab(f'{base_url}/scroll_test')
# Verify we have two tabs now
tabs_info = await browser_session.get_tabs()
assert len(tabs_info) == 2, 'Should have two tabs open'
# Verify current tab is the scroll test page
current_url = await browser_session.get_current_page_url()
assert f'{base_url}/scroll_test' in current_url
# Switch back to the first tab
await browser_session.switch_to_tab(0)
# Verify we're back on the home page
current_url = await browser_session.get_current_page_url()
assert f'{base_url}/' in current_url
# Close the second tab
await browser_session.close_tab(1)
# Verify we have the expected number of tabs
# The first tab remains plus any about:blank tabs created by AboutBlankWatchdog
tabs_info = await browser_session.get_tabs_info()
# Filter out about:blank tabs created by the watchdog
non_blank_tabs = [tab for tab in tabs_info if 'about:blank' not in tab.url]
assert len(non_blank_tabs) == 1, (
f'Should have one non-blank tab open after closing the second, but got {len(non_blank_tabs)}: {non_blank_tabs}'
)
assert base_url in non_blank_tabs[0].url, 'The remaining tab should be the home page'
# TODO: highlighting doesn't exist anymore
# @pytest.mark.asyncio
# async def test_remove_highlights(self, browser_session, base_url):
# """Test that remove_highlights successfully removes highlight elements."""
# # Navigate to a test page
# from browser_use.browser.events import NavigateToUrlEvent; event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/')
# # Add a highlight via JavaScript
# await browser_session.execute_javascript("""
# const container = document.createElement('div');
# container.id = 'playwright-highlight-container';
# document.body.appendChild(container);
# const highlight = document.createElement('div');
# highlight.id = 'playwright-highlight-1';
# container.appendChild(highlight);
# const element = document.querySelector('h1');
# element.setAttribute('browser-user-highlight-id', 'playwright-highlight-1');
# """)
# # Verify the highlight container exists
# container_exists = await browser_session.execute_javascript(
# "document.getElementById('playwright-highlight-container') !== null"
# )
# assert container_exists, 'Highlight container should exist before removal'
# # Call remove_highlights
# await browser_session.remove_highlights()
# # Verify the highlight container was removed
# container_exists_after = await browser_session.execute_javascript(
# "document.getElementById('playwright-highlight-container') !== null"
# )
# assert not container_exists_after, 'Highlight container should be removed'
# # Verify the highlight attribute was removed from the element
# attribute_exists = await browser_session.execute_javascript(
# "document.querySelector('h1').hasAttribute('browser-user-highlight-id')"
# )
# assert not attribute_exists, 'browser-user-highlight-id attribute should be removed'
@pytest.mark.asyncio
@pytest.mark.skip(reason='TODO: fix')
async def test_custom_action_with_no_arguments(self, browser_session, base_url):
"""Test that custom actions with no arguments are handled correctly"""
from browser_use.agent.views import ActionResult
from browser_use.tools.registry.service import Registry
# Create a registry
registry = Registry()
# Register a custom action with no arguments
@registry.action('Some custom action with no args')
def simple_action():
return ActionResult(extracted_content='return some result')
# Navigate to a test page
from browser_use.browser.events import NavigateToUrlEvent
event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/'))
await event
# Execute the action
result = await registry.execute_action('simple_action', {})
# Verify the result
assert isinstance(result, ActionResult)
assert result.extracted_content == 'return some result'
# Test that the action model is created correctly
action_model = registry.create_action_model()
# The action should be in the model fields
assert 'simple_action' in action_model.model_fields
# Create an instance with the simple_action
action_instance = action_model(simple_action={}) # type: ignore[call-arg]
# Test that model_dump works correctly
dumped = action_instance.model_dump(exclude_unset=True)
assert 'simple_action' in dumped
assert dumped['simple_action'] == {}
# Test async version as well
@registry.action('Async custom action with no args')
async def async_simple_action():
return ActionResult(extracted_content='async result')
result = await registry.execute_action('async_simple_action', {})
assert result.extracted_content == 'async result'
# Test with special parameters but no regular arguments
@registry.action('Action with only special params')
async def special_params_only(browser_session):
current_url = await browser_session.get_current_page_url()
return ActionResult(extracted_content=f'Page URL: {current_url}')
result = await registry.execute_action('special_params_only', {}, browser_session=browser_session)
assert 'Page URL:' in result.extracted_content
assert base_url in result.extracted_content
@@ -0,0 +1,993 @@
"""
Tests for OAuth2 device flow and cloud sync functionality.
"""
import json
import tempfile
from datetime import datetime
from pathlib import Path
import anyio
import httpx
import pytest
from dotenv import load_dotenv
from pytest_httpserver import HTTPServer
# Load environment variables before any imports
load_dotenv()
from browser_use.agent.cloud_events import CreateAgentSessionEvent, CreateAgentTaskEvent
from browser_use.sync.auth import TEMP_USER_ID, DeviceAuthClient
from browser_use.sync.service import CloudSync
# Define config dir for tests - not needed anymore since we'll use env vars
@pytest.fixture
def temp_config_dir(monkeypatch):
"""Create temporary config directory."""
with tempfile.TemporaryDirectory() as tmpdir:
temp_dir = Path(tmpdir) / '.config' / 'browseruse'
temp_dir.mkdir(parents=True, exist_ok=True)
# Use monkeypatch to set the environment variable
monkeypatch.setenv('BROWSER_USE_CONFIG_DIR', str(temp_dir))
yield temp_dir
@pytest.fixture
async def http_client(httpserver: HTTPServer):
"""Create a real HTTP client pointed at the test server"""
async with httpx.AsyncClient(base_url=httpserver.url_for('')) as client:
yield client
class TestDeviceAuthClient:
"""Test DeviceAuthClient class."""
async def test_init_creates_config_dir(self, temp_config_dir, httpserver):
"""Test that initialization creates config directory."""
auth = DeviceAuthClient(base_url=httpserver.url_for(''))
assert temp_config_dir.exists()
assert (temp_config_dir / 'cloud_auth.json').exists() is False
async def test_load_credentials_no_file(self, temp_config_dir, httpserver):
"""Test loading credentials when file doesn't exist."""
auth = DeviceAuthClient(base_url=httpserver.url_for(''))
# When no file exists, auth_config should have no token/user_id
assert auth.auth_config.api_token is None
assert auth.auth_config.user_id is None
assert not auth.is_authenticated
async def test_save_and_load_credentials(self, temp_config_dir, httpserver):
"""Test saving and loading credentials."""
auth = DeviceAuthClient(base_url=httpserver.url_for(''))
# Update auth config and save
auth.auth_config.api_token = 'test-key-123'
auth.auth_config.user_id = 'test-user-123'
auth.auth_config.authorized_at = datetime.utcnow()
auth.auth_config.save_to_file()
# Load in a new instance
auth2 = DeviceAuthClient(base_url=httpserver.url_for(''))
assert auth2.auth_config.api_token == 'test-key-123'
assert auth2.auth_config.user_id == 'test-user-123'
assert auth2.is_authenticated
assert (temp_config_dir / 'cloud_auth.json').exists()
# Check file permissions (should be readable only by owner)
stat = (temp_config_dir / 'cloud_auth.json').stat()
assert oct(stat.st_mode)[-3:] == '600'
async def test_is_authenticated(self, temp_config_dir, httpserver):
"""Test authentication status check."""
auth = DeviceAuthClient(base_url=httpserver.url_for(''))
# Not authenticated initially
assert auth.is_authenticated is False
# Save credentials
auth.auth_config.api_token = 'test-key'
auth.auth_config.user_id = 'test-user'
auth.auth_config.save_to_file()
# Reload to verify persistence
auth2 = DeviceAuthClient(base_url=httpserver.url_for(''))
assert auth2.is_authenticated is True
async def test_get_credentials(self, temp_config_dir, httpserver):
"""Test getting credentials."""
auth = DeviceAuthClient(base_url=httpserver.url_for(''))
# No credentials initially
assert auth.api_token is None
assert auth.user_id == TEMP_USER_ID # Should return temp user ID when not authenticated
# Save credentials
auth.auth_config.api_token = 'test-key'
auth.auth_config.user_id = 'test-user'
# Get credentials
assert auth.api_token == 'test-key'
assert auth.user_id == 'test-user'
async def test_start_device_flow(self, httpserver: HTTPServer, http_client, temp_config_dir):
"""Test starting device flow."""
# Set up the test server response
httpserver.expect_request(
'/api/v1/oauth/device/authorize',
method='POST',
).respond_with_json(
{
'device_code': 'test-device-code',
'user_code': 'ABCD-1234',
'verification_uri': 'https://example.com/device',
'verification_uri_complete': 'https://example.com/device?user_code=ABCD-1234',
'expires_in': 1800,
'interval': 5,
}
)
# Create auth client with injected http client
auth = DeviceAuthClient(base_url=httpserver.url_for(''), http_client=http_client)
result = await auth.start_device_authorization('test-session-id')
assert result['device_code'] == 'test-device-code'
assert result['user_code'] == 'ABCD-1234'
assert 'verification_uri' in result
# Verify the request was made correctly
request = httpserver.log[0][0]
assert request.method == 'POST'
# Get the body as string
body = request.get_data(as_text=True)
assert 'client_id=library' in body
assert 'agent_session_id=test-session-id' in body
assert 'device_id=' in body # Should include device_id
async def test_poll_for_token_pending(self, httpserver: HTTPServer, http_client, temp_config_dir):
"""Test polling when authorization is pending."""
# Set up the test server to always return pending
httpserver.expect_request(
'/api/v1/oauth/device/token',
method='POST',
).respond_with_json(
{
'error': 'authorization_pending',
'error_description': 'Authorization pending',
}
)
auth = DeviceAuthClient(base_url=httpserver.url_for(''), http_client=http_client)
# Use very short timeout to avoid long test
result = await auth.poll_for_token('test-device-code', interval=0.1, timeout=0.5)
assert result is None
assert not auth.is_authenticated
async def test_poll_for_token_success(self, httpserver: HTTPServer, http_client, temp_config_dir):
"""Test successful token polling."""
# Set up the test server to return success immediately
httpserver.expect_request(
'/api/v1/oauth/device/token',
method='POST',
).respond_with_json(
{
'access_token': 'test-api-key',
'token_type': 'Bearer',
'user_id': 'test-user-123',
'scope': 'read write',
}
)
auth = DeviceAuthClient(base_url=httpserver.url_for(''), http_client=http_client)
result = await auth.poll_for_token('test-device-code')
assert result is not None
assert result['access_token'] == 'test-api-key'
assert result['user_id'] == 'test-user-123'
async def test_wait_for_authorization(self, httpserver: HTTPServer, http_client, temp_config_dir):
"""Test waiting for authorization with polling."""
# Track number of requests
request_count = 0
def handle_token_request(request):
nonlocal request_count
request_count += 1
from werkzeug.wrappers import Response
if request_count < 3:
# First two requests return pending
return Response(
json.dumps({'error': 'authorization_pending', 'error_description': 'Authorization pending'}),
status=200,
mimetype='application/json',
)
else:
# Third request returns success
return Response(
json.dumps(
{
'access_token': 'test-api-key',
'token_type': 'Bearer',
'user_id': 'test-user-123',
'scope': 'read write',
}
),
status=200,
mimetype='application/json',
)
# Set up auth endpoint
httpserver.expect_request(
'/api/v1/oauth/device/authorize',
method='POST',
).respond_with_json(
{
'device_code': 'test-device-code',
'user_code': 'ABCD-1234',
'verification_uri': 'https://example.com/device',
'verification_uri_complete': 'https://example.com/device?user_code=ABCD-1234',
'expires_in': 1800,
'interval': 0.1, # Short interval for testing
}
)
# Set up token endpoint with custom handler
httpserver.expect_request(
'/api/v1/oauth/device/token',
method='POST',
).respond_with_handler(handle_token_request)
auth = DeviceAuthClient(base_url=httpserver.url_for(''), http_client=http_client)
success = await auth.authenticate(agent_session_id='test-session-id', show_instructions=False)
assert success is True
assert auth.is_authenticated
assert auth.api_token == 'test-api-key'
assert auth.user_id == 'test-user-123'
assert request_count == 3 # Verify it took 3 polls
async def test_wait_for_authorization_timeout(self, httpserver: HTTPServer, http_client, temp_config_dir):
"""Test timeout during authorization waiting."""
# Set up auth endpoint
httpserver.expect_request(
'/api/v1/oauth/device/authorize',
method='POST',
).respond_with_json(
{
'device_code': 'test-device-code',
'user_code': 'ABCD-1234',
'verification_uri': 'https://example.com/device',
'verification_uri_complete': 'https://example.com/device?user_code=ABCD-1234',
'expires_in': 1800,
'interval': 0.1,
}
)
# Set up token endpoint to always return pending
httpserver.expect_request(
'/api/v1/oauth/device/token',
method='POST',
).respond_with_json(
{
'error': 'authorization_pending',
'error_description': 'Authorization pending',
}
)
auth = DeviceAuthClient(base_url=httpserver.url_for(''), http_client=http_client)
# Call poll_for_token directly with short timeout
result = await auth.poll_for_token('test-device-code', interval=0.1, timeout=0.5)
assert result is None # Should timeout and return None
assert not auth.is_authenticated
async def test_logout(self, temp_config_dir, httpserver):
"""Test logout functionality."""
auth = DeviceAuthClient(base_url=httpserver.url_for(''))
# Save credentials directly using auth_config
auth.auth_config.api_token = 'test-key'
auth.auth_config.user_id = 'test-user'
auth.auth_config.save_to_file()
assert auth.is_authenticated is True
assert (temp_config_dir / 'cloud_auth.json').exists()
# Clear auth (logout)
auth.clear_auth()
assert auth.is_authenticated is False
# Note: clear_auth() deletes the config file entirely for security
assert not (temp_config_dir / 'cloud_auth.json').exists()
# Verify a new client loads empty credentials when no file exists
auth2 = DeviceAuthClient(base_url=httpserver.url_for(''))
assert auth2.auth_config.api_token is None
assert auth2.auth_config.user_id is None
class TestCloudSync:
"""Test CloudSync class."""
async def test_init(self, temp_config_dir, httpserver):
"""Test CloudSync initialization."""
service = CloudSync(base_url=httpserver.url_for(''))
assert service.base_url == httpserver.url_for('')
assert service.auth_client is not None
assert isinstance(service.auth_client, DeviceAuthClient)
async def test_send_event_authenticated(self, httpserver: HTTPServer, temp_config_dir):
"""Test sending event when authenticated."""
requests = []
def capture_request(request):
requests.append(
{
'headers': dict(request.headers),
'json': request.get_json(),
}
)
from werkzeug.wrappers import Response
return Response('{"processed": 1, "failed": 0}', status=200, mimetype='application/json')
httpserver.expect_request('/api/v1/events', method='POST').respond_with_handler(capture_request)
# Create authenticated service
auth = DeviceAuthClient(base_url=httpserver.url_for(''))
auth.auth_config.api_token = 'test-api-key'
auth.auth_config.user_id = 'test-user-123'
service = CloudSync(base_url=httpserver.url_for(''))
service.auth_client = auth
service.session_id = 'test-session-id'
# Send event
await service.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task='Test task',
user_id='test-user-123',
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
device_id='test-device-id',
)
)
# Check request was made
assert len(requests) == 1
request_data = requests[0]
# Check auth header
assert request_data['headers']['Authorization'] == 'Bearer test-api-key'
# Check event data
json_data = request_data['json']
assert len(json_data['events']) == 1
event = json_data['events'][0]
assert event['event_type'] == 'CreateAgentTaskEvent'
assert event['user_id'] == 'test-user-123'
assert event['task'] == 'Test task'
async def test_send_event_pre_auth(self, httpserver: HTTPServer, temp_config_dir):
"""Test that non-session events are not sent when auth is not in progress."""
requests = []
def capture_request(request):
requests.append(
{
'headers': dict(request.headers),
'json': request.get_json(),
}
)
from werkzeug.wrappers import Response
return Response('{"processed": 1, "failed": 0}', status=200, mimetype='application/json')
httpserver.expect_request('/api/v1/events', method='POST').respond_with_handler(capture_request)
# Create unauthenticated service WITHOUT triggering auth
auth = DeviceAuthClient(base_url=httpserver.url_for(''))
# Don't set api_token - leave it unauthenticated
service = CloudSync(base_url=httpserver.url_for(''))
service.auth_client = auth
service.session_id = 'test-session-id' # Set manually, don't trigger CreateAgentSessionEvent
# Send task event when NO auth is in progress (should be skipped)
await service.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task='Test task',
user_id=TEMP_USER_ID,
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
device_id='test-device-id',
)
)
# Check that no requests were made
assert len(requests) == 0
async def test_block_events_during_auth_progress(self, httpserver: HTTPServer, temp_config_dir):
"""Test that task events are BLOCKED when authentication is in progress (prevents data leak)."""
requests = []
def capture_request(request):
requests.append(
{
'headers': dict(request.headers),
'json': request.get_json(),
}
)
from werkzeug.wrappers import Response
return Response('{"processed": 1, "failed": 0}', status=200, mimetype='application/json')
httpserver.expect_request('/api/v1/events', method='POST').respond_with_handler(capture_request)
# Set up auth endpoints to simulate background auth in progress
httpserver.expect_request(
'/api/v1/oauth/device/authorize',
method='POST',
).respond_with_json(
{
'device_code': 'test-device-code',
'user_code': 'ABCD-1234',
'verification_uri': 'https://example.com/device',
'verification_uri_complete': 'https://example.com/device?user_code=ABCD-1234',
'expires_in': 1800,
'interval': 5,
}
)
httpserver.expect_request(
'/api/v1/oauth/device/token',
method='POST',
).respond_with_json(
{
'error': 'authorization_pending',
'error_description': 'Authorization pending',
}
)
# Create unauthenticated service
auth = DeviceAuthClient(base_url=httpserver.url_for(''))
# Don't set api_token - leave it unauthenticated
service = CloudSync(base_url=httpserver.url_for(''))
service.auth_client = auth
# Manually start an auth task to simulate the scenario where auth is in progress
import asyncio
async def fake_auth():
await asyncio.sleep(1) # Simulate auth taking some time
service.auth_task = asyncio.create_task(fake_auth())
# Set session ID
service.session_id = 'test-session-id'
# Send task event while auth is in progress (should be BLOCKED for security)
await service.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task='Test task during auth',
user_id=TEMP_USER_ID,
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
device_id='test-device-id',
)
)
# Check that the task event was NOT sent (blocked for security during auth)
assert len(requests) == 0
# Clean up the background task to avoid test flakiness
if service.auth_task and not service.auth_task.done():
service.auth_task.cancel()
try:
await service.auth_task
except asyncio.CancelledError:
pass
async def test_authenticate_then_send(self, httpserver: HTTPServer, temp_config_dir):
"""Test that events are only sent after authentication."""
requests = []
def capture_request(request):
requests.append(
{
'headers': dict(request.headers),
'json': request.get_json(),
}
)
from werkzeug.wrappers import Response
return Response('{"processed": 1, "failed": 0}', status=200, mimetype='application/json')
httpserver.expect_request('/api/v1/events', method='POST').respond_with_handler(capture_request)
# Create service with unauthenticated auth client
auth = DeviceAuthClient(base_url=httpserver.url_for(''))
# Start unauthenticated
service = CloudSync(base_url=httpserver.url_for(''))
service.auth_client = auth
service.session_id = 'test-session-id'
# Send pre-auth event (should be skipped)
await service.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task='Pre-auth task',
user_id=TEMP_USER_ID,
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
device_id='test-device-id',
)
)
# No requests should have been made yet
assert len(requests) == 0
# Now authenticate the auth client
auth.auth_config.api_token = 'test-api-key'
auth.auth_config.user_id = 'test-user-123'
# Send post-auth event (should be sent)
await service.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task='Post-auth task',
user_id='test-user-123',
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
device_id='test-device-id',
)
)
# Now exactly one request should have been made (the post-auth event)
assert len(requests) == 1
assert requests[0]['headers']['Authorization'] == 'Bearer test-api-key'
assert requests[0]['json']['events'][0]['user_id'] == 'test-user-123'
assert requests[0]['json']['events'][0]['task'] == 'Post-auth task'
async def test_error_handling(self, httpserver: HTTPServer, temp_config_dir):
"""Test error handling during event sending."""
# Set up server to return 500 error
httpserver.expect_request('/api/v1/events', method='POST').respond_with_data('Internal Server Error', status=500)
# Create service with real auth
auth = DeviceAuthClient(base_url=httpserver.url_for(''))
auth.auth_config.api_token = 'test-api-key'
auth.auth_config.user_id = 'test-user-123'
service = CloudSync(base_url=httpserver.url_for(''))
service.auth_client = auth
service.session_id = 'test-session-id'
# Send event - should not raise exception but handle gracefully
await service.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task='Test task',
user_id='test-user-123',
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
device_id='test-device-id',
)
)
# Should handle error gracefully without crashing
# async def test_update_wal_events(self, temp_config_dir):
# """Test updating WAL events with real user ID."""
# # Create real auth client
# auth = DeviceAuthClient(base_url='http://localhost:8000')
# auth.auth_config.api_token = 'test-api-key'
# auth.auth_config.user_id = 'test-user-123'
# service = CloudSync(
# base_url='http://localhost:8000'
# )
# service.auth_client = auth
# service.session_id = 'test-session-id'
# # Create the events directory structure that the method expects
# events_dir = temp_config_dir / 'events'
# events_dir.mkdir(exist_ok=True)
# # Create WAL file with temp user IDs
# wal_path = events_dir / f'{service.session_id}.jsonl'
# events = [
# {
# 'event_type': 'CreateAgentTaskEvent',
# 'user_id': '99999999-9999-9999-9999-999999999999', # TEMP_USER_ID
# 'task': 'Task 1',
# },
# {
# 'event_type': 'UpdateAgentTaskEvent',
# 'user_id': '99999999-9999-9999-9999-999999999999', # TEMP_USER_ID
# 'status': 'done',
# },
# {
# 'event_type': 'CreateAgentStepEvent',
# 'user_id': 'some-other-user', # Different user, should still be updated
# 'step': 1,
# },
# ]
# # Write events to WAL file
# content = '\n'.join(json.dumps(event) for event in events) + '\n'
# await anyio.Path(wal_path).write_text(content)
# # Call the method under test (temp_config_dir fixture already sets the env var)
# await service._update_wal_user_ids(service.session_id)
# # Read back the updated file and verify changes
# content = await anyio.Path(wal_path).read_text()
# updated_events = []
# for line in content.splitlines():
# if line.strip():
# updated_events.append(json.loads(line))
# # Verify all user_ids were updated to the authenticated user's ID
# assert len(updated_events) == 3
# for event in updated_events:
# assert event['user_id'] == 'test-user-123'
# # Verify other fields remained unchanged
# assert updated_events[0]['event_type'] == 'CreateAgentTaskEvent'
# assert updated_events[0]['task'] == 'Task 1'
# assert updated_events[1]['event_type'] == 'UpdateAgentTaskEvent'
# assert updated_events[1]['status'] == 'done'
# assert updated_events[2]['event_type'] == 'CreateAgentStepEvent'
# assert updated_events[2]['step'] == 1
class TestIntegration:
"""Integration tests for OAuth2 and cloud sync."""
async def test_full_auth_flow(self, httpserver: HTTPServer, temp_config_dir):
"""Test complete authentication flow."""
# Track token polling attempts
token_attempts = 0
def handle_token_request(request):
nonlocal token_attempts
token_attempts += 1
from werkzeug.wrappers import Response
if token_attempts == 1:
# First attempt: pending
return Response(
json.dumps({'error': 'authorization_pending'}),
status=200,
mimetype='application/json',
)
else:
# Second attempt: success
return Response(
json.dumps(
{
'access_token': 'test-api-key',
'token_type': 'Bearer',
'user_id': 'test-user-123',
}
),
status=200,
mimetype='application/json',
)
# Set up auth flow endpoints
httpserver.expect_request(
'/api/v1/oauth/device/authorize',
method='POST',
).respond_with_json(
{
'device_code': 'test-device-code',
'user_code': 'ABCD-1234',
'verification_uri': f'{httpserver.url_for("")}/device',
'verification_uri_complete': f'{httpserver.url_for("")}/device?user_code=ABCD-1234',
'expires_in': 1800,
'interval': 0.1, # Fast polling for test
}
)
httpserver.expect_request(
'/api/v1/oauth/device/token',
method='POST',
).respond_with_handler(handle_token_request)
# Set up events endpoint
httpserver.expect_request(
'/api/v1/events',
method='POST',
).respond_with_json({'processed': 1, 'failed': 0})
# Create service
service = CloudSync(base_url=httpserver.url_for(''))
service.session_id = 'test-session-id'
# Send pre-auth event
await service.handle_event(
CreateAgentSessionEvent(
user_id=TEMP_USER_ID,
browser_session_id='test-browser-session',
browser_session_live_url='http://example.com/live',
browser_session_cdp_url='ws://example.com/cdp',
device_id='test-device-id',
)
)
# Authenticate
authenticated = await service.authenticate(show_instructions=False)
assert authenticated is True
assert service.auth_client is not None
assert service.auth_client.is_authenticated
assert service.auth_client.api_token == 'test-api-key'
assert service.auth_client.user_id == 'test-user-123'
# Send authenticated event
await service.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task='Authenticated task',
user_id='test-user-123',
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
device_id='test-device-id',
)
)
# Verify auth was saved
auth_file = temp_config_dir / 'cloud_auth.json'
assert await anyio.Path(auth_file).exists()
content = await anyio.Path(auth_file).read_text()
saved_auth = json.loads(content)
assert saved_auth['api_token'] == 'test-api-key'
assert saved_auth['user_id'] == 'test-user-123'
class TestAuthResilience:
"""Test auth resilience scenarios - agent should never break due to sync failures."""
async def test_token_expiry_handling(self, httpserver: HTTPServer, http_client, temp_config_dir):
"""Test that expired tokens are handled gracefully."""
# Set up successful auth flow first
httpserver.expect_request(
'/api/v1/oauth/device/authorize',
method='POST',
).respond_with_json(
{
'device_code': 'test-device-code',
'user_code': 'ABCD-1234',
'verification_uri': 'https://example.com/device',
'verification_uri_complete': 'https://example.com/device?user_code=ABCD-1234',
'expires_in': 1800,
'interval': 0.1,
}
)
httpserver.expect_request(
'/api/v1/oauth/device/token',
method='POST',
).respond_with_json(
{
'access_token': 'test-api-key',
'token_type': 'Bearer',
'user_id': 'test-user-123',
'scope': 'read write',
}
)
# Authenticate successfully first
auth = DeviceAuthClient(base_url=httpserver.url_for(''), http_client=http_client)
success = await auth.authenticate(agent_session_id='test-session-id', show_instructions=False)
assert success is True
# Now simulate token expiry by returning 401 errors
httpserver.expect_request(
'/api/v1/events',
method='POST',
).respond_with_json({'error': 'unauthorized', 'detail': 'Token expired'}, status=401)
# Create cloud sync service
from browser_use.sync.service import CloudSync
service = CloudSync(base_url=httpserver.url_for(''))
service.auth_client = auth
# Send event - should not raise exception even though token is expired
await service.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task='Test task after token expiry',
user_id='test-user-123',
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
device_id='test-device-id',
)
)
# Agent should continue functioning despite sync failure
assert True # No exception raised
async def test_auth_failure_resilience(self, httpserver: HTTPServer, http_client, temp_config_dir):
"""Test that auth failures don't break the agent."""
# Set up auth endpoint to always fail
httpserver.expect_request(
'/api/v1/oauth/device/authorize',
method='POST',
).respond_with_json({'error': 'invalid_client', 'error_description': 'Client not found'}, status=400)
auth = DeviceAuthClient(base_url=httpserver.url_for(''), http_client=http_client)
# Auth should fail gracefully without throwing
success = await auth.authenticate(agent_session_id='test-session-id', show_instructions=False)
assert success is False
# Should still be able to create sync service
from browser_use.sync.service import CloudSync
service = CloudSync(base_url=httpserver.url_for(''))
service.auth_client = auth
# Set up events endpoint to handle unauthenticated requests
httpserver.expect_request(
'/api/v1/events',
method='POST',
).respond_with_json({'processed': 1, 'failed': 0})
# Should be able to send events without auth (pre-auth mode)
await service.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task='Test task without auth',
user_id='',
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
device_id='test-device-id',
)
)
async def test_server_downtime_resilience(self, httpserver: HTTPServer, http_client, temp_config_dir):
"""Test that server downtime doesn't break the agent."""
auth = DeviceAuthClient(base_url=httpserver.url_for(''), http_client=http_client)
# Don't set up any server responses - simulate server being down
# Auth should timeout gracefully
result = await auth.poll_for_token('fake-device-code', interval=0.1, timeout=0.3)
assert result is None
from browser_use.sync.service import CloudSync
service = CloudSync(base_url=httpserver.url_for(''))
service.auth_client = auth
# Should be able to send events even when server is down
# They will be queued locally
await service.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task='Test task during server downtime',
user_id='test-user-123',
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
device_id='test-device-id',
)
)
async def test_excessive_event_queue_handling(self, httpserver: HTTPServer, http_client, temp_config_dir):
"""Test that excessive event queuing doesn't break the agent."""
auth = DeviceAuthClient(base_url=httpserver.url_for(''), http_client=http_client)
from browser_use.sync.service import CloudSync
service = CloudSync(base_url=httpserver.url_for(''))
service.auth_client = auth
# Send many events while server is down (no responses configured)
for i in range(100):
await service.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task=f'Test task {i}',
user_id='test-user-123',
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
device_id='test-device-id',
)
)
# Agent should still be functioning
assert True # No memory issues or crashes
async def test_malformed_server_responses(self, httpserver: HTTPServer, http_client, temp_config_dir):
"""Test that malformed server responses don't break the agent."""
# Set up malformed JSON responses
httpserver.expect_request(
'/api/v1/oauth/device/authorize',
method='POST',
).respond_with_data('invalid json{', status=200, content_type='application/json')
auth = DeviceAuthClient(base_url=httpserver.url_for(''), http_client=http_client)
# Should handle malformed response gracefully
try:
await auth.start_device_authorization('test-session-id')
except Exception:
pass # Exception is expected but shouldn't crash the agent
# Set up another malformed response for events
httpserver.expect_request(
'/api/v1/events',
method='POST',
).respond_with_data('malformed response', status=500)
from browser_use.sync.service import CloudSync
service = CloudSync(base_url=httpserver.url_for(''))
service.auth_client = auth
# Should handle malformed event response gracefully
await service.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task='Test task with malformed response',
user_id='test-user-123',
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
device_id='test-device-id',
)
)
@@ -0,0 +1,603 @@
"""Tests for CloudSync client machinery - retry logic, event handling, backend communication."""
import os
import tempfile
from pathlib import Path
import httpx
import pytest
from bubus import BaseEvent
from pytest_httpserver import HTTPServer
from browser_use.agent.cloud_events import CreateAgentTaskEvent
from browser_use.sync.auth import TEMP_USER_ID, DeviceAuthClient
from browser_use.sync.service import CloudSync
@pytest.fixture
def temp_config_dir():
"""Create temporary config directory for tests."""
with tempfile.TemporaryDirectory() as tmpdir:
temp_dir = Path(tmpdir) / '.config' / 'browseruse'
temp_dir.mkdir(parents=True, exist_ok=True)
os.environ['BROWSER_USE_CONFIG_DIR'] = str(temp_dir)
yield temp_dir
@pytest.fixture
async def http_client(httpserver: HTTPServer):
"""Create a real HTTP client pointed at the test server."""
async with httpx.AsyncClient(base_url=httpserver.url_for('')) as client:
yield client
class TestCloudSyncInit:
"""Test CloudSync initialization and configuration."""
async def test_init_with_auth_enabled(self, temp_config_dir):
"""Test CloudSync initialization with auth enabled."""
service = CloudSync(base_url='http://localhost:8000')
assert service.base_url == 'http://localhost:8000'
assert service.auth_client is not None
assert isinstance(service.auth_client, DeviceAuthClient)
assert service.session_id is None
async def test_init_basic(self, temp_config_dir):
"""Test CloudSync basic initialization."""
service = CloudSync(base_url='http://localhost:8000')
assert service.base_url == 'http://localhost:8000'
assert service.auth_client is not None # Always has auth_client in current version
assert isinstance(service.auth_client, DeviceAuthClient)
class TestCloudSyncEventHandling:
"""Test CloudSync event validation and processing."""
@pytest.fixture
def authenticated_sync(self, httpserver: HTTPServer, http_client, temp_config_dir):
"""Create authenticated CloudSync service."""
auth = DeviceAuthClient(base_url=httpserver.url_for(''), http_client=http_client)
auth.auth_config.api_token = 'test-api-key'
auth.auth_config.user_id = 'test-user-123'
service = CloudSync(base_url=httpserver.url_for(''), enable_auth=True)
service.auth_client = auth
service.session_id = 'test-session-id'
return service
@pytest.fixture
def unauthenticated_sync(self, httpserver: HTTPServer, temp_config_dir):
"""Create unauthenticated CloudSync service."""
service = CloudSync(base_url=httpserver.url_for(''), enable_auth=True)
service.session_id = 'test-session-id'
return service
async def test_event_forwarding_authenticated(self, httpserver: HTTPServer, authenticated_sync):
"""Test event forwarding when authenticated."""
# Capture requests
requests = []
def capture_request(request):
requests.append(request.get_json())
from werkzeug.wrappers import Response
return Response('{"processed": 1, "failed": 0}', status=200, mimetype='application/json')
httpserver.expect_request('/api/v1/events', method='POST').respond_with_handler(capture_request)
# Send event
await authenticated_sync.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task='Test task',
user_id='test-user-123',
device_id='test-device-id',
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
)
)
# Verify forwarding
assert len(requests) == 1
event_batch = requests[0]
assert len(event_batch['events']) == 1
event = event_batch['events'][0]
assert event['event_type'] == 'CreateAgentTaskEvent'
assert event['user_id'] == 'test-user-123'
# BaseEvent creates event_type attribute, plus our custom data as attributes
assert event['task'] == 'Test task'
async def test_event_queueing_unauthenticated(self, httpserver: HTTPServer, unauthenticated_sync):
"""Test event queueing when unauthenticated."""
# Server returns 401
httpserver.expect_request('/api/v1/events', method='POST').respond_with_json({'error': 'unauthorized'}, status=401)
# Send event
await unauthenticated_sync.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task='Queued task',
user_id=TEMP_USER_ID,
device_id='test-device-id',
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
)
)
# Event should be queued
assert len(unauthenticated_sync.pending_events) == 1
queued_event = unauthenticated_sync.pending_events[0]
assert queued_event.event_type == 'CreateAgentTaskEvent'
assert queued_event.user_id == TEMP_USER_ID
assert queued_event.task == 'Queued task'
async def test_event_user_id_injection_pre_auth(self, httpserver: HTTPServer, unauthenticated_sync):
"""Test that temp user ID is injected for pre-auth events."""
requests = []
def capture_request(request):
requests.append(request.get_json())
from werkzeug.wrappers import Response
return Response('{"processed": 1, "failed": 0}', status=200, mimetype='application/json')
httpserver.expect_request('/api/v1/events', method='POST').respond_with_handler(capture_request)
# Send event without user_id
await unauthenticated_sync.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task='Pre-auth task',
user_id=TEMP_USER_ID,
device_id='test-device-id',
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
)
)
# Verify temp user ID was injected
assert len(requests) == 1
event = requests[0]['events'][0]
assert event['user_id'] == TEMP_USER_ID
class TestCloudSyncRetryLogic:
"""Test CloudSync retry and error handling logic."""
@pytest.fixture
def sync_with_auth(self, httpserver: HTTPServer, http_client, temp_config_dir):
"""Create CloudSync with auth."""
auth = DeviceAuthClient(base_url=httpserver.url_for(''), http_client=http_client)
auth.auth_config.api_token = 'test-api-key'
auth.auth_config.user_id = 'test-user-123'
service = CloudSync(base_url=httpserver.url_for(''), enable_auth=True)
service.auth_client = auth
service.session_id = 'test-session-id'
return service
async def test_pending_event_resending(self, httpserver: HTTPServer, sync_with_auth):
"""Test resending of pending events after authentication."""
requests = []
def capture_request(request):
requests.append(request.get_json())
from werkzeug.wrappers import Response
return Response('{"processed": 1, "failed": 0}', status=200, mimetype='application/json')
httpserver.expect_request('/api/v1/events', method='POST').respond_with_handler(capture_request)
# Manually add pending events (simulating 401 scenario)
sync_with_auth.pending_events.extend(
[
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task='Pending task 1',
user_id=TEMP_USER_ID,
device_id='test-device-id',
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
),
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task='Pending task 2',
user_id=TEMP_USER_ID,
device_id='test-device-id',
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
),
]
)
# Resend pending events
await sync_with_auth._resend_pending_events()
# Should send all pending events with updated user ID
assert len(requests) == 2
for i, request in enumerate(requests):
event = request['events'][0]
assert event['user_id'] == 'test-user-123' # Updated from temp ID
assert f'Pending task {i + 1}' == event['task']
# Pending events should be cleared
assert len(sync_with_auth.pending_events) == 0
async def test_backend_error_resilience(self, httpserver: HTTPServer, sync_with_auth):
"""Test resilience to backend errors."""
# Server returns 500 error
httpserver.expect_request('/api/v1/events', method='POST').respond_with_data('Internal Server Error', status=500)
# Should not raise exception
await sync_with_auth.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task='Task during outage',
user_id='test-user-123',
device_id='test-device-id',
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
)
)
# Events should not be queued for 500 errors (only 401)
assert len(sync_with_auth.pending_events) == 0
async def test_network_error_resilience(self, sync_with_auth):
"""Test resilience to network errors."""
# No server running - will get connection error
sync_with_auth.base_url = 'http://localhost:99999' # Invalid port
# Should not raise exception
await sync_with_auth.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task='Task during network error',
user_id='test-user-123',
device_id='test-device-id',
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
)
)
# Should handle gracefully without crashing
async def test_concurrent_event_sending(self, httpserver: HTTPServer, sync_with_auth):
"""Test handling of concurrent event sending."""
import asyncio
requests = []
def capture_request(request):
requests.append(request.get_json())
from werkzeug.wrappers import Response
return Response('{"processed": 1, "failed": 0}', status=200, mimetype='application/json')
httpserver.expect_request('/api/v1/events', method='POST').respond_with_handler(capture_request)
# Send multiple events concurrently
tasks = []
for i in range(5):
task = sync_with_auth.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task=f'Concurrent task {i}',
user_id='test-user-123',
device_id='test-device-id',
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
)
)
tasks.append(task)
await asyncio.gather(*tasks)
# All events should be sent
assert len(requests) == 5
# Just verify all events have task data - order may vary due to concurrency
task_values = [req['events'][0]['task'] for req in requests]
expected_tasks = [f'Concurrent task {i}' for i in range(5)]
assert sorted(task_values) == sorted(expected_tasks)
class TestCloudSyncBackendCommunication:
"""Test CloudSync backend communication patterns."""
async def test_request_format_validation(self, httpserver: HTTPServer, temp_config_dir):
"""Test that requests are formatted correctly for backend."""
requests = []
def capture_request(request):
# Validate request structure
assert request.content_type == 'application/json'
data = request.get_json()
requests.append(data)
# Validate batch structure
assert 'events' in data
assert isinstance(data['events'], list)
assert len(data['events']) == 1
event = data['events'][0]
required_fields = ['event_type', 'event_id', 'event_created_at', 'event_schema', 'user_id']
for field in required_fields:
assert field in event, f'Missing required field: {field}'
from werkzeug.wrappers import Response
return Response('{"processed": 1, "failed": 0}', status=200, mimetype='application/json')
httpserver.expect_request('/api/v1/events', method='POST').respond_with_handler(capture_request)
# Create authenticated service
auth = DeviceAuthClient(base_url=httpserver.url_for(''))
auth.auth_config.api_token = 'test-api-key'
auth.auth_config.user_id = 'test-user-123'
service = CloudSync(base_url=httpserver.url_for(''), enable_auth=True)
service.auth_client = auth
service.session_id = 'test-session-id'
await service.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task='Format validation test',
user_id='test-user-123',
device_id='test-device-id',
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
)
)
assert len(requests) == 1
async def test_auth_header_handling(self, httpserver: HTTPServer, temp_config_dir):
"""Test proper auth header handling."""
requests = []
def capture_request(request):
requests.append(
{
'headers': dict(request.headers),
'json': request.get_json(),
}
)
from werkzeug.wrappers import Response
return Response('{"processed": 1, "failed": 0}', status=200, mimetype='application/json')
httpserver.expect_request('/api/v1/events', method='POST').respond_with_handler(capture_request)
# Test authenticated request
auth = DeviceAuthClient(base_url=httpserver.url_for(''))
auth.auth_config.api_token = 'test-api-key'
auth.auth_config.user_id = 'test-user-123'
service = CloudSync(base_url=httpserver.url_for(''), enable_auth=True)
service.auth_client = auth
await service.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task='Auth header test',
user_id='test-user-123',
device_id='test-device-id',
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
)
)
# Check auth header was included
assert len(requests) == 1
headers = requests[0]['headers']
assert 'Authorization' in headers
assert headers['Authorization'] == 'Bearer test-api-key'
# Test unauthenticated request
requests.clear()
service.auth_client = DeviceAuthClient(base_url=httpserver.url_for('')) # No credentials
await service.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task='No auth test',
user_id='',
device_id='test-device-id',
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
)
)
# Check no auth header
assert len(requests) == 1
headers = requests[0]['headers']
assert 'Authorization' not in headers
class TestCloudSyncErrorHandling:
"""Test CloudSync error handling doesn't crash the agent."""
@pytest.fixture
def sync_service(self, httpserver: HTTPServer, temp_config_dir):
"""Create CloudSync service."""
return CloudSync(base_url=httpserver.url_for(''), enable_auth=False)
async def test_timeout_error_handling(self, sync_service):
"""Test that timeout errors are handled gracefully."""
# Use a URL that will timeout
sync_service.base_url = 'http://10.255.255.1' # Non-routable IP for timeout
# Should not raise exception
await sync_service.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task='Timeout test',
user_id='test-user-123',
device_id='test-device-id',
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
)
)
async def test_malformed_event_handling(self, httpserver: HTTPServer, sync_service):
"""Test handling of events that can't be serialized."""
class BadEvent(BaseEvent):
"""Event that will fail to serialize."""
event_type: str = 'BadEvent'
def model_dump(self, **kwargs):
raise ValueError('Serialization failed')
# Should not raise exception
await sync_service.handle_event(BadEvent())
async def test_http_error_responses(self, httpserver: HTTPServer, sync_service):
"""Test various HTTP error responses don't crash the service."""
error_codes = [400, 403, 404, 429, 500, 502, 503]
for status_code in error_codes:
httpserver.expect_request('/api/v1/events', method='POST').respond_with_json(
{'error': f'Test error {status_code}'}, status=status_code
)
# Should not raise exception
await sync_service.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task=f'Error {status_code} test',
user_id='test-user-123',
device_id='test-device-id',
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
)
)
async def test_invalid_response_handling(self, httpserver: HTTPServer, sync_service):
"""Test handling of invalid server responses."""
# Return invalid JSON
httpserver.expect_request('/api/v1/events', method='POST').respond_with_data('Not JSON', status=200)
# Should not raise exception
await sync_service.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task='Invalid response test',
user_id='test-user-123',
device_id='test-device-id',
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
)
)
async def test_event_with_restricted_attributes(self, httpserver: HTTPServer, sync_service):
"""Test handling events that don't allow user_id attribute."""
from pydantic import ConfigDict
class RestrictedEvent(BaseEvent):
"""Event that doesn't allow extra attributes."""
model_config = ConfigDict(extra='forbid')
event_type: str = 'RestrictedEvent'
data: str = 'test'
httpserver.expect_request('/api/v1/events', method='POST').respond_with_json({'processed': 1}, status=200)
# Should not raise exception - will log debug message about not being able to set user_id
await sync_service.handle_event(RestrictedEvent())
async def test_concurrent_error_resilience(self, httpserver: HTTPServer, sync_service):
"""Test that concurrent errors don't affect other events."""
import asyncio
successful_requests = []
request_count = 0
def handler(request):
nonlocal request_count
request_count += 1
# Every 3rd request fails
if request_count % 3 == 0:
from werkzeug.wrappers import Response
return Response('Server Error', status=500)
else:
successful_requests.append(request.get_json())
from werkzeug.wrappers import Response
return Response('{"processed": 1}', status=200, mimetype='application/json')
httpserver.expect_request('/api/v1/events', method='POST').respond_with_handler(handler)
# Send 10 events concurrently
tasks = []
for i in range(10):
task = sync_service.handle_event(
CreateAgentTaskEvent(
agent_session_id='test-session',
llm_model='test-model',
task=f'Concurrent error test {i}',
user_id='test-user-123',
device_id='test-device-id',
done_output=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
)
)
tasks.append(task)
# All should complete without raising
await asyncio.gather(*tasks)
# ~7 should succeed (10 total, ~3 fail)
assert len(successful_requests) >= 6
@@ -0,0 +1,286 @@
"""Test telemetry functionality."""
from unittest.mock import MagicMock, patch
import pytest
from browser_use.config import CONFIG
from browser_use.telemetry import (
CLITelemetryEvent,
MCPClientTelemetryEvent,
MCPServerTelemetryEvent,
ProductTelemetry,
)
from browser_use.utils import get_browser_use_version
@pytest.fixture
def reset_telemetry_singleton():
"""Reset the telemetry singleton between tests."""
# The singleton decorator stores instance in a list at index 0
# We need to access the closure variable which stores the singleton instance
# Import the actual module to access the wrapped function
from browser_use.telemetry import service
# Get the ProductTelemetry wrapper function created by @singleton
wrapper_func = service.ProductTelemetry
# Access the closure variable (instance list) and reset it
# The closure contains the 'instance' list at index 0
# Type ignore needed because pyright doesn't know this is a wrapper function
if hasattr(wrapper_func, '__closure__') and wrapper_func.__closure__: # type: ignore
for cell in wrapper_func.__closure__: # type: ignore
if hasattr(cell.cell_contents, '__setitem__'):
# This is the instance list
cell.cell_contents[0] = None
break
yield
# Reset again after test
if hasattr(wrapper_func, '__closure__') and wrapper_func.__closure__: # type: ignore
for cell in wrapper_func.__closure__: # type: ignore
if hasattr(cell.cell_contents, '__setitem__'):
cell.cell_contents[0] = None
break
@pytest.fixture
def mock_posthog():
"""Mock PostHog client."""
with patch('browser_use.telemetry.service.Posthog') as mock:
yield mock
def test_telemetry_disabled_when_config_false(monkeypatch, reset_telemetry_singleton):
"""Test that telemetry is disabled when ANONYMIZED_TELEMETRY is False."""
# Set env var to disable telemetry
monkeypatch.setattr(CONFIG, 'ANONYMIZED_TELEMETRY', False)
# Create telemetry instance
telemetry = ProductTelemetry()
# Check that posthog client is None
assert telemetry._posthog_client is None
# Try to capture an event - should not fail
event = CLITelemetryEvent(
version=get_browser_use_version(),
action='start',
mode='interactive',
)
telemetry.capture(event) # Should not raise
def test_telemetry_enabled_when_config_true(monkeypatch, mock_posthog, reset_telemetry_singleton):
"""Test that telemetry is enabled when ANONYMIZED_TELEMETRY is True."""
# Set env var to enable telemetry
monkeypatch.setattr(CONFIG, 'ANONYMIZED_TELEMETRY', True)
# Create telemetry instance
telemetry = ProductTelemetry()
# Check that posthog client is created
assert telemetry._posthog_client is not None
mock_posthog.assert_called_once()
def test_cli_telemetry_event():
"""Test CLITelemetryEvent structure."""
event = CLITelemetryEvent(
version='1.0.0',
action='start',
mode='interactive',
model='gpt-4o',
model_provider='OpenAI',
duration_seconds=10.5,
error_message=None,
)
assert event.name == 'cli_event'
assert event.version == '1.0.0'
assert event.action == 'start'
assert event.mode == 'interactive'
assert event.model == 'gpt-4o'
assert event.model_provider == 'OpenAI'
assert event.duration_seconds == 10.5
assert event.error_message is None
# Check properties
props = event.properties
assert 'version' in props
assert 'action' in props
assert 'mode' in props
assert 'is_docker' in props # Docker context should be included
assert isinstance(props['is_docker'], bool) # Should be a boolean
assert 'name' not in props # name should not be in properties
def test_mcp_client_telemetry_event():
"""Test MCPClientTelemetryEvent structure."""
event = MCPClientTelemetryEvent(
server_name='test-server',
command='npx',
tools_discovered=5,
version='1.0.0',
action='connect',
tool_name='browser_navigate',
duration_seconds=2.5,
error_message=None,
)
assert event.name == 'mcp_client_event'
assert event.server_name == 'test-server'
assert event.command == 'npx'
assert event.tools_discovered == 5
assert event.version == '1.0.0'
assert event.action == 'connect'
assert event.tool_name == 'browser_navigate'
assert event.duration_seconds == 2.5
assert event.error_message is None
def test_mcp_server_telemetry_event():
"""Test MCPServerTelemetryEvent structure."""
event = MCPServerTelemetryEvent(
version='1.0.0',
action='start',
tool_name='browser_click',
duration_seconds=1.2,
error_message='Test error',
)
assert event.name == 'mcp_server_event'
assert event.version == '1.0.0'
assert event.action == 'start'
assert event.tool_name == 'browser_click'
assert event.duration_seconds == 1.2
assert event.error_message == 'Test error'
def test_telemetry_capture_with_mock(monkeypatch, reset_telemetry_singleton):
"""Test telemetry capture with mocked PostHog client."""
# Enable telemetry
monkeypatch.setattr(CONFIG, 'ANONYMIZED_TELEMETRY', True)
# Create mock posthog client
mock_client = MagicMock()
# Create telemetry instance and inject mock
telemetry = ProductTelemetry()
telemetry._posthog_client = mock_client
# Capture an event
event = CLITelemetryEvent(
version='1.0.0',
action='start',
mode='oneshot',
)
telemetry.capture(event)
# Check that capture was called
mock_client.capture.assert_called_once()
call_args = mock_client.capture.call_args
assert call_args[1]['event'] == 'cli_event'
assert 'properties' in call_args[1]
assert call_args[1]['properties']['version'] == '1.0.0'
assert call_args[1]['properties']['action'] == 'start'
assert call_args[1]['properties']['mode'] == 'oneshot'
def test_telemetry_flush(monkeypatch, reset_telemetry_singleton):
"""Test telemetry flush method."""
# Enable telemetry
monkeypatch.setattr(CONFIG, 'ANONYMIZED_TELEMETRY', True)
# Create mock posthog client
mock_client = MagicMock()
# Create telemetry instance and inject mock
telemetry = ProductTelemetry()
telemetry._posthog_client = mock_client
# Call flush
telemetry.flush()
# Check that flush was called
mock_client.flush.assert_called_once()
def test_telemetry_user_id_generation(tmp_path, monkeypatch, reset_telemetry_singleton):
"""Test that telemetry generates and persists user ID."""
# Set BROWSER_USE_CONFIG_DIR to temp directory
config_dir = tmp_path / 'config' / 'browseruse'
config_dir.mkdir(parents=True)
monkeypatch.setenv('BROWSER_USE_CONFIG_DIR', str(config_dir))
# Enable telemetry
monkeypatch.setattr(CONFIG, 'ANONYMIZED_TELEMETRY', True)
# Create telemetry instance with patched path
telemetry1 = ProductTelemetry()
# Manually patch the USER_ID_PATH on the instance
telemetry1.USER_ID_PATH = str(config_dir / 'device_id')
user_id1 = telemetry1.user_id
# Check that user ID is generated
assert user_id1 != 'UNKNOWN_USER_ID'
assert len(user_id1) > 0
# Create another instance - should get same ID
telemetry2 = ProductTelemetry()
telemetry2.USER_ID_PATH = str(config_dir / 'device_id')
user_id2 = telemetry2.user_id
assert user_id1 == user_id2
# Check that ID was persisted in config directory
id_file = config_dir / 'device_id'
assert id_file.exists()
assert id_file.read_text() == user_id1
def test_mcp_server_telemetry_event_with_parent_process():
"""Test MCPServerTelemetryEvent with parent_process_cmdline field."""
event = MCPServerTelemetryEvent(
version='1.0.0',
action='start',
tool_name=None,
duration_seconds=None,
error_message=None,
parent_process_cmdline='python -m browser_use.mcp.server',
)
assert event.name == 'mcp_server_event'
assert event.version == '1.0.0'
assert event.action == 'start'
assert event.parent_process_cmdline == 'python -m browser_use.mcp.server'
# Check properties includes parent_process_cmdline
props = event.properties
assert 'parent_process_cmdline' in props
assert props['parent_process_cmdline'] == 'python -m browser_use.mcp.server'
assert 'is_docker' in props # Docker context should be included
assert isinstance(props['is_docker'], bool) # Should be a boolean
def test_telemetry_device_id_uses_config_dir():
"""Test that telemetry device_id is stored in config directory, not cache directory."""
# This test verifies that the ProductTelemetry class uses CONFIG.BROWSER_USE_CONFIG_DIR
# for the device_id file location instead of the cache directory.
# Import ProductTelemetry to check the path
from browser_use.telemetry.service import ProductTelemetry
# Create telemetry instance
telemetry = ProductTelemetry()
# The USER_ID_PATH should use the config directory
# We check that it contains the config dir path and ends with 'device_id'
assert 'browseruse/device_id' in telemetry.USER_ID_PATH or 'browseruse\\device_id' in telemetry.USER_ID_PATH
assert telemetry.USER_ID_PATH.endswith('device_id')
# Verify it's not using the cache directory path
assert 'cache' not in telemetry.USER_ID_PATH
assert 'telemetry_user_id' not in telemetry.USER_ID_PATH
@@ -0,0 +1,582 @@
import asyncio
import tempfile
import time
import pytest
from pydantic import BaseModel
from pytest_httpserver import HTTPServer
from browser_use.agent.views import ActionModel, ActionResult
from browser_use.browser import BrowserSession
from browser_use.browser.profile import BrowserProfile
from browser_use.filesystem.file_system import FileSystem
from browser_use.tools.service import Tools
from browser_use.tools.views import (
DoneAction,
GoToUrlAction,
NoParamsAction,
SearchGoogleAction,
)
@pytest.fixture(scope='session')
def http_server():
"""Create and provide a test HTTP server that serves static content."""
server = HTTPServer()
server.start()
# Add routes for common test pages
server.expect_request('/').respond_with_data(
'<html><head><title>Test Home Page</title></head><body><h1>Test Home Page</h1><p>Welcome to the test site</p></body></html>',
content_type='text/html',
)
server.expect_request('/page1').respond_with_data(
'<html><head><title>Test Page 1</title></head><body><h1>Test Page 1</h1><p>This is test page 1</p></body></html>',
content_type='text/html',
)
server.expect_request('/page2').respond_with_data(
'<html><head><title>Test Page 2</title></head><body><h1>Test Page 2</h1><p>This is test page 2</p></body></html>',
content_type='text/html',
)
server.expect_request('/search').respond_with_data(
"""
<html>
<head><title>Search Results</title></head>
<body>
<h1>Search Results</h1>
<div class="results">
<div class="result">Result 1</div>
<div class="result">Result 2</div>
<div class="result">Result 3</div>
</div>
</body>
</html>
""",
content_type='text/html',
)
yield server
server.stop()
@pytest.fixture(scope='session')
def base_url(http_server):
"""Return the base URL for the test HTTP server."""
return f'http://{http_server.host}:{http_server.port}'
@pytest.fixture(scope='module')
async def browser_session():
"""Create and provide a Browser instance with security disabled."""
browser_session = BrowserSession(
browser_profile=BrowserProfile(
headless=True,
user_data_dir=None,
keep_alive=True,
)
)
await browser_session.start()
yield browser_session
await browser_session.kill()
@pytest.fixture(scope='function')
def tools():
"""Create and provide a Tools instance."""
return Tools()
class TestToolsIntegration:
"""Integration tests for Tools using actual browser instances."""
async def test_registry_actions(self, tools, browser_session):
"""Test that the registry contains the expected default actions."""
# Check that common actions are registered
common_actions = [
'go_to_url',
'search_google',
'click_element_by_index',
'input_text',
'scroll',
'go_back',
'switch_tab',
'close_tab',
'wait',
]
for action in common_actions:
assert action in tools.registry.registry.actions
assert tools.registry.registry.actions[action].function is not None
assert tools.registry.registry.actions[action].description is not None
async def test_custom_action_registration(self, tools, browser_session, base_url):
"""Test registering a custom action and executing it."""
# Define a custom action
class CustomParams(BaseModel):
text: str
@tools.action('Test custom action', param_model=CustomParams)
async def custom_action(params: CustomParams, browser_session):
current_url = await browser_session.get_current_page_url()
return ActionResult(extracted_content=f'Custom action executed with: {params.text} on {current_url}')
# Navigate to a page first
goto_action = {'go_to_url': GoToUrlAction(url=f'{base_url}/page1', new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
await tools.act(GoToUrlActionModel(**goto_action), browser_session)
# Create the custom action model
custom_action_data = {'custom_action': CustomParams(text='test_value')}
class CustomActionModel(ActionModel):
custom_action: CustomParams | None = None
# Execute the custom action
result = await tools.act(CustomActionModel(**custom_action_data), browser_session)
# Verify the result
assert isinstance(result, ActionResult)
assert result.extracted_content is not None
assert 'Custom action executed with: test_value on' in result.extracted_content
assert f'{base_url}/page1' in result.extracted_content
async def test_wait_action(self, tools, browser_session):
"""Test that the wait action correctly waits for the specified duration."""
# verify that it's in the default action set
wait_action = None
for action_name, action in tools.registry.registry.actions.items():
if 'wait' in action_name.lower() and 'seconds' in str(action.param_model.model_fields):
wait_action = action
break
assert wait_action is not None, 'Could not find wait action in tools'
# Check that it has seconds parameter with default
assert 'seconds' in wait_action.param_model.model_fields
schema = wait_action.param_model.model_json_schema()
assert schema['properties']['seconds']['default'] == 3
# Create wait action for 1 second - fix to use a dictionary
wait_action = {'wait': {'seconds': 3}} # Corrected format
class WaitActionModel(ActionModel):
wait: dict | None = None
# Record start time
start_time = time.time()
# Execute wait action
result = await tools.act(WaitActionModel(**wait_action), browser_session)
# Record end time
end_time = time.time()
# Verify the result
assert isinstance(result, ActionResult)
assert result.extracted_content is not None
assert 'Waited for' in result.extracted_content or 'Waiting for' in result.extracted_content
# Verify that approximately 1 second has passed (allowing some margin)
assert end_time - start_time <= 0.5 # We wait 3-3 seconds for LLM call
# longer wait
# Create wait action for 1 second - fix to use a dictionary
wait_action = {'wait': {'seconds': 5}} # Corrected format
# Record start time
start_time = time.time()
# Execute wait action
result = await tools.act(WaitActionModel(**wait_action), browser_session)
# Record end time
end_time = time.time()
# Verify the result
assert isinstance(result, ActionResult)
assert result.extracted_content is not None
assert 'Waited for' in result.extracted_content or 'Waiting for' in result.extracted_content
assert 1.5 <= end_time - start_time <= 2.5 # We wait 5-3 seconds for LLM call
async def test_go_back_action(self, tools, browser_session, base_url):
"""Test that go_back action navigates to the previous page."""
# Navigate to first page
goto_action1 = {'go_to_url': GoToUrlAction(url=f'{base_url}/page1', new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
await tools.act(GoToUrlActionModel(**goto_action1), browser_session)
# Store the first page URL
first_url = await browser_session.get_current_page_url()
print(f'First page URL: {first_url}')
# Navigate to second page
goto_action2 = {'go_to_url': GoToUrlAction(url=f'{base_url}/page2', new_tab=False)}
await tools.act(GoToUrlActionModel(**goto_action2), browser_session)
# Verify we're on the second page
second_url = await browser_session.get_current_page_url()
print(f'Second page URL: {second_url}')
assert f'{base_url}/page2' in second_url
# Execute go back action
go_back_action = {'go_back': NoParamsAction()}
class GoBackActionModel(ActionModel):
go_back: NoParamsAction | None = None
result = await tools.act(GoBackActionModel(**go_back_action), browser_session)
# Verify the result
assert isinstance(result, ActionResult)
assert result.extracted_content is not None
assert 'Navigated back' in result.extracted_content
# Add another delay to allow the navigation to complete
await asyncio.sleep(1)
# Verify we're back on a different page than before
final_url = await browser_session.get_current_page_url()
print(f'Final page URL after going back: {final_url}')
# Try to verify we're back on the first page, but don't fail the test if not
assert f'{base_url}/page1' in final_url, f'Expected to return to page1 but got {final_url}'
async def test_navigation_chain(self, tools, browser_session, base_url):
"""Test navigating through multiple pages and back through history."""
# Set up a chain of navigation: Home -> Page1 -> Page2
urls = [f'{base_url}/', f'{base_url}/page1', f'{base_url}/page2']
# Navigate to each page in sequence
for url in urls:
action_data = {'go_to_url': GoToUrlAction(url=url, new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
await tools.act(GoToUrlActionModel(**action_data), browser_session)
# Verify current page
current_url = await browser_session.get_current_page_url()
assert url in current_url
# Go back twice and verify each step
for expected_url in reversed(urls[:-1]):
go_back_action = {'go_back': NoParamsAction()}
class GoBackActionModel(ActionModel):
go_back: NoParamsAction | None = None
await tools.act(GoBackActionModel(**go_back_action), browser_session)
await asyncio.sleep(1) # Wait for navigation to complete
current_url = await browser_session.get_current_page_url()
assert expected_url in current_url
async def test_excluded_actions(self, browser_session):
"""Test that excluded actions are not registered."""
# Create tools with excluded actions
excluded_tools = Tools(exclude_actions=['search_google', 'scroll'])
# Verify excluded actions are not in the registry
assert 'search_google' not in excluded_tools.registry.registry.actions
assert 'scroll' not in excluded_tools.registry.registry.actions
# But other actions are still there
assert 'go_to_url' in excluded_tools.registry.registry.actions
assert 'click_element_by_index' in excluded_tools.registry.registry.actions
async def test_search_google_action(self, tools, browser_session, base_url):
"""Test the search_google action."""
await browser_session.get_current_page_url()
# Execute search_google action - it will actually navigate to our search results page
search_action = {'search_google': SearchGoogleAction(query='Python web automation')}
class SearchGoogleActionModel(ActionModel):
search_google: SearchGoogleAction | None = None
result = await tools.act(SearchGoogleActionModel(**search_action), browser_session)
# Verify the result
assert isinstance(result, ActionResult)
assert result.extracted_content is not None
assert 'Searched' in result.extracted_content and 'Python web automation' in result.extracted_content
# For our test purposes, we just verify we're on some URL
current_url = await browser_session.get_current_page_url()
assert current_url is not None and 'Python' in current_url
async def test_done_action(self, tools, browser_session, base_url):
"""Test that DoneAction completes a task and reports success or failure."""
# Create a temporary directory for the file system
with tempfile.TemporaryDirectory() as temp_dir:
file_system = FileSystem(temp_dir)
# First navigate to a page
goto_action = {'go_to_url': GoToUrlAction(url=f'{base_url}/page1', new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
await tools.act(GoToUrlActionModel(**goto_action), browser_session)
success_done_message = 'Successfully completed task'
# Create done action with success
done_action = {'done': DoneAction(text=success_done_message, success=True)}
class DoneActionModel(ActionModel):
done: DoneAction | None = None
# Execute done action with file_system
result = await tools.act(DoneActionModel(**done_action), browser_session, file_system=file_system)
# Verify the result
assert isinstance(result, ActionResult)
assert result.extracted_content is not None
assert success_done_message in result.extracted_content
assert result.success is True
assert result.is_done is True
assert result.error is None
failed_done_message = 'Failed to complete task'
# Test with failure case
failed_done_action = {'done': DoneAction(text=failed_done_message, success=False)}
# Execute failed done action with file_system
result = await tools.act(DoneActionModel(**failed_done_action), browser_session, file_system=file_system)
# Verify the result
assert isinstance(result, ActionResult)
assert result.extracted_content is not None
assert failed_done_message in result.extracted_content
assert result.success is False
assert result.is_done is True
assert result.error is None
async def test_get_dropdown_options(self, tools, browser_session, base_url, http_server):
"""Test that get_dropdown_options correctly retrieves options from a dropdown."""
# Add route for dropdown test page
http_server.expect_request('/dropdown1').respond_with_data(
"""
<!DOCTYPE html>
<html>
<head>
<title>Dropdown Test</title>
</head>
<body>
<h1>Dropdown Test</h1>
<select id="test-dropdown" name="test-dropdown">
<option value="">Please select</option>
<option value="option1">First Option</option>
<option value="option2">Second Option</option>
<option value="option3">Third Option</option>
</select>
</body>
</html>
""",
content_type='text/html',
)
# Navigate to the dropdown test page
goto_action = {'go_to_url': GoToUrlAction(url=f'{base_url}/dropdown1', new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
await tools.act(GoToUrlActionModel(**goto_action), browser_session)
# Wait for the page to load using CDP
cdp_session = browser_session.agent_focus
assert cdp_session is not None, 'CDP session not initialized'
# Wait for page load by checking document ready state
await asyncio.sleep(0.5) # Brief wait for navigation to start
ready_state = await cdp_session.cdp_client.send.Runtime.evaluate(
params={'expression': 'document.readyState'}, session_id=cdp_session.session_id
)
# If not complete, wait a bit more
if ready_state.get('result', {}).get('value') != 'complete':
await asyncio.sleep(1.0)
# Initialize the DOM state to populate the selector map
await browser_session.get_browser_state_summary(cache_clickable_elements_hashes=True)
# Get the selector map
selector_map = await browser_session.get_selector_map()
# Find the dropdown element in the selector map
dropdown_index = None
for idx, element in selector_map.items():
if element.tag_name.lower() == 'select':
dropdown_index = idx
break
assert dropdown_index is not None, (
f'Could not find select element in selector map. Available elements: {[f"{idx}: {element.tag_name}" for idx, element in selector_map.items()]}'
)
# Create a model for the standard get_dropdown_options action
class GetDropdownOptionsModel(ActionModel):
get_dropdown_options: dict[str, int]
# Execute the action with the dropdown index
result = await tools.act(
action=GetDropdownOptionsModel(get_dropdown_options={'index': dropdown_index}),
browser_session=browser_session,
)
expected_options = [
{'index': 0, 'text': 'Please select', 'value': ''},
{'index': 1, 'text': 'First Option', 'value': 'option1'},
{'index': 2, 'text': 'Second Option', 'value': 'option2'},
{'index': 3, 'text': 'Third Option', 'value': 'option3'},
]
# Verify the result structure
assert isinstance(result, ActionResult)
# Core logic validation: Verify all options are returned
assert result.extracted_content is not None
for option in expected_options[1:]: # Skip the placeholder option
assert option['text'] in result.extracted_content, f"Option '{option['text']}' not found in result content"
# Verify the instruction for using the text in select_dropdown_option is included
assert (
'Use the exact text or value string' in result.extracted_content
and 'select_dropdown_option' in result.extracted_content
)
# Verify the actual dropdown options in the DOM using CDP
dropdown_options_result = await cdp_session.cdp_client.send.Runtime.evaluate(
params={
'expression': """
JSON.stringify((() => {
const select = document.getElementById('test-dropdown');
return Array.from(select.options).map(opt => ({
text: opt.text,
value: opt.value
}));
})())
""",
'returnByValue': True,
},
session_id=cdp_session.session_id,
)
dropdown_options_json = dropdown_options_result.get('result', {}).get('value', '[]')
import json
dropdown_options = json.loads(dropdown_options_json) if isinstance(dropdown_options_json, str) else dropdown_options_json
# Verify the dropdown has the expected options
assert len(dropdown_options) == len(expected_options), (
f'Expected {len(expected_options)} options, got {len(dropdown_options)}'
)
for i, expected in enumerate(expected_options):
actual = dropdown_options[i]
assert actual['text'] == expected['text'], (
f"Option at index {i} has wrong text: expected '{expected['text']}', got '{actual['text']}'"
)
assert actual['value'] == expected['value'], (
f"Option at index {i} has wrong value: expected '{expected['value']}', got '{actual['value']}'"
)
async def test_select_dropdown_option(self, tools, browser_session, base_url, http_server):
"""Test that select_dropdown_option correctly selects an option from a dropdown."""
# Add route for dropdown test page
http_server.expect_request('/dropdown2').respond_with_data(
"""
<!DOCTYPE html>
<html>
<head>
<title>Dropdown Test</title>
</head>
<body>
<h1>Dropdown Test</h1>
<select id="test-dropdown" name="test-dropdown">
<option value="">Please select</option>
<option value="option1">First Option</option>
<option value="option2">Second Option</option>
<option value="option3">Third Option</option>
</select>
</body>
</html>
""",
content_type='text/html',
)
# Navigate to the dropdown test page
goto_action = {'go_to_url': GoToUrlAction(url=f'{base_url}/dropdown2', new_tab=False)}
class GoToUrlActionModel(ActionModel):
go_to_url: GoToUrlAction | None = None
await tools.act(GoToUrlActionModel(**goto_action), browser_session)
# Wait for the page to load using CDP
cdp_session = browser_session.agent_focus
assert cdp_session is not None, 'CDP session not initialized'
# Wait for page load by checking document ready state
await asyncio.sleep(0.5) # Brief wait for navigation to start
ready_state = await cdp_session.cdp_client.send.Runtime.evaluate(
params={'expression': 'document.readyState'}, session_id=cdp_session.session_id
)
# If not complete, wait a bit more
if ready_state.get('result', {}).get('value') != 'complete':
await asyncio.sleep(1.0)
# populate the selector map with highlight indices
await browser_session.get_browser_state_summary(cache_clickable_elements_hashes=True)
# Now get the selector map which should contain our dropdown
selector_map = await browser_session.get_selector_map()
# Find the dropdown element in the selector map
dropdown_index = None
for idx, element in selector_map.items():
if element.tag_name.lower() == 'select':
dropdown_index = idx
break
assert dropdown_index is not None, (
f'Could not find select element in selector map. Available elements: {[f"{idx}: {element.tag_name}" for idx, element in selector_map.items()]}'
)
# Create a model for the standard select_dropdown_option action
class SelectDropdownOptionModel(ActionModel):
select_dropdown_option: dict
# Execute the action with the dropdown index
result = await tools.act(
SelectDropdownOptionModel(select_dropdown_option={'index': dropdown_index, 'text': 'Second Option'}),
browser_session,
)
# Verify the result structure
assert isinstance(result, ActionResult)
# Core logic validation: Verify selection was successful
assert result.extracted_content is not None
assert 'selected option' in result.extracted_content.lower()
assert 'Second Option' in result.extracted_content
# Verify the actual dropdown selection was made by checking the DOM using CDP
selected_value_result = await cdp_session.cdp_client.send.Runtime.evaluate(
params={'expression': "document.getElementById('test-dropdown').value"}, session_id=cdp_session.session_id
)
selected_value = selected_value_result.get('result', {}).get('value')
assert selected_value == 'option2' # Second Option has value "option2"
@@ -0,0 +1,161 @@
"""
Simplified tests for URL shortening functionality in Agent service.
Three focused tests:
1. Input message processing with URL shortening
2. Output processing with custom actions and URL restoration
3. End-to-end pipeline test
"""
import json
import pytest
from browser_use.agent.service import Agent
from browser_use.agent.views import AgentOutput
from browser_use.llm.messages import AssistantMessage, BaseMessage, UserMessage
# Super long URL to reuse across tests - much longer than the 25 character limit
# Includes both query params (?...) and fragment params (#...)
SUPER_LONG_URL = 'https://documentation.example-company.com/api/v3/enterprise/user-management/endpoints/administration/create-new-user-account-with-permissions/advanced-settings?format=detailed-json&version=3.2.1&timestamp=1699123456789&session_id=abc123def456ghi789&authentication_token=very_long_authentication_token_string_here&include_metadata=true&expand_relationships=user_groups,permissions,roles&sort_by=created_at&order=desc&page_size=100&include_deprecated_fields=false&api_key=super_long_api_key_that_exceeds_normal_limits#section=user_management&tab=advanced&view=detailed&scroll_to=permissions_table&highlight=admin_settings&filter=active_users&expand_all=true&debug_mode=enabled'
@pytest.fixture
def agent():
"""Create an agent instance for testing URL shortening functionality."""
from tests.ci.conftest import create_mock_llm
return Agent(task='Test URL shortening', llm=create_mock_llm(), url_shortening_limit=25)
class TestUrlShorteningInputProcessing:
"""Test URL shortening for input messages."""
def test_process_input_messages_with_url_shortening(self, agent: Agent):
"""Test that long URLs in input messages are shortened and mappings stored."""
original_content = f'Please visit {SUPER_LONG_URL} and extract information'
messages: list[BaseMessage] = [UserMessage(content=original_content)]
# Process messages (modifies messages in-place and returns URL mappings)
url_mappings = agent._process_messsages_and_replace_long_urls_shorter_ones(messages)
# Verify URL was shortened in the message (modified in-place)
processed_content = messages[0].content or ''
assert processed_content != original_content
assert 'https://documentation.example-company.com' in processed_content
assert len(processed_content) < len(original_content)
# Verify URL mapping was returned
assert len(url_mappings) == 1
shortened_url = next(iter(url_mappings.keys()))
assert url_mappings[shortened_url] == SUPER_LONG_URL
def test_process_user_and_assistant_messages_with_url_shortening(self, agent: Agent):
"""Test URL shortening in both UserMessage and AssistantMessage."""
user_content = f'I need to access {SUPER_LONG_URL} for the API documentation'
assistant_content = f'I will help you navigate to {SUPER_LONG_URL} to retrieve the documentation'
messages: list[BaseMessage] = [UserMessage(content=user_content), AssistantMessage(content=assistant_content)]
# Process messages (modifies messages in-place and returns URL mappings)
url_mappings = agent._process_messsages_and_replace_long_urls_shorter_ones(messages)
# Verify URL was shortened in both messages
user_processed_content = messages[0].content or ''
assistant_processed_content = messages[1].content or ''
assert user_processed_content != user_content
assert assistant_processed_content != assistant_content
assert 'https://documentation.example-company.com' in user_processed_content
assert 'https://documentation.example-company.com' in assistant_processed_content
assert len(user_processed_content) < len(user_content)
assert len(assistant_processed_content) < len(assistant_content)
# Verify URL mapping was returned (should be same shortened URL for both occurrences)
assert len(url_mappings) == 1
shortened_url = next(iter(url_mappings.keys()))
assert url_mappings[shortened_url] == SUPER_LONG_URL
class TestUrlShorteningOutputProcessing:
"""Test URL restoration for output processing with custom actions."""
def test_process_output_with_custom_actions_and_url_restoration(self, agent: Agent):
"""Test that shortened URLs in AgentOutput with custom actions are restored."""
# Set up URL mapping (simulating previous shortening)
shortened_url: str = agent._replace_urls_in_text(SUPER_LONG_URL)[0]
url_mappings = {shortened_url: SUPER_LONG_URL}
# Create AgentOutput with shortened URLs using JSON parsing
output_json = {
'thinking': f'I need to navigate to {shortened_url} for documentation',
'evaluation_previous_goal': 'Successfully processed the request',
'memory': f'Found useful info at {shortened_url}',
'next_goal': 'Complete the documentation review',
'action': [{'go_to_url': {'url': shortened_url, 'new_tab': False}}],
}
# Create properly typed AgentOutput with custom actions
tools = agent.tools
ActionModel = tools.registry.create_action_model()
AgentOutputWithActions = AgentOutput.type_with_custom_actions(ActionModel)
agent_output = AgentOutputWithActions.model_validate_json(json.dumps(output_json))
# Process the output to restore URLs (modifies agent_output in-place)
agent._recursive_process_all_strings_inside_pydantic_model(agent_output, url_mappings)
# Verify URLs were restored in all locations
assert SUPER_LONG_URL in (agent_output.thinking or '')
assert SUPER_LONG_URL in (agent_output.memory or '')
action_data = agent_output.action[0].model_dump()
assert action_data['go_to_url']['url'] == SUPER_LONG_URL
class TestUrlShorteningEndToEnd:
"""Test complete URL shortening pipeline end-to-end."""
def test_complete_url_shortening_pipeline(self, agent: Agent):
"""Test the complete pipeline: input shortening -> processing -> output restoration."""
# Step 1: Input processing with URL shortening
original_content = f'Navigate to {SUPER_LONG_URL} and extract the API documentation'
messages: list[BaseMessage] = [UserMessage(content=original_content)]
url_mappings = agent._process_messsages_and_replace_long_urls_shorter_ones(messages)
# Verify URL was shortened in input
assert len(url_mappings) == 1
shortened_url = next(iter(url_mappings.keys()))
assert url_mappings[shortened_url] == SUPER_LONG_URL
assert shortened_url in (messages[0].content or '')
# Step 2: Simulate agent output with shortened URL
output_json = {
'thinking': f'I will navigate to {shortened_url} to get the documentation',
'evaluation_previous_goal': 'Starting documentation extraction',
'memory': f'Target URL: {shortened_url}',
'next_goal': 'Extract API documentation',
'action': [{'go_to_url': {'url': shortened_url, 'new_tab': True}}],
}
# Create AgentOutput with custom actions
tools = agent.tools
ActionModel = tools.registry.create_action_model()
AgentOutputWithActions = AgentOutput.type_with_custom_actions(ActionModel)
agent_output = AgentOutputWithActions.model_validate_json(json.dumps(output_json))
# Step 3: Output processing with URL restoration (modifies agent_output in-place)
agent._recursive_process_all_strings_inside_pydantic_model(agent_output, url_mappings)
# Verify complete pipeline worked correctly
assert SUPER_LONG_URL in (agent_output.thinking or '')
assert SUPER_LONG_URL in (agent_output.memory or '')
action_data = agent_output.action[0].model_dump()
assert action_data['go_to_url']['url'] == SUPER_LONG_URL
assert action_data['go_to_url']['new_tab'] is True
# Verify original shortened content is no longer present
assert shortened_url not in (agent_output.thinking or '')
assert shortened_url not in (agent_output.memory or '')