Skip to content
4 changes: 3 additions & 1 deletion stagehand/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ class StagehandConfig(BaseModel):
headless (bool): Run browser in headless mode
system_prompt (Optional[str]): System prompt to use for LLM interactions.
local_browser_launch_options (Optional[dict[str, Any]]): Local browser launch options.
use_api (bool): Whether to use API mode.
experimental (bool): Enable experimental features.
"""

env: Literal["BROWSERBASE", "LOCAL"] = "BROWSERBASE"
Expand All @@ -43,7 +45,7 @@ class StagehandConfig(BaseModel):
"https://api.stagehand.browserbase.com/v1",
alias="apiUrl",
description="Stagehand API URL",
) # might add a default value here
)
model_api_key: Optional[str] = Field(
None, alias="modelApiKey", description="Model API key"
)
Expand Down
13 changes: 10 additions & 3 deletions stagehand/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,9 +426,16 @@ def is_url_type(annotation):
if annotation is None:
return False

# Direct URL type
if inspect.isclass(annotation) and issubclass(annotation, (AnyUrl, HttpUrl)):
return True
# Direct URL type - handle subscripted generics safely
# Pydantic V2 can generate complex type annotations that can't be used with issubclass()
try:
if inspect.isclass(annotation) and issubclass(annotation, (AnyUrl, HttpUrl)):
return True
except TypeError:
# Handle subscripted generics that can't be used with issubclass
# This commonly occurs with Pydantic V2's typing.Annotated[...] constructs
# We gracefully skip these rather than crashing, as they're not simple URL types
pass

# Check for URL in generic containers
origin = get_origin(annotation)
Expand Down
11 changes: 8 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,15 @@ def mock_stagehand_config():
return StagehandConfig(
env="LOCAL",
model_name="gpt-4o-mini",
verbose=0, # Quiet for tests
verbose=1, # Quiet for tests
api_key="test-api-key",
project_id="test-project-id",
dom_settle_timeout_ms=1000,
self_heal=True,
wait_for_captcha_solves=False,
system_prompt="Test system prompt"
system_prompt="Test system prompt",
use_api=False,
experimental=False,
)


Expand All @@ -48,7 +50,9 @@ def mock_browserbase_config():
model_name="gpt-4o",
api_key="test-browserbase-api-key",
project_id="test-browserbase-project-id",
verbose=0
verbose=0,
use_api=True,
experimental=False,
)


Expand Down Expand Up @@ -78,6 +82,7 @@ def mock_stagehand_page(mock_playwright_page):

# Create a mock stagehand client
mock_client = MagicMock()
mock_client.use_api = False
mock_client.env = "LOCAL"
mock_client.logger = MagicMock()
mock_client.logger.debug = MagicMock()
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/api/test_core_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class Article(BaseModel):


class TestStagehandAPIIntegration:
"""Integration tests for Stagehand Python SDK in BROWSERBASE API mode."""
"""Integration tests for Stagehand Python SDK in BROWSERBASE API mode"""

@pytest.fixture(scope="class")
def browserbase_config(self):
Expand Down
1 change: 1 addition & 0 deletions tests/integration/local/test_core_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def local_config(self):
wait_for_captcha_solves=False,
system_prompt="You are a browser automation assistant for testing purposes.",
model_client_options={"apiKey": os.getenv("MODEL_API_KEY")},
use_api=False,
)

@pytest_asyncio.fixture
Expand Down
1 change: 1 addition & 0 deletions tests/unit/core/test_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ async def test_goto_local_mode(self, mock_stagehand_page):
async def test_goto_browserbase_mode(self, mock_stagehand_page):
"""Test navigation in BROWSERBASE mode"""
mock_stagehand_page._stagehand.env = "BROWSERBASE"
mock_stagehand_page._stagehand.use_api = True
mock_stagehand_page._stagehand._execute = AsyncMock(return_value={"success": True})

lock = AsyncMock()
Expand Down
122 changes: 71 additions & 51 deletions tests/unit/handlers/test_extract_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from pydantic import BaseModel

from stagehand.handlers.extract_handler import ExtractHandler
from stagehand.types import ExtractOptions, ExtractResult
from stagehand.types import ExtractOptions, ExtractResult, DefaultExtractSchema
from tests.mocks.mock_llm import MockLLMClient, MockLLMResponse


Expand Down Expand Up @@ -45,41 +45,72 @@ async def test_extract_with_default_schema(self, mock_stagehand_page):
# Mock page content
mock_stagehand_page._page.content = AsyncMock(return_value="<html><body>Sample content</body></html>")

# Mock get_accessibility_tree
with patch('stagehand.handlers.extract_handler.get_accessibility_tree') as mock_get_tree:
mock_get_tree.return_value = {
"simplified": "Sample accessibility tree content",
"idToUrl": {}
# Mock extract_inference
with patch('stagehand.handlers.extract_handler.extract_inference') as mock_extract_inference:
mock_extract_inference.return_value = {
"data": {"extraction": "Sample extracted text from the page"},
"metadata": {"completed": True},
"prompt_tokens": 100,
"completion_tokens": 50,
"inference_time_ms": 1000
}

# Mock extract_inference
with patch('stagehand.handlers.extract_handler.extract_inference') as mock_extract_inference:
mock_extract_inference.return_value = {
"data": {"extraction": "Sample extracted text from the page"},
"metadata": {"completed": True},
"prompt_tokens": 100,
"completion_tokens": 50,
"inference_time_ms": 1000
}

# Also need to mock _wait_for_settled_dom
mock_stagehand_page._wait_for_settled_dom = AsyncMock()

options = ExtractOptions(instruction="extract the main content")
result = await handler.extract(options)

assert isinstance(result, ExtractResult)
# The handler should now properly populate the result with extracted data
assert result.data is not None
assert result.data == {"extraction": "Sample extracted text from the page"}

# Verify the mocks were called
mock_get_tree.assert_called_once()
mock_extract_inference.assert_called_once()
# Also need to mock _wait_for_settled_dom
mock_stagehand_page._wait_for_settled_dom = AsyncMock()

options = ExtractOptions(instruction="extract the main content")
result = await handler.extract(options)

assert isinstance(result, ExtractResult)
# The handler should now properly populate the result with extracted data
assert result.data is not None
# The handler returns a validated Pydantic model instance, not a raw dict
assert isinstance(result.data, DefaultExtractSchema)
assert result.data.extraction == "Sample extracted text from the page"

# Verify the mocks were called
mock_extract_inference.assert_called_once()

@pytest.mark.asyncio
async def test_extract_with_no_schema_returns_default_schema(self, mock_stagehand_page):
"""Test extracting data with no schema returns DefaultExtractSchema instance"""
mock_client = MagicMock()
mock_llm = MockLLMClient()
mock_client.llm = mock_llm
mock_client.start_inference_timer = MagicMock()
mock_client.update_metrics = MagicMock()

handler = ExtractHandler(mock_stagehand_page, mock_client, "")
mock_stagehand_page._page.content = AsyncMock(return_value="<html><body>Sample content</body></html>")

# Mock extract_inference - return data compatible with DefaultExtractSchema
with patch('stagehand.handlers.extract_handler.extract_inference') as mock_extract_inference:
mock_extract_inference.return_value = {
"data": {"extraction": "Sample extracted text from the page"},
"metadata": {"completed": True},
"prompt_tokens": 100,
"completion_tokens": 50,
"inference_time_ms": 1000
}

mock_stagehand_page._wait_for_settled_dom = AsyncMock()

options = ExtractOptions(instruction="extract the main content")
# No schema parameter passed - should use DefaultExtractSchema
result = await handler.extract(options)

assert isinstance(result, ExtractResult)
assert result.data is not None
# Should return DefaultExtractSchema instance
assert isinstance(result.data, DefaultExtractSchema)
assert result.data.extraction == "Sample extracted text from the page"

# Verify the mocks were called
mock_extract_inference.assert_called_once()

@pytest.mark.asyncio
async def test_extract_with_pydantic_model(self, mock_stagehand_page):
"""Test extracting data with Pydantic model schema"""
async def test_extract_with_pydantic_model_returns_validated_model(self, mock_stagehand_page):
"""Test extracting data with custom Pydantic model returns validated model instance"""
mock_client = MagicMock()
mock_llm = MockLLMClient()
mock_client.llm = mock_llm
Expand All @@ -90,52 +121,41 @@ class ProductModel(BaseModel):
name: str
price: float
in_stock: bool = True
tags: list[str] = []

handler = ExtractHandler(mock_stagehand_page, mock_client, "")
mock_stagehand_page._page.content = AsyncMock(return_value="<html><body>Product page</body></html>")

# Mock get_accessibility_tree
with patch('stagehand.handlers.extract_handler.get_accessibility_tree') as mock_get_tree:
mock_get_tree.return_value = {
"simplified": "Product page accessibility tree content",
"idToUrl": {}
}
# Mock transform_url_strings_to_ids to avoid the subscripted generics bug
with patch('stagehand.handlers.extract_handler.transform_url_strings_to_ids') as mock_transform:
mock_transform.return_value = (ProductModel, [])

# Mock extract_inference
# Mock extract_inference - return data compatible with ProductModel
with patch('stagehand.handlers.extract_handler.extract_inference') as mock_extract_inference:
mock_extract_inference.return_value = {
"data": {
"name": "Wireless Mouse",
"price": 29.99,
"in_stock": True,
"tags": ["electronics", "computer", "accessories"]
"in_stock": True
},
"metadata": {"completed": True},
"prompt_tokens": 150,
"completion_tokens": 80,
"inference_time_ms": 1200
}

# Also need to mock _wait_for_settled_dom
mock_stagehand_page._wait_for_settled_dom = AsyncMock()

options = ExtractOptions(
instruction="extract product details",
schema_definition=ProductModel
)

options = ExtractOptions(instruction="extract product details")
# Pass ProductModel as schema parameter - should return ProductModel instance
result = await handler.extract(options, ProductModel)

assert isinstance(result, ExtractResult)
# The handler should now properly populate the result with a validated Pydantic model
assert result.data is not None
# Should return ProductModel instance due to validation
assert isinstance(result.data, ProductModel)
assert result.data.name == "Wireless Mouse"
assert result.data.price == 29.99
assert result.data.in_stock is True
assert result.data.tags == ["electronics", "computer", "accessories"]

# Verify the mocks were called
mock_get_tree.assert_called_once()
mock_extract_inference.assert_called_once()