"""Tests for schema-enforced structured extraction.""" import asyncio import json import tempfile from unittest.mock import AsyncMock import pytest from pydantic import ValidationError from pytest_httpserver import HTTPServer from browser_use.agent.views import ActionResult from browser_use.browser import BrowserProfile, BrowserSession from browser_use.filesystem.file_system import FileSystem from browser_use.llm.base import BaseChatModel from browser_use.llm.views import ChatInvokeCompletion from browser_use.tools.extraction.schema_utils import schema_dict_to_pydantic_model from browser_use.tools.extraction.views import ExtractionResult from browser_use.tools.service import Tools # --------------------------------------------------------------------------- # Unit tests: schema_dict_to_pydantic_model # --------------------------------------------------------------------------- class TestSchemaDictToPydanticModel: """Unit tests for the JSON-Schema → Pydantic model converter.""" def test_flat_object(self): schema = { 'type': 'object', 'properties': { 'name': {'type': 'string'}, 'age': {'type': 'integer'}, }, 'required': ['name', 'age'], } Model = schema_dict_to_pydantic_model(schema) instance = Model(name='Alice', age=30) assert instance.name == 'Alice' # type: ignore[attr-defined] assert instance.age == 30 # type: ignore[attr-defined] def test_nested_object(self): schema = { 'type': 'object', 'properties': { 'person': { 'type': 'object', 'properties': { 'first': {'type': 'string'}, 'last': {'type': 'string'}, }, 'required': ['first'], }, }, 'required': ['person'], } Model = schema_dict_to_pydantic_model(schema) instance = Model(person={'first': 'Bob', 'last': 'Smith'}) assert instance.person.first == 'Bob' # type: ignore[attr-defined] def test_array_of_objects(self): schema = { 'type': 'object', 'properties': { 'items': { 'type': 'array', 'items': { 'type': 'object', 'properties': { 'id': {'type': 'integer'}, 'label': {'type': 'string'}, }, 'required': ['id', 'label'], }, }, }, 'required': ['items'], } Model = schema_dict_to_pydantic_model(schema) instance = Model(items=[{'id': 1, 'label': 'a'}, {'id': 2, 'label': 'b'}]) assert len(instance.items) == 2 # type: ignore[attr-defined] assert instance.items[0].id == 1 # type: ignore[attr-defined] def test_array_of_primitives(self): schema = { 'type': 'object', 'properties': { 'tags': {'type': 'array', 'items': {'type': 'string'}}, }, 'required': ['tags'], } Model = schema_dict_to_pydantic_model(schema) instance = Model(tags=['a', 'b', 'c']) assert instance.tags == ['a', 'b', 'c'] # type: ignore[attr-defined] def test_enum_field(self): schema = { 'type': 'object', 'properties': { 'status': {'type': 'string', 'enum': ['active', 'inactive']}, }, 'required': ['status'], } Model = schema_dict_to_pydantic_model(schema) instance = Model(status='active') assert instance.status == 'active' # type: ignore[attr-defined] def test_optional_enum_defaults_to_none(self): """Non-required enum fields default to None, not an out-of-set empty string.""" schema = { 'type': 'object', 'properties': { 'name': {'type': 'string'}, 'priority': {'type': 'string', 'enum': ['low', 'medium', 'high']}, }, 'required': ['name'], } Model = schema_dict_to_pydantic_model(schema) instance = Model(name='task1') assert instance.priority is None # type: ignore[attr-defined] # Serialized output must not contain an out-of-set value dumped = instance.model_dump(mode='json') assert dumped['priority'] is None # When provided, value still works instance2 = Model(name='task2', priority='high') assert instance2.priority == 'high' # type: ignore[attr-defined] def test_optional_fields_get_type_appropriate_defaults(self): schema = { 'type': 'object', 'properties': { 'name': {'type': 'string'}, 'nickname': {'type': 'string'}, 'score': {'type': 'number'}, 'rank': {'type': 'integer'}, 'active': {'type': 'boolean'}, 'tags': {'type': 'array', 'items': {'type': 'string'}}, }, 'required': ['name'], } Model = schema_dict_to_pydantic_model(schema) instance = Model(name='Alice') assert instance.name == 'Alice' # type: ignore[attr-defined] assert instance.nickname == '' # type: ignore[attr-defined] assert instance.score == 0.0 # type: ignore[attr-defined] assert instance.rank == 0 # type: ignore[attr-defined] assert instance.active is False # type: ignore[attr-defined] assert instance.tags == [] # type: ignore[attr-defined] def test_optional_non_nullable_rejects_null(self): """Non-required fields that aren't nullable must reject explicit null.""" schema = { 'type': 'object', 'properties': { 'name': {'type': 'string'}, 'nickname': {'type': 'string'}, }, 'required': ['name'], } Model = schema_dict_to_pydantic_model(schema) with pytest.raises(ValidationError): Model(name='Alice', nickname=None) def test_optional_with_explicit_default(self): schema = { 'type': 'object', 'properties': { 'name': {'type': 'string'}, 'color': {'type': 'string', 'default': 'blue'}, }, 'required': ['name'], } Model = schema_dict_to_pydantic_model(schema) instance = Model(name='Alice') assert instance.color == 'blue' # type: ignore[attr-defined] def test_optional_nested_object_defaults_to_none(self): """Non-required nested objects fall back to None since constructing a default is not feasible.""" schema = { 'type': 'object', 'properties': { 'name': {'type': 'string'}, 'address': { 'type': 'object', 'properties': {'city': {'type': 'string'}}, 'required': ['city'], }, }, 'required': ['name'], } Model = schema_dict_to_pydantic_model(schema) instance = Model(name='Alice') assert instance.address is None # type: ignore[attr-defined] def test_model_name_from_title(self): schema = { 'title': 'ProductInfo', 'type': 'object', 'properties': {'sku': {'type': 'string'}}, 'required': ['sku'], } Model = schema_dict_to_pydantic_model(schema) assert Model.__name__ == 'ProductInfo' def test_model_validate_json_roundtrip(self): schema = { 'type': 'object', 'properties': { 'x': {'type': 'number'}, 'y': {'type': 'boolean'}, }, 'required': ['x', 'y'], } Model = schema_dict_to_pydantic_model(schema) instance = Model(x=3.14, y=True) raw = instance.model_dump_json() restored = Model.model_validate_json(raw) assert restored.x == instance.x # type: ignore[attr-defined] assert restored.y == instance.y # type: ignore[attr-defined] def test_rejects_ref(self): schema = { 'type': 'object', 'properties': {'item': {'$ref': '#/$defs/Item'}}, '$defs': {'Item': {'type': 'object', 'properties': {'name': {'type': 'string'}}}}, } with pytest.raises(ValueError, match='Unsupported JSON Schema keyword'): schema_dict_to_pydantic_model(schema) def test_rejects_allOf(self): schema = { 'type': 'object', 'properties': {'x': {'allOf': [{'type': 'string'}]}}, } with pytest.raises(ValueError, match='Unsupported JSON Schema keyword'): schema_dict_to_pydantic_model(schema) def test_rejects_non_object_toplevel(self): with pytest.raises(ValueError, match='type "object"'): schema_dict_to_pydantic_model({'type': 'array', 'items': {'type': 'string'}}) def test_rejects_empty_properties(self): with pytest.raises(ValueError, match='at least one property'): schema_dict_to_pydantic_model({'type': 'object', 'properties': {}}) def test_extra_fields_forbidden(self): schema = { 'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name'], } Model = schema_dict_to_pydantic_model(schema) with pytest.raises(ValidationError): Model(name='ok', bogus='nope') def test_nullable_field(self): schema = { 'type': 'object', 'properties': { 'value': {'type': 'string', 'nullable': True}, }, 'required': ['value'], } Model = schema_dict_to_pydantic_model(schema) instance = Model(value=None) assert instance.value is None # type: ignore[attr-defined] def test_field_descriptions_preserved(self): schema = { 'type': 'object', 'properties': { 'price': {'type': 'number', 'description': 'The price in USD'}, }, 'required': ['price'], } Model = schema_dict_to_pydantic_model(schema) field_info = Model.model_fields['price'] assert field_info.description == 'The price in USD' # --------------------------------------------------------------------------- # Unit tests: ExtractionResult # --------------------------------------------------------------------------- class TestExtractionResult: def test_construction(self): er = ExtractionResult( data={'name': 'Alice'}, schema_used={'type': 'object', 'properties': {'name': {'type': 'string'}}}, ) assert er.data == {'name': 'Alice'} assert er.is_partial is False assert er.source_url is None def test_serialization_roundtrip(self): er = ExtractionResult( data={'items': [1, 2]}, schema_used={'type': 'object', 'properties': {'items': {'type': 'array'}}}, is_partial=True, source_url='http://example.com', content_stats={'original_html_chars': 5000}, ) raw = er.model_dump_json() restored = ExtractionResult.model_validate_json(raw) assert restored == er # --------------------------------------------------------------------------- # Integration tests: extract action via Tools # --------------------------------------------------------------------------- def _make_extraction_llm(structured_response: dict | None = None, freetext_response: str = 'free text result') -> BaseChatModel: """Create a mock LLM that handles both structured and freetext extraction calls.""" llm = AsyncMock(spec=BaseChatModel) llm.model = 'mock-extraction-llm' llm._verified_api_keys = True llm.provider = 'mock' llm.name = 'mock-extraction-llm' llm.model_name = 'mock-extraction-llm' async def mock_ainvoke(messages, output_format=None, **kwargs): if output_format is not None and structured_response is not None: # Structured path: parse the dict through the model instance = output_format.model_validate(structured_response) return ChatInvokeCompletion(completion=instance, usage=None) # Freetext path return ChatInvokeCompletion(completion=freetext_response, usage=None) llm.ainvoke.side_effect = mock_ainvoke return llm @pytest.fixture(scope='module') async def browser_session(): session = BrowserSession(browser_profile=BrowserProfile(headless=True, user_data_dir=None, keep_alive=True)) await session.start() yield session await session.kill() await session.event_bus.stop(clear=True, timeout=5) @pytest.fixture(scope='session') def http_server(): server = HTTPServer() server.start() server.expect_request('/products').respond_with_data( """