4cd2d4af2b
Test Browser Use CLI Install / uv pip install (ubuntu-latest) (push) Failing after 1s
Test Browser Use CLI Install / uvx browser-use from local wheel (push) Failing after 1s
Test Browser Use CLI Install / uvx browser-use[cli] from PyPI (push) Failing after 1s
package / pip-install-on-macos-latest-py-3.11 (push) Has been skipped
package / pip-install-on-macos-latest-py-3.13 (push) Has been skipped
package / pip-install-on-ubuntu-latest-py-3.11 (push) Has been skipped
package / pip-install-on-windows-latest-py-3.13 (push) Has been skipped
cloud_evals / trigger_cloud_eval_image_build (push) Failing after 1s
docker / build_publish_image (push) Failing after 1s
Test Browser Use CLI Install / browser-use skill sync (push) Failing after 1s
lint / code-style (push) Failing after 0s
lint / type-checker (push) Failing after 1s
package / pip-build (push) Failing after 1s
lint / syntax-errors (push) Failing after 3s
package / pip-install-on-ubuntu-latest-py-3.13 (push) Has been skipped
package / pip-install-on-windows-latest-py-3.11 (push) Has been skipped
test / ${{ matrix.test_filename }} (push) Has been skipped
test / evaluate-tasks (push) Has been skipped
test / setup-chromium (push) Failing after 2s
test / find_tests (push) Failing after 2s
Test Browser Use CLI Install / uv pip install (windows-latest) (push) Has been cancelled
Test Browser Use CLI Install / uv pip install (macos-latest) (push) Has been cancelled
466 lines
16 KiB
Python
466 lines
16 KiB
Python
"""Tests for search_page and find_elements actions."""
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
from pytest_httpserver import HTTPServer
|
|
|
|
from browser_use.agent.views import ActionResult
|
|
from browser_use.browser import BrowserProfile, BrowserSession
|
|
from browser_use.tools.service import Tools
|
|
|
|
# --- Fixtures ---
|
|
|
|
|
|
@pytest.fixture(scope='session')
|
|
def http_server():
|
|
"""Test HTTP server serving pages for search/find tests."""
|
|
server = HTTPServer()
|
|
server.start()
|
|
|
|
server.expect_request('/products').respond_with_data(
|
|
"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head><title>Products</title></head>
|
|
<body>
|
|
<h1>Product Catalog</h1>
|
|
<div id="main">
|
|
<table class="products">
|
|
<thead>
|
|
<tr><th>Name</th><th>Price</th><th>Rating</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr class="product-row"><td>Widget A</td><td>$29.99</td><td>4.5 stars</td></tr>
|
|
<tr class="product-row"><td>Widget B</td><td>$49.99</td><td>4.2 stars</td></tr>
|
|
<tr class="product-row"><td>Gadget C</td><td>$19.50</td><td>3.8 stars</td></tr>
|
|
<tr class="product-row"><td>Gadget D</td><td>$99.00</td><td>4.9 stars</td></tr>
|
|
</tbody>
|
|
</table>
|
|
<div class="pagination">
|
|
<a href="/products?page=1" class="page-link active">1</a>
|
|
<a href="/products?page=2" class="page-link">2</a>
|
|
<a href="/products?page=3" class="page-link">3</a>
|
|
</div>
|
|
</div>
|
|
<footer id="footer">
|
|
<p>Best price guarantee on all items.</p>
|
|
<p>Contact us at support@example.com</p>
|
|
</footer>
|
|
</body>
|
|
</html>
|
|
""",
|
|
content_type='text/html',
|
|
)
|
|
|
|
server.expect_request('/articles').respond_with_data(
|
|
"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head><title>Articles</title></head>
|
|
<body>
|
|
<article id="post-1">
|
|
<h2>Introduction to Python</h2>
|
|
<p>Python is a versatile programming language used in web development, data science, and automation.</p>
|
|
<a href="/articles/python" class="read-more">Read more</a>
|
|
</article>
|
|
<article id="post-2">
|
|
<h2>JavaScript for Beginners</h2>
|
|
<p>JavaScript powers the interactive web. Learn about DOM manipulation and event handling.</p>
|
|
<a href="/articles/javascript" class="read-more">Read more</a>
|
|
</article>
|
|
<article id="post-3">
|
|
<h2>Advanced CSS Techniques</h2>
|
|
<p>Master CSS Grid, Flexbox, and custom properties for modern web layouts.</p>
|
|
<a href="/articles/css" class="read-more">Read more</a>
|
|
</article>
|
|
</body>
|
|
</html>
|
|
""",
|
|
content_type='text/html',
|
|
)
|
|
|
|
server.expect_request('/empty').respond_with_data(
|
|
"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head><title>Empty</title></head>
|
|
<body>
|
|
<div id="content"></div>
|
|
</body>
|
|
</html>
|
|
""",
|
|
content_type='text/html',
|
|
)
|
|
|
|
server.expect_request('/case-test').respond_with_data(
|
|
"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head><title>Case Test</title></head>
|
|
<body>
|
|
<p>The Quick Brown Fox jumps over the lazy dog.</p>
|
|
<p>QUICK BROWN FOX is an uppercase variant.</p>
|
|
<p>quick brown fox is a lowercase variant.</p>
|
|
</body>
|
|
</html>
|
|
""",
|
|
content_type='text/html',
|
|
)
|
|
|
|
# /images-page route is registered dynamically in base_url fixture once port is known
|
|
yield server
|
|
server.stop()
|
|
|
|
|
|
@pytest.fixture(scope='session')
|
|
def base_url(http_server):
|
|
url = f'http://{http_server.host}:{http_server.port}'
|
|
# Register images page here so we can embed the absolute img src URL
|
|
http_server.expect_request('/images-page').respond_with_data(
|
|
f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head><title>Images Page</title></head>
|
|
<body>
|
|
<img src="{url}/images/product.jpg" alt="Product">
|
|
</body>
|
|
</html>
|
|
""",
|
|
content_type='text/html',
|
|
)
|
|
return url
|
|
|
|
|
|
@pytest.fixture(scope='module')
|
|
async def browser_session():
|
|
session = BrowserSession(
|
|
browser_profile=BrowserProfile(
|
|
headless=True,
|
|
user_data_dir=None,
|
|
keep_alive=True,
|
|
)
|
|
)
|
|
await session.start()
|
|
yield session
|
|
await session.kill()
|
|
|
|
|
|
@pytest.fixture(scope='function')
|
|
def tools():
|
|
return Tools()
|
|
|
|
|
|
# --- Helper ---
|
|
|
|
|
|
async def _navigate_and_wait(tools, browser_session, url):
|
|
"""Navigate to URL and wait for page load."""
|
|
await tools.navigate(url=url, new_tab=False, browser_session=browser_session)
|
|
await asyncio.sleep(0.5)
|
|
|
|
|
|
# --- search_page tests ---
|
|
|
|
|
|
class TestSearchPage:
|
|
"""Tests for the search_page action."""
|
|
|
|
async def test_literal_text_search(self, tools, browser_session, base_url):
|
|
"""Literal text search finds matches with context."""
|
|
await _navigate_and_wait(tools, browser_session, f'{base_url}/products')
|
|
|
|
result = await tools.search_page(pattern='Widget A', browser_session=browser_session)
|
|
|
|
assert isinstance(result, ActionResult)
|
|
assert result.error is None
|
|
assert result.extracted_content is not None
|
|
assert 'Widget A' in result.extracted_content
|
|
assert '1 match' in result.extracted_content
|
|
|
|
async def test_regex_search_prices(self, tools, browser_session, base_url):
|
|
"""Regex search finds all price patterns on the page."""
|
|
await _navigate_and_wait(tools, browser_session, f'{base_url}/products')
|
|
|
|
result = await tools.search_page(pattern=r'\$\d+\.\d{2}', regex=True, browser_session=browser_session)
|
|
|
|
assert isinstance(result, ActionResult)
|
|
assert result.error is None
|
|
assert result.extracted_content is not None
|
|
# Should find $29.99, $49.99, $19.50, $99.00
|
|
assert '4 matches' in result.extracted_content
|
|
assert '$29.99' in result.extracted_content
|
|
assert '$49.99' in result.extracted_content
|
|
|
|
async def test_css_scope_limits_search(self, tools, browser_session, base_url):
|
|
"""css_scope limits search to elements within the selector."""
|
|
await _navigate_and_wait(tools, browser_session, f'{base_url}/products')
|
|
|
|
# Search only in footer
|
|
result = await tools.search_page(pattern='price', css_scope='#footer', browser_session=browser_session)
|
|
|
|
assert isinstance(result, ActionResult)
|
|
assert result.error is None
|
|
assert result.extracted_content is not None
|
|
# "Best price guarantee" is in footer
|
|
assert '1 match' in result.extracted_content
|
|
assert 'guarantee' in result.extracted_content
|
|
|
|
async def test_case_insensitive_default(self, tools, browser_session, base_url):
|
|
"""Search is case-insensitive by default."""
|
|
await _navigate_and_wait(tools, browser_session, f'{base_url}/case-test')
|
|
|
|
result = await tools.search_page(pattern='quick brown fox', browser_session=browser_session)
|
|
|
|
assert isinstance(result, ActionResult)
|
|
assert result.error is None
|
|
assert result.extracted_content is not None
|
|
# Should match all three variants (Quick, QUICK, quick)
|
|
assert '3 matches' in result.extracted_content
|
|
|
|
async def test_case_sensitive(self, tools, browser_session, base_url):
|
|
"""case_sensitive=True restricts to exact case."""
|
|
await _navigate_and_wait(tools, browser_session, f'{base_url}/case-test')
|
|
|
|
result = await tools.search_page(pattern='QUICK BROWN FOX', case_sensitive=True, browser_session=browser_session)
|
|
|
|
assert isinstance(result, ActionResult)
|
|
assert result.error is None
|
|
assert result.extracted_content is not None
|
|
assert '1 match' in result.extracted_content
|
|
|
|
async def test_max_results(self, tools, browser_session, base_url):
|
|
"""max_results limits the number of returned matches."""
|
|
await _navigate_and_wait(tools, browser_session, f'{base_url}/products')
|
|
|
|
result = await tools.search_page(pattern=r'\$\d+\.\d{2}', regex=True, max_results=2, browser_session=browser_session)
|
|
|
|
assert isinstance(result, ActionResult)
|
|
assert result.error is None
|
|
assert result.extracted_content is not None
|
|
# Total should still show 4, but only 2 are displayed
|
|
assert '4 matches' in result.extracted_content
|
|
assert 'Increase max_results' in result.extracted_content
|
|
|
|
async def test_no_matches(self, tools, browser_session, base_url):
|
|
"""No matches returns a clean message, not an error."""
|
|
await _navigate_and_wait(tools, browser_session, f'{base_url}/products')
|
|
|
|
result = await tools.search_page(pattern='xyznonexistent', browser_session=browser_session)
|
|
|
|
assert isinstance(result, ActionResult)
|
|
assert result.error is None
|
|
assert result.extracted_content is not None
|
|
assert 'No matches found' in result.extracted_content
|
|
|
|
async def test_element_path_in_results(self, tools, browser_session, base_url):
|
|
"""Matches include the element path for context."""
|
|
await _navigate_and_wait(tools, browser_session, f'{base_url}/products')
|
|
|
|
result = await tools.search_page(pattern='guarantee', browser_session=browser_session)
|
|
|
|
assert isinstance(result, ActionResult)
|
|
assert result.error is None
|
|
assert result.extracted_content is not None
|
|
# Should show element path containing footer
|
|
assert '(in' in result.extracted_content
|
|
|
|
async def test_invalid_css_scope(self, tools, browser_session, base_url):
|
|
"""Invalid css_scope returns a clear error."""
|
|
await _navigate_and_wait(tools, browser_session, f'{base_url}/products')
|
|
|
|
result = await tools.search_page(pattern='test', css_scope='#nonexistent-scope', browser_session=browser_session)
|
|
|
|
assert isinstance(result, ActionResult)
|
|
assert result.error is not None
|
|
assert 'scope' in result.error.lower() or 'not found' in result.error.lower()
|
|
|
|
async def test_memory_set(self, tools, browser_session, base_url):
|
|
"""long_term_memory is set with match count summary."""
|
|
await _navigate_and_wait(tools, browser_session, f'{base_url}/products')
|
|
|
|
result = await tools.search_page(pattern='Widget', browser_session=browser_session)
|
|
|
|
assert isinstance(result, ActionResult)
|
|
assert result.long_term_memory is not None
|
|
assert 'Widget' in result.long_term_memory
|
|
assert 'match' in result.long_term_memory
|
|
|
|
|
|
# --- find_elements tests ---
|
|
|
|
|
|
class TestFindElements:
|
|
"""Tests for the find_elements action."""
|
|
|
|
async def test_basic_selector(self, tools, browser_session, base_url):
|
|
"""Basic CSS selector returns correct elements."""
|
|
await _navigate_and_wait(tools, browser_session, f'{base_url}/products')
|
|
|
|
result = await tools.find_elements(selector='tr.product-row', browser_session=browser_session)
|
|
|
|
assert isinstance(result, ActionResult)
|
|
assert result.error is None
|
|
assert result.extracted_content is not None
|
|
assert '4 elements' in result.extracted_content
|
|
assert 'Widget A' in result.extracted_content
|
|
assert 'Gadget D' in result.extracted_content
|
|
|
|
async def test_attribute_extraction(self, tools, browser_session, base_url):
|
|
"""attributes parameter extracts specific attributes from elements."""
|
|
await _navigate_and_wait(tools, browser_session, f'{base_url}/products')
|
|
|
|
result = await tools.find_elements(
|
|
selector='a.page-link',
|
|
attributes=['href', 'class'],
|
|
browser_session=browser_session,
|
|
)
|
|
|
|
assert isinstance(result, ActionResult)
|
|
assert result.error is None
|
|
assert result.extracted_content is not None
|
|
assert '3 elements' in result.extracted_content
|
|
assert 'href=' in result.extracted_content
|
|
assert '/products?page=' in result.extracted_content
|
|
|
|
async def test_max_results_limiting(self, tools, browser_session, base_url):
|
|
"""max_results limits displayed elements while showing total count."""
|
|
await _navigate_and_wait(tools, browser_session, f'{base_url}/products')
|
|
|
|
result = await tools.find_elements(selector='tr.product-row', max_results=2, browser_session=browser_session)
|
|
|
|
assert isinstance(result, ActionResult)
|
|
assert result.error is None
|
|
assert result.extracted_content is not None
|
|
assert '4 elements' in result.extracted_content
|
|
assert 'Showing 2 of 4' in result.extracted_content
|
|
|
|
async def test_no_matching_elements(self, tools, browser_session, base_url):
|
|
"""No matches returns a clean message, not an error."""
|
|
await _navigate_and_wait(tools, browser_session, f'{base_url}/products')
|
|
|
|
result = await tools.find_elements(selector='div.nonexistent', browser_session=browser_session)
|
|
|
|
assert isinstance(result, ActionResult)
|
|
assert result.error is None
|
|
assert result.extracted_content is not None
|
|
assert 'No elements found' in result.extracted_content
|
|
|
|
async def test_invalid_selector(self, tools, browser_session, base_url):
|
|
"""Invalid CSS selector returns a clear error, not a crash."""
|
|
await _navigate_and_wait(tools, browser_session, f'{base_url}/products')
|
|
|
|
result = await tools.find_elements(selector='[[[invalid', browser_session=browser_session)
|
|
|
|
assert isinstance(result, ActionResult)
|
|
assert result.error is not None
|
|
assert 'selector' in result.error.lower() or 'invalid' in result.error.lower()
|
|
|
|
async def test_include_text_false(self, tools, browser_session, base_url):
|
|
"""include_text=False omits text content from results."""
|
|
await _navigate_and_wait(tools, browser_session, f'{base_url}/articles')
|
|
|
|
result = await tools.find_elements(selector='article', include_text=False, browser_session=browser_session)
|
|
|
|
assert isinstance(result, ActionResult)
|
|
assert result.error is None
|
|
assert result.extracted_content is not None
|
|
assert '3 elements' in result.extracted_content
|
|
# Text content should not appear (no article body text)
|
|
# But the tag and children count should still be present
|
|
assert '<article>' in result.extracted_content
|
|
|
|
async def test_nested_selectors(self, tools, browser_session, base_url):
|
|
"""Nested CSS selectors (child combinator) work correctly."""
|
|
await _navigate_and_wait(tools, browser_session, f'{base_url}/articles')
|
|
|
|
result = await tools.find_elements(
|
|
selector='article a.read-more',
|
|
attributes=['href'],
|
|
browser_session=browser_session,
|
|
)
|
|
|
|
assert isinstance(result, ActionResult)
|
|
assert result.error is None
|
|
assert result.extracted_content is not None
|
|
assert '3 elements' in result.extracted_content
|
|
assert '/articles/python' in result.extracted_content
|
|
assert '/articles/javascript' in result.extracted_content
|
|
assert '/articles/css' in result.extracted_content
|
|
|
|
async def test_children_count(self, tools, browser_session, base_url):
|
|
"""Elements show children count."""
|
|
await _navigate_and_wait(tools, browser_session, f'{base_url}/products')
|
|
|
|
result = await tools.find_elements(selector='table.products thead tr', browser_session=browser_session)
|
|
|
|
assert isinstance(result, ActionResult)
|
|
assert result.error is None
|
|
assert result.extracted_content is not None
|
|
assert '1 element' in result.extracted_content
|
|
# The header row has 3 <th> children
|
|
assert '3 children' in result.extracted_content
|
|
|
|
async def test_memory_set(self, tools, browser_session, base_url):
|
|
"""long_term_memory is set with element count summary."""
|
|
await _navigate_and_wait(tools, browser_session, f'{base_url}/products')
|
|
|
|
result = await tools.find_elements(selector='tr.product-row', browser_session=browser_session)
|
|
|
|
assert isinstance(result, ActionResult)
|
|
assert result.long_term_memory is not None
|
|
assert '4 element' in result.long_term_memory
|
|
|
|
async def test_empty_page(self, tools, browser_session, base_url):
|
|
"""Works on a nearly empty page without errors."""
|
|
await _navigate_and_wait(tools, browser_session, f'{base_url}/empty')
|
|
|
|
result = await tools.find_elements(selector='p', browser_session=browser_session)
|
|
|
|
assert isinstance(result, ActionResult)
|
|
assert result.error is None
|
|
assert result.extracted_content is not None
|
|
assert 'No elements found' in result.extracted_content
|
|
|
|
async def test_img_src_attribute_resolved(self, tools, browser_session, base_url):
|
|
"""find_elements with attributes=['src'] returns absolute URLs for img elements."""
|
|
await _navigate_and_wait(tools, browser_session, f'{base_url}/images-page')
|
|
|
|
result = await tools.find_elements(
|
|
selector='img',
|
|
attributes=['src'],
|
|
browser_session=browser_session,
|
|
)
|
|
|
|
assert isinstance(result, ActionResult)
|
|
assert result.error is None
|
|
assert result.extracted_content is not None
|
|
assert '1 element' in result.extracted_content
|
|
assert 'src=' in result.extracted_content
|
|
# The resolved DOM property should give the absolute URL (including the httpserver base URL)
|
|
assert base_url in result.extracted_content
|
|
assert 'product.jpg' in result.extracted_content
|
|
|
|
|
|
# --- Registration tests ---
|
|
|
|
|
|
class TestRegistration:
|
|
"""Test that new actions are properly registered."""
|
|
|
|
async def test_search_page_registered(self, tools):
|
|
"""search_page is in the default action registry."""
|
|
assert 'search_page' in tools.registry.registry.actions
|
|
|
|
async def test_find_elements_registered(self, tools):
|
|
"""find_elements is in the default action registry."""
|
|
assert 'find_elements' in tools.registry.registry.actions
|
|
|
|
async def test_excluded_actions(self):
|
|
"""New actions can be excluded via exclude_actions."""
|
|
excluded_tools = Tools(exclude_actions=['search_page', 'find_elements'])
|
|
assert 'search_page' not in excluded_tools.registry.registry.actions
|
|
assert 'find_elements' not in excluded_tools.registry.registry.actions
|
|
# Other actions still present
|
|
assert 'navigate' in excluded_tools.registry.registry.actions
|