SERPent / tests /test_serp_navigation.py
Claude
Claude Opus 5
Move orchestration out of the route handlers into a service layer
e44fdef unverified
Raw History Blame Contribute Delete
5.13 kB
"""The glue holding each scraper together.
Every Playwright scraper is now: build a URL, open a page, block
decorative resources, navigate, extract. The builders are tested in
test_serp_urls.py and the extractors in test_serp_playwright.py; this
covers the composition - specifically that each scraper navigates to the
URL its own builder produced.
That link is the one the old goto-patching harness could not test, because
the stand-in it installed accepted the navigation URL and discarded it.
These use a recording stand-in browser instead: no real Chromium, and the
URL is the thing being asserted on.
"""
import pytest
import serp
from serp import (bing_search_url, brave_search_url, google_patents_search_url,
google_scholar_url, playwright_open_page,
query_bing_search, query_brave_search, query_google_patents,
query_google_scholar)
SENTINEL = [{"title": "extracted"}]
class _RecordingPage:
def __init__(self):
self.goto_urls = []
self.route_patterns = []
self.closed = False
async def route(self, pattern, handler):
self.route_patterns.append(pattern)
async def goto(self, url, **kwargs):
self.goto_urls.append(url)
return None
async def close(self):
self.closed = True
class _RecordingContext:
def __init__(self, page):
self._page = page
self.closed = False
async def new_page(self):
return self._page
async def close(self):
self.closed = True
class _RecordingBrowser:
def __init__(self):
self.page = _RecordingPage()
self.context = _RecordingContext(self.page)
async def new_context(self, **kwargs):
return self.context
async def _sentinel_extractor(page, n_results):
return SENTINEL
SCRAPERS = [
("query_google_scholar", query_google_scholar,
"_extract_google_scholar_results", google_scholar_url),
("query_google_patents", query_google_patents,
"_extract_google_patents_results", google_patents_search_url),
("query_brave_search", query_brave_search,
"_extract_brave_results", brave_search_url),
("query_bing_search", query_bing_search,
"_extract_bing_results", bing_search_url),
]
@pytest.mark.parametrize("name, scraper, extractor_attr, builder",
SCRAPERS, ids=[s[0] for s in SCRAPERS])
async def test_scraper_navigates_to_the_url_its_builder_produced(
monkeypatch, name, scraper, extractor_attr, builder):
monkeypatch.setattr(serp, extractor_attr, _sentinel_extractor)
browser = _RecordingBrowser()
await scraper(browser, "agentic ai", 25)
assert browser.page.goto_urls == [builder("agentic ai", 25)]
@pytest.mark.parametrize("name, scraper, extractor_attr, builder",
SCRAPERS, ids=[s[0] for s in SCRAPERS])
async def test_scraper_returns_what_its_extractor_produced(
monkeypatch, name, scraper, extractor_attr, builder):
monkeypatch.setattr(serp, extractor_attr, _sentinel_extractor)
results = await scraper(_RecordingBrowser(), "widgets", 10)
assert results == SENTINEL
@pytest.mark.parametrize("name, scraper, extractor_attr, builder",
SCRAPERS, ids=[s[0] for s in SCRAPERS])
async def test_scraper_blocks_decorative_resources(
monkeypatch, name, scraper, extractor_attr, builder):
"""Skipping stylesheets and images meaningfully speeds up navigation,
and every scraper is supposed to do it."""
monkeypatch.setattr(serp, extractor_attr, _sentinel_extractor)
browser = _RecordingBrowser()
await scraper(browser, "widgets", 10)
assert browser.page.route_patterns == ["**/*"]
@pytest.mark.parametrize("name, scraper, extractor_attr, builder",
SCRAPERS, ids=[s[0] for s in SCRAPERS])
async def test_scraper_closes_its_page_and_context(
monkeypatch, name, scraper, extractor_attr, builder):
monkeypatch.setattr(serp, extractor_attr, _sentinel_extractor)
browser = _RecordingBrowser()
await scraper(browser, "widgets", 10)
assert browser.page.closed and browser.context.closed
async def test_page_and_context_are_closed_even_when_extraction_raises(monkeypatch):
async def exploding_extractor(page, n_results):
raise RuntimeError("selectors changed")
monkeypatch.setattr(serp, "_extract_bing_results", exploding_extractor)
browser = _RecordingBrowser()
with pytest.raises(RuntimeError):
await query_bing_search(browser, "widgets", 10)
assert browser.page.closed and browser.context.closed
async def test_context_is_closed_even_when_closing_the_page_raises():
"""A crashed renderer can make page.close() throw; the context still has
to be released or it leaks for the process's lifetime."""
browser = _RecordingBrowser()
async def exploding_close():
raise RuntimeError("renderer gone")
browser.page.close = exploding_close
with pytest.raises(RuntimeError):
async with playwright_open_page(browser):
pass
assert browser.context.closed