Claude Claude Opus 5 commited on
Commit
232eb80
·
unverified ·
1 Parent(s): 2e4089d

Test the search URLs, and delete the harness that hid them

Browse files

The Playwright tests drove a real browser through a wrapper that replaced
`page.goto` with a stand-in accepting the URL and discarding it. So every
scraper's navigation target was untested, and invisibly so: mutation
testing showed that replacing the Bing URL with a constant, dropping
`&num=` from the Scholar URL, and making the Google Patents URL ignore the
query all left the full suite green. A scraper searching for the wrong
thing entirely passed.

Finish the fetch/parse split that scrap.py and _extract_bing_results had
already started:

- Each scraper's URL construction becomes a named pure builder
(bing_search_url and friends), asserted directly in tests/test_serp_urls.py
- query present, query encoded, result count honoured, expected host.
22 tests, no browser, 0.11s. All four URL mutants now fail the suite.
- The extraction tests take an already-loaded page via a `page_factory`
fixture using `set_content`, exactly as the bing test already did. This
removes the need for the goto-patching Browser and Context wrappers, so
they are deleted (~45 lines of test infrastructure).
- Result-limit coverage added for all four extractors, and the shared
resource-blocking route handler is now tested - blocking the main
document instead of just stylesheets and images would break every
Playwright scrape.

Also cover the /serp/search aggregation, which had no endpoint test:
merging across queries, partial failure reported alongside results,
all-fail, and per-query fallthrough. Discarding every per-query error now
fails the suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSNSYnceqvdVz7Csis4K9e

