Files
gyxx-flow/tests/test_scrapling_adapter.py

335 lines
10 KiB
Python

from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pytest
import gyxx_flow.adapters.scrapling as scrapling_adapter
from gyxx_flow.adapters.scrapling import (
BrowserTimeoutError,
ScraplingBrowser,
is_browser_timeout_error,
)
class _FakeContext:
def __init__(self, *, browser: _FakeBrowser | None = None) -> None:
self.browser = browser
self.close_count = 0
self.added_cookies: list[list[dict[str, Any]]] = []
self.restored_states: list[dict[str, Any]] = []
def close(self) -> None:
self.close_count += 1
def add_cookies(self, cookies: list[dict[str, Any]]) -> None:
self.added_cookies.append(cookies)
def set_storage_state(self, state: dict[str, Any]) -> None:
self.restored_states.append(state)
def cookies(self) -> list[dict[str, Any]]:
return [{"name": "saved", "value": "cookie"}]
def storage_state(self) -> dict[str, Any]:
return {"cookies": self.cookies(), "origins": []}
class _ClosedOnExportContext(_FakeContext):
def cookies(self) -> list[dict[str, Any]]:
raise RuntimeError(
"BrowserContext.cookies: Target page, context or browser has been closed"
)
class _FakeBrowser:
def __init__(self, contexts: list[_FakeContext] | None = None) -> None:
self.contexts = contexts or []
self.close_count = 0
def close(self) -> None:
self.close_count += 1
class _FakeSession:
def __init__(
self,
*,
context: _FakeContext,
browser: _FakeBrowser | None = None,
page: object | None = None,
response: object | None = None,
) -> None:
self.context = context
self.browser = browser
self.page = page or object()
self.response = response or object()
self.start_count = 0
self.close_count = 0
self.fetch_calls: list[tuple[str, dict[str, Any]]] = []
def start(self) -> None:
self.start_count += 1
def close(self) -> None:
self.close_count += 1
if self.context is not None:
self.context.close()
self.context = None
if self.browser is not None:
self.browser.close()
self.browser = None
def fetch(self, url: str, **options: Any) -> object:
self.fetch_calls.append((url, options))
options["page_action"](self.page)
return self.response
def _install_session_factory(
monkeypatch: pytest.MonkeyPatch,
name: str,
session: _FakeSession,
) -> list[dict[str, Any]]:
constructor_calls: list[dict[str, Any]] = []
def factory(**options: Any) -> _FakeSession:
constructor_calls.append(options)
return session
monkeypatch.setattr(scrapling_adapter, name, factory)
return constructor_calls
def test_dynamic_session_supports_persistent_profile_and_fetch_result(
monkeypatch: pytest.MonkeyPatch,
) -> None:
browser = _FakeBrowser()
context = _FakeContext(browser=browser)
page = object()
response = object()
session = _FakeSession(context=context, page=page, response=response)
calls = _install_session_factory(monkeypatch, "DynamicSession", session)
with ScraplingBrowser(user_data_dir="profile-a") as client:
actual_response, result = client.run_fetch_action(
"https://example.test",
lambda actual_page: (actual_page, "done"),
wait=0,
)
assert client.session is session
assert client.browser is browser
assert client.context is context
assert client.page is page
assert actual_response is response
assert result == (page, "done")
assert calls == [{"user_data_dir": "profile-a", "retries": 1}]
assert session.start_count == 1
assert session.close_count == 1
assert context.close_count == 1
assert session.fetch_calls[0][0] == "https://example.test"
assert session.fetch_calls[0][1]["wait"] == 0
def test_stealthy_session_is_selected(monkeypatch: pytest.MonkeyPatch) -> None:
context = _FakeContext()
session = _FakeSession(context=context)
calls = _install_session_factory(monkeypatch, "StealthySession", session)
browser = ScraplingBrowser(stealthy=True, user_data_dir="profile-b").start()
browser.close()
assert calls == [{"user_data_dir": "profile-b", "retries": 1}]
assert session.start_count == 1
def test_run_fetch_action_propagates_scrapling_swallowed_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
context = _FakeContext()
session = _FakeSession(context=context)
_install_session_factory(monkeypatch, "DynamicSession", session)
expected = ValueError("action failed")
with ScraplingBrowser() as client:
with pytest.raises(ValueError) as captured:
client.run_fetch_action(
"https://example.test",
lambda _page: (_ for _ in ()).throw(expected),
)
assert captured.value is expected
def test_context_manager_preserves_action_error_when_state_export_target_closed(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
context = _ClosedOnExportContext()
session = _FakeSession(context=context)
_install_session_factory(monkeypatch, "DynamicSession", session)
expected = ValueError("action failed")
with pytest.raises(ValueError) as captured:
with ScraplingBrowser(cookie_file=tmp_path / "cookies.json"):
raise expected
assert captured.value is expected
assert session.close_count == 1
def test_explicit_close_ignores_target_closed_state_export(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
context = _ClosedOnExportContext()
session = _FakeSession(context=context)
_install_session_factory(monkeypatch, "DynamicSession", session)
client = ScraplingBrowser(cookie_file=tmp_path / "cookies.json").start()
client.close()
assert session.close_count == 1
def test_cdp_reuses_only_existing_context_and_preserves_browser(
monkeypatch: pytest.MonkeyPatch,
) -> None:
existing_context = _FakeContext()
created_context = _FakeContext()
remote_browser = _FakeBrowser([existing_context, created_context])
session = _FakeSession(context=created_context, browser=remote_browser)
_install_session_factory(monkeypatch, "DynamicSession", session)
client = ScraplingBrowser(
cdp_url="http://127.0.0.1:9222",
reuse_existing_cdp_context=True,
).start()
assert created_context.close_count == 1
assert client.browser is remote_browser
assert client.context is existing_context
assert session.context is existing_context
client.close()
assert session.close_count == 1
assert existing_context.close_count == 0
assert remote_browser.close_count == 0
@pytest.mark.parametrize("existing_context_count", [0, 2])
def test_cdp_reuse_requires_exactly_one_existing_context(
monkeypatch: pytest.MonkeyPatch,
existing_context_count: int,
) -> None:
existing_contexts = [_FakeContext() for _ in range(existing_context_count)]
created_context = _FakeContext()
remote_browser = _FakeBrowser([*existing_contexts, created_context])
session = _FakeSession(context=created_context, browser=remote_browser)
_install_session_factory(monkeypatch, "DynamicSession", session)
with pytest.raises(RuntimeError, match="exactly one existing context"):
ScraplingBrowser(
cdp_url="http://127.0.0.1:9222",
reuse_existing_cdp_context=True,
).start()
assert created_context.close_count == 1
assert remote_browser.close_count == 0
assert all(context.close_count == 0 for context in existing_contexts)
def test_browser_timeout_detection_uses_engine_type_metadata() -> None:
playwright_timeout = type(
"TimeoutError",
(Exception,),
{"__module__": "playwright._impl._errors"},
)
patchright_timeout = type(
"TimeoutError",
(Exception,),
{"__module__": "patchright._impl._errors"},
)
unrelated_timeout = type(
"TimeoutError",
(Exception,),
{"__module__": "unrelated.browser"},
)
assert is_browser_timeout_error(BrowserTimeoutError())
assert is_browser_timeout_error(playwright_timeout())
assert is_browser_timeout_error(patchright_timeout())
assert not is_browser_timeout_error(TimeoutError())
assert not is_browser_timeout_error(unrelated_timeout())
def test_cdp_reuse_requires_cdp_url() -> None:
with pytest.raises(ValueError, match="requires cdp_url"):
ScraplingBrowser(reuse_existing_cdp_context=True)
def test_bound_cookie_and_storage_state_are_restored_and_persisted(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
cookie_file = tmp_path / "cookies.json"
storage_file = tmp_path / "storage.json"
cookie_file.write_text(
json.dumps([{"name": "loaded", "value": "cookie"}]),
encoding="utf-8",
)
storage_file.write_text(
json.dumps({"cookies": [], "origins": [{"origin": "https://example.test"}]}),
encoding="utf-8",
)
context = _FakeContext()
session = _FakeSession(context=context)
_install_session_factory(monkeypatch, "DynamicSession", session)
with ScraplingBrowser(
cookie_file=cookie_file,
storage_state_file=storage_file,
):
assert context.added_cookies == [
[{"name": "loaded", "value": "cookie"}]
]
assert context.restored_states == [
{"cookies": [], "origins": [{"origin": "https://example.test"}]}
]
assert json.loads(cookie_file.read_text(encoding="utf-8")) == [
{"name": "saved", "value": "cookie"}
]
assert json.loads(storage_file.read_text(encoding="utf-8")) == {
"cookies": [{"name": "saved", "value": "cookie"}],
"origins": [],
}
def test_borrowed_cdp_context_does_not_import_or_export_bound_state(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
cookie_file = tmp_path / "cookies.json"
cookie_file.write_text("[]", encoding="utf-8")
existing_context = _FakeContext()
created_context = _FakeContext()
browser = _FakeBrowser([existing_context, created_context])
session = _FakeSession(context=created_context, browser=browser)
_install_session_factory(monkeypatch, "DynamicSession", session)
with ScraplingBrowser(
cdp_url="http://127.0.0.1:9222",
reuse_existing_cdp_context=True,
cookie_file=cookie_file,
):
pass
assert cookie_file.read_text(encoding="utf-8") == "[]"
assert existing_context.added_cookies == []