447 lines
17 KiB
Python
447 lines
17 KiB
Python
"""Unit tests for launch_context() — context kwargs, viewport defaults, close cleanup."""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, call, patch
|
|
|
|
import pytest
|
|
|
|
from cloakbrowser.config import DEFAULT_VIEWPORT
|
|
|
|
|
|
# All tests mock launch() to avoid needing a binary.
|
|
# launch_context() calls launch() internally, then browser.new_context().
|
|
|
|
|
|
def _make_mock_browser():
|
|
"""Create a mock browser with new_context() returning a mock context."""
|
|
browser = MagicMock()
|
|
context = MagicMock()
|
|
browser.new_context.return_value = context
|
|
return browser, context
|
|
|
|
|
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
@patch("cloakbrowser.browser.launch")
|
|
def test_default_viewport(mock_launch, _mock_bin):
|
|
"""DEFAULT_VIEWPORT applied when no viewport given."""
|
|
browser, context = _make_mock_browser()
|
|
mock_launch.return_value = browser
|
|
|
|
from cloakbrowser.browser import launch_context
|
|
launch_context()
|
|
|
|
ctx_kwargs = browser.new_context.call_args
|
|
assert ctx_kwargs[1]["viewport"] == DEFAULT_VIEWPORT
|
|
|
|
|
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
@patch("cloakbrowser.browser.launch")
|
|
def test_headed_no_viewport(mock_launch, _mock_bin):
|
|
"""Headed (headless=False): no emulated viewport — no_viewport=True so the page
|
|
tracks the real window (CDP viewport emulation would force outerWidth < innerWidth)."""
|
|
browser, context = _make_mock_browser()
|
|
mock_launch.return_value = browser
|
|
|
|
from cloakbrowser.browser import launch_context
|
|
launch_context(headless=False)
|
|
|
|
ctx_kwargs = browser.new_context.call_args[1]
|
|
assert ctx_kwargs.get("no_viewport") is True
|
|
assert "viewport" not in ctx_kwargs
|
|
|
|
|
|
def test_default_no_viewport_helper():
|
|
"""_default_no_viewport defaults new_page()/new_context() to no_viewport=True,
|
|
but never overrides an explicit viewport (Playwright rejects passing both)."""
|
|
from cloakbrowser.browser import _default_no_viewport
|
|
|
|
browser = MagicMock()
|
|
orig_new_page = browser.new_page
|
|
orig_new_context = browser.new_context
|
|
_default_no_viewport(browser)
|
|
|
|
browser.new_page()
|
|
orig_new_page.assert_called_once_with(no_viewport=True)
|
|
browser.new_context()
|
|
orig_new_context.assert_called_once_with(no_viewport=True)
|
|
|
|
# Explicit viewport respected — no_viewport NOT injected.
|
|
orig_new_page.reset_mock()
|
|
browser.new_page(viewport={"width": 800, "height": 600})
|
|
orig_new_page.assert_called_once_with(viewport={"width": 800, "height": 600})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_default_no_viewport_helper_async():
|
|
"""_default_no_viewport_async mirrors the sync helper for async new_page/new_context."""
|
|
from cloakbrowser.browser import _default_no_viewport_async
|
|
|
|
browser = MagicMock()
|
|
browser.new_page = AsyncMock()
|
|
browser.new_context = AsyncMock()
|
|
orig_new_page = browser.new_page
|
|
orig_new_context = browser.new_context
|
|
_default_no_viewport_async(browser)
|
|
|
|
await browser.new_page()
|
|
orig_new_page.assert_awaited_once_with(no_viewport=True)
|
|
await browser.new_context()
|
|
orig_new_context.assert_awaited_once_with(no_viewport=True)
|
|
|
|
# Explicit viewport respected — no_viewport NOT injected.
|
|
orig_new_page.reset_mock()
|
|
await browser.new_page(viewport={"width": 800, "height": 600})
|
|
orig_new_page.assert_awaited_once_with(viewport={"width": 800, "height": 600})
|
|
|
|
|
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
@patch("cloakbrowser.browser.launch")
|
|
def test_conflicting_viewport_kwargs_deduped(mock_launch, _mock_bin):
|
|
"""If a caller forces no_viewport via **kwargs alongside viewport=, only one
|
|
reaches Playwright (which rejects both). The explicit kwargs value wins."""
|
|
browser, context = _make_mock_browser()
|
|
mock_launch.return_value = browser
|
|
|
|
from cloakbrowser.browser import launch_context
|
|
launch_context(viewport={"width": 1280, "height": 800}, no_viewport=True)
|
|
|
|
ctx_kwargs = browser.new_context.call_args[1]
|
|
assert ctx_kwargs.get("no_viewport") is True
|
|
assert "viewport" not in ctx_kwargs
|
|
|
|
|
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
@patch("cloakbrowser.browser.launch")
|
|
def test_custom_viewport(mock_launch, _mock_bin):
|
|
"""Custom viewport overrides DEFAULT_VIEWPORT."""
|
|
browser, context = _make_mock_browser()
|
|
mock_launch.return_value = browser
|
|
|
|
from cloakbrowser.browser import launch_context
|
|
custom = {"width": 1280, "height": 720}
|
|
launch_context(viewport=custom)
|
|
|
|
ctx_kwargs = browser.new_context.call_args
|
|
assert ctx_kwargs[1]["viewport"] == custom
|
|
|
|
|
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
@patch("cloakbrowser.browser.launch")
|
|
def test_default_does_not_suppress_maximize(mock_launch, _mock_bin):
|
|
"""No explicit viewport → let launch() auto-maximize (parity with JS/.NET)."""
|
|
browser, _ = _make_mock_browser()
|
|
mock_launch.return_value = browser
|
|
|
|
from cloakbrowser.browser import launch_context
|
|
launch_context()
|
|
|
|
assert mock_launch.call_args.kwargs["_suppress_maximize"] is False
|
|
|
|
|
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
@patch("cloakbrowser.browser.launch")
|
|
def test_explicit_viewport_suppresses_maximize(mock_launch, _mock_bin):
|
|
"""Caller chose a viewport → suppress auto --start-maximized (parity with JS/.NET)."""
|
|
browser, _ = _make_mock_browser()
|
|
mock_launch.return_value = browser
|
|
|
|
from cloakbrowser.browser import launch_context
|
|
launch_context(viewport={"width": 800, "height": 600})
|
|
|
|
assert mock_launch.call_args.kwargs["_suppress_maximize"] is True
|
|
|
|
|
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
@patch("cloakbrowser.browser.launch")
|
|
def test_no_viewport_kwarg_suppresses_maximize(mock_launch, _mock_bin):
|
|
"""Explicit no_viewport also counts as a chosen geometry → suppress."""
|
|
browser, _ = _make_mock_browser()
|
|
mock_launch.return_value = browser
|
|
|
|
from cloakbrowser.browser import launch_context
|
|
launch_context(no_viewport=True)
|
|
|
|
assert mock_launch.call_args.kwargs["_suppress_maximize"] is True
|
|
|
|
|
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
@patch("cloakbrowser.browser.launch")
|
|
def test_user_agent(mock_launch, _mock_bin):
|
|
"""user_agent forwarded to new_context()."""
|
|
browser, context = _make_mock_browser()
|
|
mock_launch.return_value = browser
|
|
|
|
from cloakbrowser.browser import launch_context
|
|
launch_context(user_agent="Mozilla/5.0 Custom")
|
|
|
|
ctx_kwargs = browser.new_context.call_args
|
|
assert ctx_kwargs[1]["user_agent"] == "Mozilla/5.0 Custom"
|
|
|
|
|
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
@patch("cloakbrowser.browser.launch")
|
|
def test_locale_forwarded(mock_launch, _mock_bin):
|
|
"""locale flows to launch() for --lang binary flag, NOT to new_context() CDP."""
|
|
browser, context = _make_mock_browser()
|
|
mock_launch.return_value = browser
|
|
|
|
from cloakbrowser.browser import launch_context
|
|
launch_context(locale="de-DE")
|
|
|
|
# Locale in launch() call (for --lang binary flag)
|
|
assert mock_launch.call_args[1]["locale"] == "de-DE"
|
|
# NOT in new_context() — would trigger detectable CDP emulation
|
|
ctx_kwargs = browser.new_context.call_args
|
|
assert "locale" not in ctx_kwargs[1]
|
|
|
|
|
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
@patch("cloakbrowser.browser.launch")
|
|
def test_timezone_via_binary_not_cdp(mock_launch, _mock_bin):
|
|
"""timezone passed to launch() for binary flag, NOT to new_context() CDP.
|
|
|
|
--fingerprint-timezone is process-wide (reads CommandLine in renderer),
|
|
so it applies to ALL contexts, not just the default one.
|
|
"""
|
|
browser, context = _make_mock_browser()
|
|
mock_launch.return_value = browser
|
|
|
|
from cloakbrowser.browser import launch_context
|
|
launch_context(timezone="America/New_York")
|
|
|
|
# timezone in launch() — binary flag set
|
|
assert mock_launch.call_args[1]["timezone"] == "America/New_York"
|
|
# NOT in new_context() — no CDP emulation
|
|
ctx_kwargs = browser.new_context.call_args
|
|
assert "timezone_id" not in ctx_kwargs[1]
|
|
|
|
|
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
@patch("cloakbrowser.browser.launch")
|
|
def test_color_scheme(mock_launch, _mock_bin):
|
|
"""color_scheme forwarded to new_context()."""
|
|
browser, context = _make_mock_browser()
|
|
mock_launch.return_value = browser
|
|
|
|
from cloakbrowser.browser import launch_context
|
|
launch_context(color_scheme="dark")
|
|
|
|
ctx_kwargs = browser.new_context.call_args
|
|
assert ctx_kwargs[1]["color_scheme"] == "dark"
|
|
|
|
|
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8"))
|
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
@patch("cloakbrowser.browser.launch")
|
|
def test_geoip_resolution(mock_launch, _mock_bin, _mock_geoip):
|
|
"""geoip fills timezone+locale, both flow to binary args only."""
|
|
browser, context = _make_mock_browser()
|
|
mock_launch.return_value = browser
|
|
|
|
from cloakbrowser.browser import launch_context
|
|
launch_context(proxy="http://proxy:8080", geoip=True)
|
|
|
|
# Both go to launch() for binary flags
|
|
assert mock_launch.call_args[1]["locale"] == "de-DE"
|
|
assert mock_launch.call_args[1]["timezone"] == "Europe/Berlin"
|
|
# Neither in context — no CDP emulation
|
|
ctx_kwargs = browser.new_context.call_args
|
|
assert "timezone_id" not in ctx_kwargs[1]
|
|
assert "locale" not in ctx_kwargs[1]
|
|
|
|
|
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
@patch("cloakbrowser.browser.launch")
|
|
def test_timezone_id_alias(mock_launch, _mock_bin):
|
|
"""timezone_id kwarg accepted as alias for timezone."""
|
|
browser, context = _make_mock_browser()
|
|
mock_launch.return_value = browser
|
|
|
|
from cloakbrowser.browser import launch_context
|
|
launch_context(timezone_id="Europe/Paris")
|
|
|
|
# Resolved value flows to launch() for binary flag
|
|
assert mock_launch.call_args[1]["timezone"] == "Europe/Paris"
|
|
# NOT in context — no CDP emulation
|
|
ctx_kwargs = browser.new_context.call_args
|
|
assert "timezone_id" not in ctx_kwargs[1]
|
|
|
|
|
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
@patch("cloakbrowser.browser.launch")
|
|
def test_close_closes_browser(mock_launch, _mock_bin):
|
|
"""context.close() also calls browser.close()."""
|
|
browser, context = _make_mock_browser()
|
|
# Save reference before launch_context() monkey-patches context.close
|
|
original_ctx_close = context.close
|
|
mock_launch.return_value = browser
|
|
|
|
from cloakbrowser.browser import launch_context
|
|
ctx = launch_context()
|
|
|
|
# The returned context has a patched close()
|
|
ctx.close()
|
|
# Original context close was called
|
|
original_ctx_close.assert_called_once()
|
|
# Browser close was also called
|
|
browser.close.assert_called_once()
|
|
|
|
|
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
@patch("cloakbrowser.browser.launch")
|
|
def test_error_closes_browser(mock_launch, _mock_bin):
|
|
"""If new_context() raises, browser is still closed."""
|
|
browser = MagicMock()
|
|
browser.new_context.side_effect = RuntimeError("context creation failed")
|
|
mock_launch.return_value = browser
|
|
|
|
from cloakbrowser.browser import launch_context
|
|
with pytest.raises(RuntimeError, match="context creation failed"):
|
|
launch_context()
|
|
|
|
browser.close.assert_called_once()
|
|
|
|
|
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
@patch("cloakbrowser.browser.launch")
|
|
def test_kwargs_passthrough(mock_launch, _mock_bin):
|
|
"""Extra kwargs forwarded to new_context(), NOT to launch().
|
|
|
|
Important contract: kwargs like record_video_dir go to context creation,
|
|
not browser launch.
|
|
"""
|
|
browser, context = _make_mock_browser()
|
|
mock_launch.return_value = browser
|
|
|
|
from cloakbrowser.browser import launch_context
|
|
launch_context(record_video_dir="/tmp/videos")
|
|
|
|
# Verify kwarg reached new_context()
|
|
ctx_kwargs = browser.new_context.call_args
|
|
assert ctx_kwargs[1]["record_video_dir"] == "/tmp/videos"
|
|
|
|
# Verify kwarg did NOT leak to launch()
|
|
launch_kwargs = mock_launch.call_args[1]
|
|
assert "record_video_dir" not in launch_kwargs
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Async: launch_context_async()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_mock_async_browser():
|
|
"""Create a mock async browser whose new_context() returns a mock context."""
|
|
browser = AsyncMock()
|
|
context = AsyncMock()
|
|
browser.new_context.return_value = context
|
|
return browser, context
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
@patch("cloakbrowser.browser.launch_async")
|
|
async def test_async_storage_state_forwarded(mock_launch_async, _mock_bin):
|
|
"""storage_state kwarg forwarded to browser.new_context() in async path.
|
|
|
|
This is the motivating use case from issue #141.
|
|
"""
|
|
browser, context = _make_mock_async_browser()
|
|
mock_launch_async.return_value = browser
|
|
|
|
from cloakbrowser.browser import launch_context_async
|
|
await launch_context_async(storage_state="state.json")
|
|
|
|
ctx_kwargs = browser.new_context.call_args
|
|
assert ctx_kwargs[1]["storage_state"] == "state.json"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
@patch("cloakbrowser.browser.launch_async")
|
|
async def test_async_default_viewport(mock_launch_async, _mock_bin):
|
|
"""DEFAULT_VIEWPORT applied when no viewport given (async)."""
|
|
browser, context = _make_mock_async_browser()
|
|
mock_launch_async.return_value = browser
|
|
|
|
from cloakbrowser.browser import launch_context_async
|
|
await launch_context_async()
|
|
|
|
ctx_kwargs = browser.new_context.call_args
|
|
assert ctx_kwargs[1]["viewport"] == DEFAULT_VIEWPORT
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
@patch("cloakbrowser.browser.launch_async")
|
|
async def test_async_locale_flows_to_binary_not_cdp(mock_launch_async, _mock_bin):
|
|
"""locale flows to launch_async() for --lang flag, NOT to new_context() CDP."""
|
|
browser, context = _make_mock_async_browser()
|
|
mock_launch_async.return_value = browser
|
|
|
|
from cloakbrowser.browser import launch_context_async
|
|
await launch_context_async(locale="de-DE", timezone="Europe/Berlin")
|
|
|
|
# Binary flags
|
|
assert mock_launch_async.call_args[1]["locale"] == "de-DE"
|
|
assert mock_launch_async.call_args[1]["timezone"] == "Europe/Berlin"
|
|
# Not in context — would trigger detectable CDP emulation
|
|
ctx_kwargs = browser.new_context.call_args
|
|
assert "locale" not in ctx_kwargs[1]
|
|
assert "timezone_id" not in ctx_kwargs[1]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
@patch("cloakbrowser.browser.launch_async")
|
|
async def test_async_close_closes_browser(mock_launch_async, _mock_bin):
|
|
"""await ctx.close() also closes the underlying browser."""
|
|
browser, context = _make_mock_async_browser()
|
|
original_ctx_close = context.close
|
|
mock_launch_async.return_value = browser
|
|
|
|
from cloakbrowser.browser import launch_context_async
|
|
ctx = await launch_context_async()
|
|
|
|
await ctx.close()
|
|
original_ctx_close.assert_called_once()
|
|
browser.close.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
@patch("cloakbrowser.browser.launch_async")
|
|
async def test_async_error_closes_browser(mock_launch_async, _mock_bin):
|
|
"""If new_context() raises in async path, browser is still closed."""
|
|
browser = AsyncMock()
|
|
browser.new_context.side_effect = RuntimeError("context creation failed")
|
|
mock_launch_async.return_value = browser
|
|
|
|
from cloakbrowser.browser import launch_context_async
|
|
with pytest.raises(RuntimeError, match="context creation failed"):
|
|
await launch_context_async()
|
|
|
|
browser.close.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
@patch("cloakbrowser.browser.launch_async")
|
|
async def test_async_cancellation_closes_browser(mock_launch_async, _mock_bin):
|
|
"""asyncio.CancelledError during new_context() still closes browser.
|
|
|
|
CancelledError derives from BaseException (not Exception) in Python 3.8+,
|
|
so the cleanup must catch BaseException to prevent browser process leaks
|
|
when the awaiting task is cancelled.
|
|
"""
|
|
import asyncio
|
|
|
|
browser = AsyncMock()
|
|
browser.new_context.side_effect = asyncio.CancelledError()
|
|
mock_launch_async.return_value = browser
|
|
|
|
from cloakbrowser.browser import launch_context_async
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await launch_context_async()
|
|
|
|
browser.close.assert_called_once()
|