Files
crawl4ai/tests/test_issue_1842_browser_none.py
hafezparast 310b52b663 fix: improve browser None guard in create_browser_context (#1842)
The existing guard assumed self.browser=None only meant persistent context mode.
In reality, the browser can be None because it was closed by the janitor, crashed,
or never started. This caused a misleading error message. Now the guard distinguishes
between persistent context and closed/crashed browser with appropriate messages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 10:45:38 +08:00

182 lines
7.3 KiB
Python

"""
Tests for issue #1842: Docker Job Queue API Error: 'NoneType' object has no attribute 'new_context'
Verifies that BrowserManager.create_browser_context() raises clear, correct
RuntimeError messages when self.browser is None, distinguishing between:
- persistent context mode (self._launched_persistent = True)
- browser closed/crashed/not started (self._launched_persistent = False)
"""
import asyncio
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from crawl4ai.browser_manager import BrowserManager
from crawl4ai.async_configs import BrowserConfig, CrawlerRunConfig
@pytest.fixture
def browser_config():
"""Default BrowserConfig for testing."""
return BrowserConfig()
@pytest.fixture
def manager(browser_config):
"""BrowserManager with browser=None (simulating closed/crashed state)."""
mgr = BrowserManager(browser_config)
assert mgr.browser is None # starts as None
return mgr
# ── Guard raises RuntimeError (not AttributeError) ─────────────────────
class TestBrowserNoneGuard:
"""Verify that create_browser_context raises RuntimeError when browser is None."""
@pytest.mark.asyncio
async def test_browser_none_raises_runtime_error(self, manager):
"""Should raise RuntimeError, not AttributeError."""
with pytest.raises(RuntimeError):
await manager.create_browser_context()
@pytest.mark.asyncio
async def test_browser_none_not_attribute_error(self, manager):
"""Must never raise AttributeError ('NoneType' has no attribute 'new_context')."""
with pytest.raises(RuntimeError):
await manager.create_browser_context()
# If we get here, it raised RuntimeError — not AttributeError. Good.
@pytest.mark.asyncio
async def test_browser_none_with_crawler_run_config(self, manager):
"""Guard should trigger even when CrawlerRunConfig is passed."""
config = CrawlerRunConfig()
with pytest.raises(RuntimeError):
await manager.create_browser_context(config)
# ── Correct error message based on cause ────────────────────────────────
class TestErrorMessageAccuracy:
"""Verify error messages correctly identify why browser is None."""
@pytest.mark.asyncio
async def test_persistent_context_message(self, manager):
"""When _launched_persistent=True, message should mention persistent context."""
manager._launched_persistent = True
with pytest.raises(RuntimeError, match="use_persistent_context=True"):
await manager.create_browser_context()
@pytest.mark.asyncio
async def test_persistent_context_message_mentions_single_context(self, manager):
"""Persistent context error should mention single shared context."""
manager._launched_persistent = True
with pytest.raises(RuntimeError, match="single shared context"):
await manager.create_browser_context()
@pytest.mark.asyncio
async def test_closed_browser_message(self, manager):
"""When browser is closed (not persistent), message should mention closed/crashed."""
manager._launched_persistent = False
with pytest.raises(RuntimeError, match="closed.*crashed|crashed.*closed"):
await manager.create_browser_context()
@pytest.mark.asyncio
async def test_closed_browser_message_no_persistent_mention(self, manager):
"""Non-persistent error should NOT mention use_persistent_context."""
manager._launched_persistent = False
with pytest.raises(RuntimeError) as exc_info:
await manager.create_browser_context()
assert "use_persistent_context" not in str(exc_info.value)
@pytest.mark.asyncio
async def test_default_state_gives_closed_message(self, manager):
"""Default manager (never started) should give the closed/crashed message."""
# _launched_persistent defaults to False
assert manager._launched_persistent is False
with pytest.raises(RuntimeError, match="not available"):
await manager.create_browser_context()
# ── Browser available (no guard triggered) ──────────────────────────────
class TestBrowserAvailable:
"""Verify create_browser_context works normally when browser is available."""
@pytest.mark.asyncio
async def test_browser_set_does_not_raise(self, manager):
"""When browser is set, should not raise RuntimeError."""
mock_context = AsyncMock()
mock_browser = AsyncMock()
mock_browser.new_context = AsyncMock(return_value=mock_context)
manager.browser = mock_browser
context = await manager.create_browser_context()
assert context == mock_context
mock_browser.new_context.assert_called_once()
@pytest.mark.asyncio
async def test_browser_set_with_config(self, manager):
"""When browser is set, should work with CrawlerRunConfig."""
mock_context = AsyncMock()
mock_browser = AsyncMock()
mock_browser.new_context = AsyncMock(return_value=mock_context)
manager.browser = mock_browser
config = CrawlerRunConfig()
context = await manager.create_browser_context(config)
assert context == mock_context
# ── Simulate Docker race condition scenario ─────────────────────────────
class TestDockerRaceCondition:
"""Simulate the scenario from issue #1842: browser becomes None during use."""
@pytest.mark.asyncio
async def test_browser_closed_mid_use(self, manager):
"""Simulate browser being closed while another task tries to use it."""
mock_context = AsyncMock()
mock_browser = AsyncMock()
mock_browser.new_context = AsyncMock(return_value=mock_context)
manager.browser = mock_browser
# First call works
ctx = await manager.create_browser_context()
assert ctx == mock_context
# Simulate janitor closing the browser
manager.browser = None
# Second call should raise RuntimeError, not AttributeError
with pytest.raises(RuntimeError, match="not available"):
await manager.create_browser_context()
@pytest.mark.asyncio
async def test_concurrent_access_after_close(self, manager):
"""Multiple concurrent calls after browser close all get RuntimeError."""
manager.browser = None
manager._launched_persistent = False
async def try_create():
with pytest.raises(RuntimeError, match="not available"):
await manager.create_browser_context()
# Run multiple concurrent attempts
await asyncio.gather(*[try_create() for _ in range(5)])
@pytest.mark.asyncio
async def test_error_type_is_not_attribute_error(self, manager):
"""Explicitly verify the original bug (AttributeError) cannot occur."""
manager.browser = None
try:
await manager.create_browser_context()
assert False, "Should have raised"
except RuntimeError:
pass # Expected
except AttributeError:
pytest.fail(
"Got AttributeError ('NoneType' has no attribute 'new_context') "
"— this is the original bug from issue #1842"
)