serp.py CHANGED
@@ -124,13 +124,21 @@ async def _extract_google_scholar_results(page: Page, n_results: int) -> list[di
124
  return results
125
 
126
 
 
 
 
 
 
 
 
 
 
127
  async def query_google_scholar(browser: Browser, q: str, n_results: int = 10):
128
  """Queries google scholar for the specified query and number of results. Returns relevant papers"""
129
  async with playwright_open_page(browser) as page:
130
  await page.route("**/*", _block_stylesheet_and_image_resources)
131
 
132
- url = f"https://scholar.google.com/scholar?q={quote_plus(q)}&num={n_results}"
133
- await page.goto(url)
134
 
135
  return await _extract_google_scholar_results(page, n_results)
136
 
@@ -180,13 +188,17 @@ async def _extract_google_patents_results(page: Page, n_results: int) -> list[di
180
  return results[:n_results]
181
 
182
 
 
 
 
 
 
183
  async def query_google_patents(browser: Browser, q: str, n_results: int = 10):
184
  """Queries google patents for the specified query and number of results. Returns relevant patents"""
185
  async with playwright_open_page(browser) as page:
186
  await page.route("**/*", _block_stylesheet_and_image_resources)
187
 
188
- url = f"https://patents.google.com/?q={quote_plus(q)}&num={n_results}"
189
- await page.goto(url)
190
 
191
  return await _extract_google_patents_results(page, n_results)
192
 
@@ -228,13 +240,22 @@ async def _extract_brave_results(page: Page, n_results: int) -> list[dict]:
228
  return results
229
 
230
 
 
 
 
 
 
 
 
 
 
 
231
  async def query_brave_search(browser: Browser, q: str, n_results: int = 10):
232
  """Queries Brave Search for the specified query."""
233
  async with playwright_open_page(browser) as page:
234
  await page.route("**/*", _block_stylesheet_and_image_resources)
235
 
236
- url = f"https://search.brave.com/search?q={quote_plus(q)}"
237
- await page.goto(url)
238
 
239
  return await _extract_brave_results(page, n_results)
240
 
@@ -277,13 +298,22 @@ async def _extract_bing_results(page: Page, n_results: int) -> list[dict]:
277
  return results
278
 
279
 
 
 
 
 
 
 
 
 
 
 
280
  async def query_bing_search(browser: Browser, q: str, n_results: int = 10):
281
  """Queries bing search for the specified query"""
282
  async with playwright_open_page(browser) as page:
283
  await page.route("**/*", _block_stylesheet_and_image_resources)
284
 
285
- url = f"https://www.bing.com/search?q={quote_plus(q)}"
286
- await page.goto(url)
287
 
288
  return await _extract_bing_results(page, n_results)
289
 
 
124
  return results
125
 
126
 
127
+ def google_scholar_url(q: str, n_results: int) -> str:
128
+ """The Google Scholar results URL for a query.
129
+
130
+ Split out from the scraper so it can be asserted on directly: the
131
+ Playwright tests replace `page.goto` and never see the URL.
132
+ """
133
+ return f"https://scholar.google.com/scholar?q={quote_plus(q)}&num={n_results}"
134
+
135
+
136
  async def query_google_scholar(browser: Browser, q: str, n_results: int = 10):
137
  """Queries google scholar for the specified query and number of results. Returns relevant papers"""
138
  async with playwright_open_page(browser) as page:
139
  await page.route("**/*", _block_stylesheet_and_image_resources)
140
 
141
+ await page.goto(google_scholar_url(q, n_results))
 
142
 
143
  return await _extract_google_scholar_results(page, n_results)
144
 
 
188
  return results[:n_results]
189
 
190
 
191
+ def google_patents_search_url(q: str, n_results: int) -> str:
192
+ """The Google Patents search URL for a query."""
193
+ return f"https://patents.google.com/?q={quote_plus(q)}&num={n_results}"
194
+
195
+
196
  async def query_google_patents(browser: Browser, q: str, n_results: int = 10):
197
  """Queries google patents for the specified query and number of results. Returns relevant patents"""
198
  async with playwright_open_page(browser) as page:
199
  await page.route("**/*", _block_stylesheet_and_image_resources)
200
 
201
+ await page.goto(google_patents_search_url(q, n_results))
 
202
 
203
  return await _extract_google_patents_results(page, n_results)
204
 
 
240
  return results
241
 
242
 
243
+ def brave_search_url(q: str, n_results: int = 10) -> str:
244
+ """The Brave Search results URL for a query.
245
+
246
+ Brave paginates rather than taking a result count, so `n_results` is
247
+ applied while extracting rather than in the URL; it is accepted here to
248
+ keep the builders interchangeable.
249
+ """
250
+ return f"https://search.brave.com/search?q={quote_plus(q)}"
251
+
252
+
253
  async def query_brave_search(browser: Browser, q: str, n_results: int = 10):
254
  """Queries Brave Search for the specified query."""
255
  async with playwright_open_page(browser) as page:
256
  await page.route("**/*", _block_stylesheet_and_image_resources)
257
 
258
+ await page.goto(brave_search_url(q, n_results))
 
259
 
260
  return await _extract_brave_results(page, n_results)
261
 
 
298
  return results
299
 
300
 
301
+ def bing_search_url(q: str, n_results: int = 10) -> str:
302
+ """The Bing results URL for a query.
303
+
304
+ Bing paginates rather than taking a result count, so `n_results` is
305
+ applied while extracting rather than in the URL; it is accepted here to
306
+ keep the builders interchangeable.
307
+ """
308
+ return f"https://www.bing.com/search?q={quote_plus(q)}"
309
+
310
+
311
  async def query_bing_search(browser: Browser, q: str, n_results: int = 10):
312
  """Queries bing search for the specified query"""
313
  async with playwright_open_page(browser) as page:
314
  await page.route("**/*", _block_stylesheet_and_image_resources)
315
 
316
+ await page.goto(bing_search_url(q, n_results))
 
317
 
318
  return await _extract_bing_results(page, n_results)
319
 
tests/conftest.py CHANGED
@@ -59,3 +59,29 @@ async def browser():
59
  yield b
60
  await b.close()
61
  await pw.stop()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  yield b
60
  await b.close()
61
  await pw.stop()
62
+
63
+
64
+ @pytest_asyncio.fixture
65
+ async def page_factory(browser):
66
+ """Returns a callable producing a real Page with fixture HTML loaded.
67
+
68
+ This is the whole seam the Playwright-driven tests need now that every
69
+ scraper is split into a URL builder, a navigation step and an
70
+ `_extract_*` function: the extractors take an already-loaded page, so
71
+ `set_content` reaches them directly. It replaces the goto-patching
72
+ Browser wrapper that used to be required, which had to accept the
73
+ navigation URL and throw it away - and so hid every URL bug.
74
+ """
75
+ contexts = []
76
+
77
+ async def make(html: str):
78
+ context = await browser.new_context()
79
+ contexts.append(context)
80
+ page = await context.new_page()
81
+ await page.set_content(html)
82
+ return page
83
+
84
+ yield make
85
+
86
+ for context in contexts:
87
+ await context.close()
tests/helpers.py CHANGED
@@ -1,7 +1,4 @@
1
- """Shared test helpers: fixture loading and a fake-navigation Playwright
2
- Browser wrapper used to characterize the scraper functions in `serp.py`
3
- without hitting live search engines.
4
- """
5
 
6
  from pathlib import Path
7
 
@@ -11,63 +8,3 @@ FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures"
11
  def load_fixture(*parts: str) -> str:
12
  """Read a text fixture from tests/fixtures/<parts...>."""
13
  return FIXTURES_DIR.joinpath(*parts).read_text(encoding="utf-8")
14
-
15
-
16
- class _GotoPatchingContext:
17
- """Wraps a real Playwright BrowserContext so every Page it creates loads
18
- fixture HTML via `set_content()` instead of navigating over the network.
19
- """
20
-
21
- def __init__(self, real_context, html: str):
22
- self._real_context = real_context
23
- self._html = html
24
-
25
- async def new_page(self):
26
- page = await self._real_context.new_page()
27
- html = self._html
28
-
29
- async def fake_goto(url, **kwargs):
30
- # The scraper functions don't use the response object `goto`
31
- # normally returns, so a plain None is a safe stand-in.
32
- await page.set_content(html)
33
- return None
34
-
35
- page.goto = fake_goto
36
- return page
37
-
38
- async def close(self):
39
- await self._real_context.close()
40
-
41
-
42
- class _GotoPatchingBrowser:
43
- """Wraps a real Playwright Browser the same way, so it can be passed
44
- directly to `serp.py` functions in place of the real `pw_browser`.
45
- """
46
-
47
- def __init__(self, real_browser, html: str):
48
- self._real_browser = real_browser
49
- self._html = html
50
-
51
- async def new_context(self, **kwargs):
52
- real_context = await self._real_browser.new_context(**kwargs)
53
- return _GotoPatchingContext(real_context, self._html)
54
-
55
-
56
- def make_fixture_browser(real_browser, html: str) -> _GotoPatchingBrowser:
57
- """Build a Browser-like object for characterizing `serp.py`'s scraper
58
- functions against fixture HTML.
59
-
60
- Why this exists: each scraper (e.g. `query_bing_search`) registers its
61
- own catch-all `page.route("**/*", ...)` resource blocker *inside* the
62
- function, and that handler calls `route.continue_()` (not
63
- `route.fallback()`) for the main document request. Playwright invokes
64
- the most-recently-registered matching route first, so any route we
65
- register on the page *before* calling the function never runs -
66
- verified empirically, it's not a documented guarantee either way.
67
- Patching `page.goto` itself, on a page created through this wrapper, is
68
- the only reliable seam into these functions without touching their
69
- source (see the code review this test suite followed from: the
70
- fetch/navigate and parse logic aren't separated, which is exactly what
71
- makes this workaround necessary).
72
- """
73
- return _GotoPatchingBrowser(real_browser, html)
 
1
+ """Shared test helpers: fixture loading."""
 
 
 
2
 
3
  from pathlib import Path
4
 
 
8
  def load_fixture(*parts: str) -> str:
9
  """Read a text fixture from tests/fixtures/<parts...>."""
10
  return FIXTURES_DIR.joinpath(*parts).read_text(encoding="utf-8")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_app.py CHANGED
@@ -342,3 +342,80 @@ async def test_search_one_skips_a_backend_whose_circuit_is_open(monkeypatch):
342
  q, results, err = await app_module._search_one("second query", 5)
343
  assert ddg_calls == ["first query"] # unchanged - DDG was skipped
344
  assert results == [{"title": "from brave"}]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  q, results, err = await app_module._search_one("second query", 5)
343
  assert ddg_calls == ["first query"] # unchanged - DDG was skipped
344
  assert results == [{"title": "from brave"}]
345
+
346
+
347
+ # ------------------------------- /serp/search endpoint -------------------------------
348
+ # `_search_one` is covered above; this is the aggregation wrapped around it,
349
+ # which had no coverage - dropping every per-query error left the suite green.
350
+
351
+
352
+ async def test_search_endpoint_merges_results_from_every_query(client, monkeypatch):
353
+ async def fake_ddg(q, n):
354
+ return [{"title": f"result for {q}"}]
355
+
356
+ monkeypatch.setattr(app_module, "query_ddg_search", fake_ddg)
357
+
358
+ resp = await client.post("/serp/search", json={"queries": ["a", "b"], "n_results": 5})
359
+
360
+ data = resp.json()
361
+ assert {r["title"] for r in data["results"]} == {"result for a", "result for b"}
362
+ assert data["error"] is None
363
+
364
+
365
+ async def test_search_endpoint_reports_partial_failure_alongside_results(client, monkeypatch):
366
+ """A query that no backend could answer must be reported even when
367
+ another query succeeded - otherwise the caller silently receives fewer
368
+ results than they asked for with no indication why.
369
+ """
370
+ async def selective_ddg(q, n):
371
+ if q == "bad":
372
+ raise RuntimeError("blocked")
373
+ return [{"title": f"result for {q}"}]
374
+
375
+ async def failing(*args):
376
+ raise RuntimeError("blocked")
377
+
378
+ monkeypatch.setattr(app_module, "query_ddg_search", selective_ddg)
379
+ monkeypatch.setattr(app_module, "query_brave_search", failing)
380
+ monkeypatch.setattr(app_module, "query_bing_search", failing)
381
+
382
+ resp = await client.post("/serp/search", json={"queries": ["good", "bad"], "n_results": 5})
383
+
384
+ data = resp.json()
385
+ assert [r["title"] for r in data["results"]] == ["result for good"]
386
+ assert "bad" in data["error"]
387
+
388
+
389
+ async def test_search_endpoint_reports_an_error_when_everything_fails(client, monkeypatch):
390
+ async def failing(*args):
391
+ raise RuntimeError("blocked")
392
+
393
+ monkeypatch.setattr(app_module, "query_ddg_search", failing)
394
+ monkeypatch.setattr(app_module, "query_brave_search", failing)
395
+ monkeypatch.setattr(app_module, "query_bing_search", failing)
396
+
397
+ resp = await client.post("/serp/search", json={"queries": ["a", "b"], "n_results": 5})
398
+
399
+ data = resp.json()
400
+ assert data["results"] == []
401
+ # Both failed queries are named, not just the last one.
402
+ assert "'a'" in data["error"] and "'b'" in data["error"]
403
+
404
+
405
+ async def test_search_endpoint_falls_through_backends_per_query(client, monkeypatch):
406
+ """The fallback chain is per query, so one blocked backend shouldn't
407
+ cost results for queries the next backend can answer."""
408
+ async def failing_ddg(q, n):
409
+ raise RuntimeError("ddg blocked")
410
+
411
+ async def fake_brave(browser, q, n):
412
+ return [{"title": f"brave has {q}"}]
413
+
414
+ monkeypatch.setattr(app_module, "query_ddg_search", failing_ddg)
415
+ monkeypatch.setattr(app_module, "query_brave_search", fake_brave)
416
+
417
+ resp = await client.post("/serp/search", json={"queries": ["a"], "n_results": 5})
418
+
419
+ data = resp.json()
420
+ assert data["results"] == [{"title": "brave has a"}]
421
+ assert data["error"] is None
tests/test_serp_playwright.py CHANGED
@@ -1,36 +1,36 @@
1
- """Characterization tests for the Playwright-driven scrapers in serp.py.
2
-
3
- These run against a real headless Chromium (same engine production uses) so
4
- that DOM/CSS-selector behavior is genuinely exercised, but the target page is
5
- served locally from a fixture instead of navigating to the live site - see
6
- `helpers.make_fixture_browser` for how, and why a simpler
7
- `page.route()`-based approach doesn't work here.
 
 
 
 
 
 
 
8
  """
9
 
10
  import pytest
11
 
12
- from helpers import load_fixture, make_fixture_browser
13
  from serp import (
14
  BraveSearchBlockedException,
15
  BrowserUnavailableError,
 
16
  _extract_bing_results,
 
 
 
17
  playwright_open_page,
18
- query_bing_search,
19
- query_brave_search,
20
- query_google_patents,
21
- query_google_scholar,
22
  )
23
 
24
 
25
- async def test_extract_bing_results_works_on_an_already_loaded_page(browser):
26
- """Demonstrates the payoff of splitting fetch (navigate) from parse
27
- (extract): `_extract_bing_results` takes a Page that's already loaded,
28
- so this needs nothing beyond `set_content` - no goto-patching wrapper,
29
- no browser-as-a-parameter indirection.
30
- """
31
- context = await browser.new_context()
32
- page = await context.new_page()
33
- await page.set_content(load_fixture("bing_results.html"))
34
 
35
  results = await _extract_bing_results(page, 10)
36
 
@@ -39,24 +39,19 @@ async def test_extract_bing_results_works_on_an_already_loaded_page(browser):
39
  {"title": "Result B", "href": "https://example.org/b", "body": "Snippet B"},
40
  ]
41
 
42
- await context.close()
43
 
 
 
44
 
45
- async def test_bing_search_parses_results(browser):
46
- fixture_browser = make_fixture_browser(browser, load_fixture("bing_results.html"))
47
 
48
- results = await query_bing_search(fixture_browser, "test query", 10)
49
-
50
- assert results == [
51
- {"title": "Result A", "href": "https://example.org/a", "body": "Snippet A"},
52
- {"title": "Result B", "href": "https://example.org/b", "body": "Snippet B"},
53
- ]
54
 
55
 
56
- async def test_brave_search_parses_results(browser):
57
- fixture_browser = make_fixture_browser(browser, load_fixture("brave_results.html"))
58
 
59
- results = await query_brave_search(fixture_browser, "test query", 10)
60
 
61
  assert results == [
62
  {"title": "Brave Result A", "body": "Brave snippet A", "href": "https://example.org/brave-a"},
@@ -64,17 +59,30 @@ async def test_brave_search_parses_results(browser):
64
  ]
65
 
66
 
67
- async def test_brave_search_detects_the_suspicious_block_page(browser):
68
- fixture_browser = make_fixture_browser(browser, load_fixture("brave_blocked.html"))
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
  with pytest.raises(BraveSearchBlockedException):
71
- await query_brave_search(fixture_browser, "test query", 10)
72
 
73
 
74
- async def test_google_scholar_parses_results(browser):
75
- fixture_browser = make_fixture_browser(browser, load_fixture("scholar_results.html"))
76
 
77
- results = await query_google_scholar(fixture_browser, "test query", 10)
78
 
79
  assert results == [
80
  {"title": "Scholar Paper A", "body": "Scholar snippet A", "href": "https://example.org/scholar-a"},
@@ -82,10 +90,18 @@ async def test_google_scholar_parses_results(browser):
82
  ]
83
 
84
 
85
- async def test_google_patents_parses_results(browser):
86
- fixture_browser = make_fixture_browser(browser, load_fixture("patents", "search_results.html"))
 
 
 
 
87
 
88
- results = await query_google_patents(fixture_browser, "test query", 10)
 
 
 
 
89
 
90
  assert results == [
91
  {
@@ -103,7 +119,57 @@ async def test_google_patents_parses_results(browser):
103
  ]
104
 
105
 
 
 
 
 
 
 
 
 
 
 
 
106
  async def test_playwright_open_page_raises_when_browser_unavailable():
107
  with pytest.raises(BrowserUnavailableError):
108
  async with playwright_open_page(None):
109
  pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the DOM-extraction half of the Playwright-driven scrapers.
2
+
3
+ Each scraper in serp.py is three separable pieces: a URL builder (pure,
4
+ tested in test_serp_urls.py), a navigation step, and an `_extract_*`
5
+ function that reads an already-loaded page. These test the third piece
6
+ against a real headless Chromium - the same engine production uses, so
7
+ selector behaviour is genuinely exercised - with the fixture HTML loaded
8
+ via `set_content`.
9
+
10
+ This used to require a wrapper that replaced `page.goto` on a
11
+ purpose-built fake Browser, because fetch and parse weren't separated.
12
+ That wrapper accepted the navigation URL and discarded it, which is
13
+ precisely why URL bugs were invisible to the suite; splitting the scrapers
14
+ made it unnecessary and it has been deleted.
15
  """
16
 
17
  import pytest
18
 
19
+ from helpers import load_fixture
20
  from serp import (
21
  BraveSearchBlockedException,
22
  BrowserUnavailableError,
23
+ _block_stylesheet_and_image_resources,
24
  _extract_bing_results,
25
+ _extract_brave_results,
26
+ _extract_google_patents_results,
27
+ _extract_google_scholar_results,
28
  playwright_open_page,
 
 
 
 
29
  )
30
 
31
 
32
+ async def test_bing_results_are_extracted(page_factory):
33
+ page = await page_factory(load_fixture("bing_results.html"))
 
 
 
 
 
 
 
34
 
35
  results = await _extract_bing_results(page, 10)
36
 
 
39
  {"title": "Result B", "href": "https://example.org/b", "body": "Snippet B"},
40
  ]
41
 
 
42
 
43
+ async def test_bing_extraction_honours_the_result_limit(page_factory):
44
+ page = await page_factory(load_fixture("bing_results.html"))
45
 
46
+ results = await _extract_bing_results(page, 1)
 
47
 
48
+ assert [r["title"] for r in results] == ["Result A"]
 
 
 
 
 
49
 
50
 
51
+ async def test_brave_results_are_extracted(page_factory):
52
+ page = await page_factory(load_fixture("brave_results.html"))
53
 
54
+ results = await _extract_brave_results(page, 10)
55
 
56
  assert results == [
57
  {"title": "Brave Result A", "body": "Brave snippet A", "href": "https://example.org/brave-a"},
 
59
  ]
60
 
61
 
62
+ async def test_brave_extraction_honours_the_result_limit(page_factory):
63
+ page = await page_factory(load_fixture("brave_results.html"))
64
+
65
+ results = await _extract_brave_results(page, 1)
66
+
67
+ assert [r["title"] for r in results] == ["Brave Result A"]
68
+
69
+
70
+ async def test_brave_block_page_raises(page_factory):
71
+ """Brave's anti-bot interstitial has no result cards, so it would
72
+ otherwise look like a legitimate zero-result search - and the
73
+ `/serp/search` fallback chain needs an exception to move to the next
74
+ backend.
75
+ """
76
+ page = await page_factory(load_fixture("brave_blocked.html"))
77
 
78
  with pytest.raises(BraveSearchBlockedException):
79
+ await _extract_brave_results(page, 10)
80
 
81
 
82
+ async def test_google_scholar_results_are_extracted(page_factory):
83
+ page = await page_factory(load_fixture("scholar_results.html"))
84
 
85
+ results = await _extract_google_scholar_results(page, 10)
86
 
87
  assert results == [
88
  {"title": "Scholar Paper A", "body": "Scholar snippet A", "href": "https://example.org/scholar-a"},
 
90
  ]
91
 
92
 
93
+ async def test_google_scholar_extraction_honours_the_result_limit(page_factory):
94
+ page = await page_factory(load_fixture("scholar_results.html"))
95
+
96
+ results = await _extract_google_scholar_results(page, 1)
97
+
98
+ assert [r["title"] for r in results] == ["Scholar Paper A"]
99
 
100
+
101
+ async def test_google_patents_results_are_extracted(page_factory):
102
+ page = await page_factory(load_fixture("patents", "search_results.html"))
103
+
104
+ results = await _extract_google_patents_results(page, 10)
105
 
106
  assert results == [
107
  {
 
119
  ]
120
 
121
 
122
+ async def test_google_patents_extraction_honours_the_result_limit(page_factory):
123
+ page = await page_factory(load_fixture("patents", "search_results.html"))
124
+
125
+ results = await _extract_google_patents_results(page, 1)
126
+
127
+ assert [r["id"] for r in results] == ["US11930446B2"]
128
+
129
+
130
+ # --------------------------------- shared plumbing ---------------------------------
131
+
132
+
133
  async def test_playwright_open_page_raises_when_browser_unavailable():
134
  with pytest.raises(BrowserUnavailableError):
135
  async with playwright_open_page(None):
136
  pass
137
+
138
+
139
+ class _FakeRoute:
140
+ def __init__(self):
141
+ self.aborted = False
142
+ self.continued = False
143
+
144
+ async def abort(self):
145
+ self.aborted = True
146
+
147
+ async def continue_(self):
148
+ self.continued = True
149
+
150
+
151
+ class _FakeRequest:
152
+ def __init__(self, resource_type):
153
+ self.resource_type = resource_type
154
+
155
+
156
+ @pytest.mark.parametrize("resource_type", ["stylesheet", "image"])
157
+ async def test_decorative_resources_are_blocked(resource_type):
158
+ """Only page text matters to these scrapers, and skipping stylesheets
159
+ and images meaningfully speeds up navigation."""
160
+ route = _FakeRoute()
161
+
162
+ await _block_stylesheet_and_image_resources(route, _FakeRequest(resource_type))
163
+
164
+ assert route.aborted and not route.continued
165
+
166
+
167
+ @pytest.mark.parametrize("resource_type", ["document", "script", "xhr", "fetch"])
168
+ async def test_everything_else_is_allowed_through(resource_type):
169
+ """Blocking the main document (or the XHR that renders results) would
170
+ break the scrape entirely."""
171
+ route = _FakeRoute()
172
+
173
+ await _block_stylesheet_and_image_resources(route, _FakeRequest(resource_type))
174
+
175
+ assert route.continued and not route.aborted
tests/test_serp_urls.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The search URLs each scraper navigates to.
2
+
3
+ These had no coverage at all, and the gap was invisible: the Playwright
4
+ tests drive a real browser but patch `page.goto` with a stand-in that
5
+ accepts the URL and discards it, so everything downstream of navigation
6
+ was verified and everything upstream was not. Mutation testing confirmed
7
+ it - replacing the Bing URL with a constant, dropping `&num=` from the
8
+ Scholar URL, and making the Google Patents URL ignore the query all left
9
+ the full suite green. A scraper that searched for the wrong thing entirely
10
+ passed.
11
+
12
+ These are pure string assertions: no browser, no fixtures, microseconds.
13
+ """
14
+
15
+ import pytest
16
+
17
+ from serp import (
18
+ bing_search_url,
19
+ brave_search_url,
20
+ google_patents_search_url,
21
+ google_scholar_url,
22
+ )
23
+
24
+
25
+ class TestQueryIsIncluded:
26
+ """The single most important property: the URL must ask for what the
27
+ caller asked for."""
28
+
29
+ @pytest.mark.parametrize("builder", [
30
+ bing_search_url, brave_search_url, google_patents_search_url, google_scholar_url])
31
+ def test_the_query_reaches_the_url(self, builder):
32
+ assert "widgets" in builder("widgets", 10)
33
+
34
+ @pytest.mark.parametrize("builder", [
35
+ bing_search_url, brave_search_url, google_patents_search_url, google_scholar_url])
36
+ def test_a_different_query_produces_a_different_url(self, builder):
37
+ assert builder("widgets", 10) != builder("gadgets", 10)
38
+
39
+
40
+ class TestQueryIsEncoded:
41
+ """A raw query in a URL is both a correctness bug (spaces, ampersands
42
+ and hashes truncate or corrupt the search) and the kind of thing that
43
+ turns user input into URL structure."""
44
+
45
+ @pytest.mark.parametrize("builder", [
46
+ bing_search_url, brave_search_url, google_patents_search_url, google_scholar_url])
47
+ def test_spaces_are_encoded(self, builder):
48
+ url = builder("agentic ai", 10)
49
+ assert "agentic+ai" in url or "agentic%20ai" in url
50
+ assert "agentic ai" not in url
51
+
52
+ @pytest.mark.parametrize("builder", [
53
+ bing_search_url, brave_search_url, google_patents_search_url, google_scholar_url])
54
+ def test_url_significant_characters_are_escaped(self, builder):
55
+ url = builder("a&b=c#d", 10)
56
+ query_part = url.split("?", 1)[1]
57
+ # Exactly one separator per parameter the builder itself added.
58
+ assert "a&b=c#d" not in query_part
59
+ assert "%26" in query_part
60
+
61
+
62
+ class TestResultCount:
63
+ def test_scholar_asks_for_the_requested_number_of_results(self):
64
+ assert "num=25" in google_scholar_url("widgets", 25)
65
+
66
+ def test_google_patents_asks_for_the_requested_number_of_results(self):
67
+ assert "num=25" in google_patents_search_url("widgets", 25)
68
+
69
+
70
+ class TestHosts:
71
+ @pytest.mark.parametrize("builder, host", [
72
+ (bing_search_url, "https://www.bing.com/search"),
73
+ (brave_search_url, "https://search.brave.com/search"),
74
+ (google_patents_search_url, "https://patents.google.com/"),
75
+ (google_scholar_url, "https://scholar.google.com/scholar"),
76
+ ])
77
+ def test_points_at_the_expected_endpoint(self, builder, host):
78
+ assert builder("widgets", 10).startswith(host)