From 1ba72f8139c1dd0bce6c8130fd13195eb158395e Mon Sep 17 00:00:00 2001 From: Kevin Orellana Date: Sun, 22 Feb 2026 20:49:26 -0800 Subject: [PATCH] feat: add SessionConfiguration with proxy, extensions, and profile support Replace ad-hoc TypedDicts in browser_client.py with composable dataclasses in config.py following the established BrowserConfiguration pattern: - ProxyCredentials, ExternalProxy, ProxyConfiguration for proxy routing - ExtensionS3Location, BrowserExtension for loading browser extensions - SessionConfiguration composite that produces kwargs for start() - Add extensions and profile_configuration params to start() and browser_session() Usage: client.start(**session_config.to_dict()) --- CHANGELOG.md | 5 + pyproject.toml | 4 +- src/bedrock_agentcore/tools/__init__.py | 16 + src/bedrock_agentcore/tools/browser_client.py | 82 ++- src/bedrock_agentcore/tools/config.py | 168 +++++ .../tools/test_browser_client.py | 488 +++++++++++++ tests/bedrock_agentcore/tools/test_config.py | 128 ++++ tests_integ/tools/test_browser_proxy.py | 674 ++++++++++++++++++ 8 files changed, 1557 insertions(+), 8 deletions(-) create mode 100644 tests_integ/tools/test_browser_proxy.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 24adc6a5..6f5d1764 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [Unreleased] + +### Added +- feat: add SessionConfiguration with proxy, extensions, and profile support for browser sessions (#274) + ## [1.3.2] - 2026-02-23 ### Added diff --git a/pyproject.toml b/pyproject.toml index fdd857b0..a63bb5e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,8 +26,8 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "boto3>=1.40.52", - "botocore>=1.40.52", + "boto3>=1.42.54", + "botocore>=1.42.54", "pydantic>=2.0.0,<2.41.3", "urllib3>=1.26.0", "starlette>=0.46.2", diff --git a/src/bedrock_agentcore/tools/__init__.py b/src/bedrock_agentcore/tools/__init__.py index 5d84c2a8..fb10180c 100644 --- a/src/bedrock_agentcore/tools/__init__.py +++ b/src/bedrock_agentcore/tools/__init__.py @@ -3,26 +3,42 @@ from .browser_client import BrowserClient, browser_session from .code_interpreter_client import CodeInterpreter, code_session from .config import ( + BasicAuth, BrowserConfiguration, + BrowserExtension, BrowserSigningConfiguration, CodeInterpreterConfiguration, + ExtensionS3Location, + ExternalProxy, NetworkConfiguration, + ProfileConfiguration, + ProxyConfiguration, + ProxyCredentials, RecordingConfiguration, + SessionConfiguration, ViewportConfiguration, VpcConfig, create_browser_config, ) __all__ = [ + "BasicAuth", "BrowserClient", "browser_session", "CodeInterpreter", "code_session", "BrowserConfiguration", + "BrowserExtension", "BrowserSigningConfiguration", "CodeInterpreterConfiguration", + "ExtensionS3Location", + "ExternalProxy", "NetworkConfiguration", + "ProfileConfiguration", + "ProxyConfiguration", + "ProxyCredentials", "RecordingConfiguration", + "SessionConfiguration", "ViewportConfiguration", "VpcConfig", "create_browser_config", diff --git a/src/bedrock_agentcore/tools/browser_client.py b/src/bedrock_agentcore/tools/browser_client.py index 3ec2413d..26db845d 100644 --- a/src/bedrock_agentcore/tools/browser_client.py +++ b/src/bedrock_agentcore/tools/browser_client.py @@ -11,7 +11,7 @@ import secrets import uuid from contextlib import contextmanager -from typing import Dict, Generator, Optional, Tuple +from typing import Any, Dict, Generator, List, Optional, Tuple, Union from urllib.parse import urlparse import boto3 @@ -22,6 +22,13 @@ from bedrock_agentcore._utils.user_agent import build_user_agent_suffix from .._utils.endpoints import get_control_plane_endpoint, get_data_plane_endpoint +from .config import BrowserExtension, ProfileConfiguration, ProxyConfiguration, ViewportConfiguration + + +def _to_dict(value): + """Convert a dataclass or dict to a dict. Passes dicts through unchanged.""" + return value.to_dict() if hasattr(value, "to_dict") else value + DEFAULT_IDENTIFIER = "aws.browser.v1" DEFAULT_SESSION_TIMEOUT = 3600 @@ -288,7 +295,10 @@ def start( identifier: Optional[str] = DEFAULT_IDENTIFIER, name: Optional[str] = None, session_timeout_seconds: Optional[int] = DEFAULT_SESSION_TIMEOUT, - viewport: Optional[Dict[str, int]] = None, + viewport: Optional[Union[ViewportConfiguration, Dict[str, int]]] = None, + proxy_configuration: Optional[Union[ProxyConfiguration, Dict[str, Any]]] = None, + extensions: Optional[List[Union[BrowserExtension, Dict[str, Any]]]] = None, + profile_configuration: Optional[Union[ProfileConfiguration, Dict[str, Any]]] = None, ) -> str: """Start a browser sandbox session. @@ -300,8 +310,20 @@ def start( name (Optional[str]): A name for this session. session_timeout_seconds (Optional[int]): The timeout for the session in seconds. Range: 1-28800 (8 hours). Default: 3600 (1 hour). - viewport (Optional[Dict[str, int]]): The viewport dimensions: + viewport (Optional[Union[ViewportConfiguration, Dict[str, int]]]): The viewport + dimensions. Can be a ViewportConfiguration dataclass or a plain dict: {'width': 1920, 'height': 1080} + proxy_configuration (Optional[Union[ProxyConfiguration, Dict[str, Any]]]): Proxy + configuration for routing browser traffic through external proxy servers. + Can be a ProxyConfiguration dataclass or a plain dict matching the API shape. + extensions (Optional[List[Union[BrowserExtension, Dict[str, Any]]]]): List of + browser extensions to load into the session. Each element can be a + BrowserExtension dataclass or a plain dict: + [{"location": {"s3": {"bucket": "...", "prefix": "..."}}}] + profile_configuration (Optional[Union[ProfileConfiguration, Dict[str, Any]]]): Profile + configuration for persisting browser state across sessions. Can be a + ProfileConfiguration dataclass or a plain dict: + {"profileIdentifier": "my-profile-id"} Returns: str: The session ID of the newly created session. @@ -316,6 +338,20 @@ def start( ... viewport={'width': 1920, 'height': 1080}, ... session_timeout_seconds=7200 # 2 hours ... ) + >>> + >>> # Use proxy configuration + >>> session_id = client.start( + ... proxy_configuration={ + ... "proxies": [{ + ... "externalProxy": { + ... "server": "proxy.example.com", + ... "port": 8080, + ... "domainPatterns": [".example.com"], + ... } + ... }], + ... "bypass": {"domainPatterns": [".amazonaws.com"]} + ... } + ... ) """ self.logger.info("Starting browser session...") @@ -326,7 +362,16 @@ def start( } if viewport is not None: - request_params["viewPort"] = viewport + request_params["viewPort"] = _to_dict(viewport) + + if proxy_configuration is not None: + request_params["proxyConfiguration"] = _to_dict(proxy_configuration) + + if extensions is not None: + request_params["extensions"] = [_to_dict(e) for e in extensions] + + if profile_configuration is not None: + request_params["profileConfiguration"] = _to_dict(profile_configuration) response = self.data_plane_client.start_browser_session(**request_params) @@ -581,14 +626,26 @@ def release_control(self): @contextmanager def browser_session( - region: str, viewport: Optional[Dict[str, int]] = None, identifier: Optional[str] = None + region: str, + viewport: Optional[Union[ViewportConfiguration, Dict[str, int]]] = None, + identifier: Optional[str] = None, + proxy_configuration: Optional[Union[ProxyConfiguration, Dict[str, Any]]] = None, + extensions: Optional[List[Union[BrowserExtension, Dict[str, Any]]]] = None, + profile_configuration: Optional[Union[ProfileConfiguration, Dict[str, Any]]] = None, ) -> Generator[BrowserClient, None, None]: """Context manager for creating and managing a browser sandbox session. Args: region (str): AWS region. - viewport (Optional[Dict[str, int]]): Viewport dimensions. + viewport (Optional[Union[ViewportConfiguration, Dict[str, int]]]): Viewport dimensions. + Can be a ViewportConfiguration dataclass or a plain dict. identifier (Optional[str]): Browser identifier (system or custom). + proxy_configuration (Optional[Union[ProxyConfiguration, Dict[str, Any]]]): Proxy + configuration. Can be a ProxyConfiguration dataclass or a plain dict. + extensions (Optional[List[Union[BrowserExtension, Dict[str, Any]]]]): Browser + extensions. Each element can be a BrowserExtension dataclass or a plain dict. + profile_configuration (Optional[Union[ProfileConfiguration, Dict[str, Any]]]): Profile + configuration. Can be a ProfileConfiguration dataclass or a plain dict. Yields: BrowserClient: An initialized and started browser client. @@ -602,6 +659,13 @@ def browser_session( >>> with browser_session('us-west-2', identifier='my-signed-browser') as client: ... # Automation with reduced CAPTCHA friction ... pass + ... + >>> # Use proxy configuration + >>> with browser_session('us-west-2', proxy_configuration={ + ... "proxies": [{"externalProxy": {"server": "proxy.corp.com", "port": 8080}}], + ... "bypass": {"domainPatterns": [".amazonaws.com"]} + ... }) as client: + ... ws_url, headers = client.generate_ws_headers() """ client = BrowserClient(region) start_kwargs = {} @@ -609,6 +673,12 @@ def browser_session( start_kwargs["viewport"] = viewport if identifier is not None: start_kwargs["identifier"] = identifier + if proxy_configuration is not None: + start_kwargs["proxy_configuration"] = proxy_configuration + if extensions is not None: + start_kwargs["extensions"] = extensions + if profile_configuration is not None: + start_kwargs["profile_configuration"] = profile_configuration client.start(**start_kwargs) diff --git a/src/bedrock_agentcore/tools/config.py b/src/bedrock_agentcore/tools/config.py index bf5312fc..6e521394 100644 --- a/src/bedrock_agentcore/tools/config.py +++ b/src/bedrock_agentcore/tools/config.py @@ -198,6 +198,174 @@ def mobile(cls) -> "ViewportConfiguration": return cls(width=375, height=667) +@dataclass +class BasicAuth: + """HTTP Basic Auth credentials stored in Secrets Manager. + + Attributes: + secret_arn: ARN of the Secrets Manager secret containing + {"username": "...", "password": "..."} JSON + """ + + secret_arn: str + + def to_dict(self) -> Dict: + """Convert to API-compatible dictionary.""" + return {"secretArn": self.secret_arn} + + +@dataclass +class ProxyCredentials: + """Credentials for authenticating with a proxy server. + + Currently supports HTTP Basic Auth. Modeled as a union to allow + future credential types (bearer token, mTLS, etc.) without breaking changes. + + Attributes: + basic_auth: HTTP Basic Auth credentials via Secrets Manager + """ + + basic_auth: Optional[BasicAuth] = None + + def to_dict(self) -> Dict: + """Convert to API-compatible dictionary.""" + creds = {} + if self.basic_auth: + creds["basicAuth"] = self.basic_auth.to_dict() + return creds + + +@dataclass +class ExternalProxy: + """Configuration for an external proxy server. + + Attributes: + server: Proxy server hostname + port: Proxy server port + domain_patterns: Domain patterns to route through this proxy + credentials: Optional credentials for proxy authentication + """ + + server: str + port: int + domain_patterns: Optional[List[str]] = None + credentials: Optional[ProxyCredentials] = None + + def to_dict(self) -> Dict: + """Convert to API-compatible dictionary.""" + proxy = {"server": self.server, "port": self.port} + if self.domain_patterns: + proxy["domainPatterns"] = self.domain_patterns + if self.credentials: + proxy["credentials"] = self.credentials.to_dict() + return {"externalProxy": proxy} + + +@dataclass +class ProxyConfiguration: + """Proxy configuration for routing browser traffic through external proxy servers. + + Attributes: + proxies: List of external proxy configurations + bypass_patterns: Domain patterns that bypass all proxies + """ + + proxies: List[ExternalProxy] + bypass_patterns: Optional[List[str]] = None + + def to_dict(self) -> Dict: + """Convert to API-compatible dictionary.""" + config = {"proxies": [p.to_dict() for p in self.proxies]} + if self.bypass_patterns: + config["bypass"] = {"domainPatterns": self.bypass_patterns} + return config + + +@dataclass +class ExtensionS3Location: + """S3 location for a browser extension. + + Attributes: + bucket: S3 bucket name + prefix: S3 key prefix for the extension + version_id: Optional S3 object version ID + """ + + bucket: str + prefix: str + version_id: Optional[str] = None + + def to_dict(self) -> Dict: + """Convert to API-compatible dictionary.""" + location = {"bucket": self.bucket, "prefix": self.prefix} + if self.version_id: + location["versionId"] = self.version_id + return location + + +@dataclass +class BrowserExtension: + """A browser extension to load into a session. + + Attributes: + s3_location: S3 location of the extension package + """ + + s3_location: ExtensionS3Location + + def to_dict(self) -> Dict: + """Convert to API-compatible dictionary.""" + return {"location": {"s3": self.s3_location.to_dict()}} + + +@dataclass +class ProfileConfiguration: + """Profile configuration for persisting browser state across sessions. + + Attributes: + profile_identifier: Identifier for the browser profile + """ + + profile_identifier: str + + def to_dict(self) -> Dict: + """Convert to API-compatible dictionary.""" + return {"profileIdentifier": self.profile_identifier} + + +@dataclass +class SessionConfiguration: + """Complete session configuration for start(). + + Bundles all session-level parameters into one composable type. + Usage: client.start(**session_config.to_dict()) + + Attributes: + viewport: Viewport dimensions for the browser session + proxy: Proxy configuration for routing browser traffic + extensions: Browser extensions to load into the session + profile: Profile configuration for persisting browser state + """ + + viewport: Optional[ViewportConfiguration] = None + proxy: Optional[ProxyConfiguration] = None + extensions: Optional[List[BrowserExtension]] = None + profile: Optional[ProfileConfiguration] = None + + def to_dict(self) -> Dict: + """Convert to API-compatible dictionary.""" + config = {} + if self.viewport: + config["viewport"] = self.viewport.to_dict() + if self.proxy: + config["proxy_configuration"] = self.proxy.to_dict() + if self.extensions: + config["extensions"] = [e.to_dict() for e in self.extensions] + if self.profile: + config["profile_configuration"] = self.profile.to_dict() + return config + + @dataclass class BrowserConfiguration: """Complete browser configuration for create_browser. diff --git a/tests/bedrock_agentcore/tools/test_browser_client.py b/tests/bedrock_agentcore/tools/test_browser_client.py index f4a112a2..376eea0e 100644 --- a/tests/bedrock_agentcore/tools/test_browser_client.py +++ b/tests/bedrock_agentcore/tools/test_browser_client.py @@ -8,6 +8,16 @@ BrowserClient, browser_session, ) +from bedrock_agentcore.tools.config import ( + BasicAuth, + BrowserExtension, + ExtensionS3Location, + ExternalProxy, + ProfileConfiguration, + ProxyConfiguration, + ProxyCredentials, + ViewportConfiguration, +) class TestBrowserClient: @@ -871,3 +881,481 @@ def test_browser_session_context_manager_with_all_params(self, mock_client_class mock_client_class.assert_called_once_with("us-west-2") mock_client.start.assert_called_once_with(viewport=viewport, identifier="custom-browser") mock_client.stop.assert_called_once() + + @patch("bedrock_agentcore.tools.browser_client.get_control_plane_endpoint") + @patch("bedrock_agentcore.tools.browser_client.get_data_plane_endpoint") + @patch("bedrock_agentcore.tools.browser_client.boto3") + @patch("bedrock_agentcore.tools.browser_client.uuid.uuid4") + def test_start_with_proxy_configuration( + self, mock_uuid4, mock_boto3, mock_get_data_endpoint, mock_get_control_endpoint + ): + # Arrange + mock_boto3.client.return_value = MagicMock() + mock_uuid4.return_value.hex = "12345678abcdef" + + client = BrowserClient("us-west-2") + mock_response = {"browserIdentifier": "aws.browser.v1", "sessionId": "session-123"} + client.data_plane_client.start_browser_session.return_value = mock_response + + proxy_config = { + "proxies": [ + { + "externalProxy": { + "server": "proxy.example.com", + "port": 8080, + "domainPatterns": [".example.com", ".internal.corp"], + "credentials": { + "basicAuth": { + "secretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:proxy-creds" + } + }, + } + } + ], + "bypass": {"domainPatterns": [".amazonaws.com", "169.254.169.254"]}, + } + + # Act + session_id = client.start(proxy_configuration=proxy_config) + + # Assert + client.data_plane_client.start_browser_session.assert_called_once_with( + browserIdentifier="aws.browser.v1", + name="browser-session-12345678", + sessionTimeoutSeconds=3600, + proxyConfiguration=proxy_config, + ) + assert session_id == "session-123" + assert client.identifier == "aws.browser.v1" + assert client.session_id == "session-123" + + @patch("bedrock_agentcore.tools.browser_client.get_control_plane_endpoint") + @patch("bedrock_agentcore.tools.browser_client.get_data_plane_endpoint") + @patch("bedrock_agentcore.tools.browser_client.boto3") + @patch("bedrock_agentcore.tools.browser_client.uuid.uuid4") + def test_start_with_proxy_configuration_and_viewport( + self, mock_uuid4, mock_boto3, mock_get_data_endpoint, mock_get_control_endpoint + ): + # Arrange + mock_boto3.client.return_value = MagicMock() + mock_uuid4.return_value.hex = "12345678abcdef" + + client = BrowserClient("us-west-2") + mock_response = {"browserIdentifier": "aws.browser.v1", "sessionId": "session-123"} + client.data_plane_client.start_browser_session.return_value = mock_response + + viewport = {"width": 1920, "height": 1080} + proxy_config = { + "proxies": [ + { + "externalProxy": { + "server": "proxy.example.com", + "port": 8080, + } + } + ], + } + + # Act + session_id = client.start(viewport=viewport, proxy_configuration=proxy_config) + + # Assert + client.data_plane_client.start_browser_session.assert_called_once_with( + browserIdentifier="aws.browser.v1", + name="browser-session-12345678", + sessionTimeoutSeconds=3600, + viewPort=viewport, + proxyConfiguration=proxy_config, + ) + assert session_id == "session-123" + + @patch("bedrock_agentcore.tools.browser_client.get_control_plane_endpoint") + @patch("bedrock_agentcore.tools.browser_client.get_data_plane_endpoint") + @patch("bedrock_agentcore.tools.browser_client.boto3") + @patch("bedrock_agentcore.tools.browser_client.uuid.uuid4") + def test_start_with_proxy_configuration_multiple_proxies( + self, mock_uuid4, mock_boto3, mock_get_data_endpoint, mock_get_control_endpoint + ): + # Arrange + mock_boto3.client.return_value = MagicMock() + mock_uuid4.return_value.hex = "12345678abcdef" + + client = BrowserClient("us-west-2") + mock_response = {"browserIdentifier": "aws.browser.v1", "sessionId": "session-123"} + client.data_plane_client.start_browser_session.return_value = mock_response + + proxy_config = { + "proxies": [ + { + "externalProxy": { + "server": "proxy-us.example.com", + "port": 8080, + "domainPatterns": [".us.example.com"], + } + }, + { + "externalProxy": { + "server": "proxy-eu.example.com", + "port": 3128, + "domainPatterns": [".eu.example.com"], + "credentials": { + "basicAuth": { + "secretArn": "arn:aws:secretsmanager:eu-west-1:123456789012:secret:eu-proxy-creds" + } + }, + } + }, + ], + "bypass": {"domainPatterns": [".amazonaws.com"]}, + } + + # Act + session_id = client.start(proxy_configuration=proxy_config) + + # Assert + client.data_plane_client.start_browser_session.assert_called_once_with( + browserIdentifier="aws.browser.v1", + name="browser-session-12345678", + sessionTimeoutSeconds=3600, + proxyConfiguration=proxy_config, + ) + assert session_id == "session-123" + + @patch("bedrock_agentcore.tools.browser_client.get_control_plane_endpoint") + @patch("bedrock_agentcore.tools.browser_client.get_data_plane_endpoint") + @patch("bedrock_agentcore.tools.browser_client.boto3") + @patch("bedrock_agentcore.tools.browser_client.uuid.uuid4") + def test_start_without_proxy_configuration_unchanged( + self, mock_uuid4, mock_boto3, mock_get_data_endpoint, mock_get_control_endpoint + ): + # Arrange + mock_boto3.client.return_value = MagicMock() + mock_uuid4.return_value.hex = "12345678abcdef" + + client = BrowserClient("us-west-2") + mock_response = {"browserIdentifier": "aws.browser.v1", "sessionId": "session-123"} + client.data_plane_client.start_browser_session.return_value = mock_response + + # Act + session_id = client.start() + + # Assert - proxyConfiguration should NOT be in the call + client.data_plane_client.start_browser_session.assert_called_once_with( + browserIdentifier="aws.browser.v1", + name="browser-session-12345678", + sessionTimeoutSeconds=3600, + ) + assert session_id == "session-123" + + @patch("bedrock_agentcore.tools.browser_client.BrowserClient") + def test_browser_session_context_manager_with_proxy_configuration(self, mock_client_class): + # Arrange + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + proxy_config = { + "proxies": [ + { + "externalProxy": { + "server": "proxy.example.com", + "port": 8080, + "domainPatterns": [".example.com"], + } + } + ], + "bypass": {"domainPatterns": [".amazonaws.com"]}, + } + + # Act + with browser_session("us-west-2", proxy_configuration=proxy_config): + pass + + # Assert + mock_client_class.assert_called_once_with("us-west-2") + mock_client.start.assert_called_once_with(proxy_configuration=proxy_config) + mock_client.stop.assert_called_once() + + @patch("bedrock_agentcore.tools.browser_client.BrowserClient") + def test_browser_session_context_manager_with_all_params_including_proxy(self, mock_client_class): + # Arrange + mock_client = MagicMock() + mock_client_class.return_value = mock_client + viewport = {"width": 1280, "height": 720} + + proxy_config = { + "proxies": [ + { + "externalProxy": { + "server": "proxy.example.com", + "port": 8080, + } + } + ], + } + + # Act + with browser_session( + "us-west-2", + viewport=viewport, + identifier="custom-browser", + proxy_configuration=proxy_config, + ): + pass + + # Assert + mock_client_class.assert_called_once_with("us-west-2") + mock_client.start.assert_called_once_with( + viewport=viewport, identifier="custom-browser", proxy_configuration=proxy_config + ) + mock_client.stop.assert_called_once() + + @patch("bedrock_agentcore.tools.browser_client.get_control_plane_endpoint") + @patch("bedrock_agentcore.tools.browser_client.get_data_plane_endpoint") + @patch("bedrock_agentcore.tools.browser_client.boto3") + @patch("bedrock_agentcore.tools.browser_client.uuid.uuid4") + def test_start_with_extensions(self, mock_uuid4, mock_boto3, mock_get_data_endpoint, mock_get_control_endpoint): + # Arrange + mock_boto3.client.return_value = MagicMock() + mock_uuid4.return_value.hex = "12345678abcdef" + + client = BrowserClient("us-west-2") + mock_response = {"browserIdentifier": "aws.browser.v1", "sessionId": "session-123"} + client.data_plane_client.start_browser_session.return_value = mock_response + + extensions = [{"location": {"s3": {"bucket": "my-bucket", "prefix": "extensions/my-ext"}}}] + + # Act + session_id = client.start(extensions=extensions) + + # Assert + client.data_plane_client.start_browser_session.assert_called_once_with( + browserIdentifier="aws.browser.v1", + name="browser-session-12345678", + sessionTimeoutSeconds=3600, + extensions=extensions, + ) + assert session_id == "session-123" + + @patch("bedrock_agentcore.tools.browser_client.get_control_plane_endpoint") + @patch("bedrock_agentcore.tools.browser_client.get_data_plane_endpoint") + @patch("bedrock_agentcore.tools.browser_client.boto3") + @patch("bedrock_agentcore.tools.browser_client.uuid.uuid4") + def test_start_with_profile_configuration( + self, mock_uuid4, mock_boto3, mock_get_data_endpoint, mock_get_control_endpoint + ): + # Arrange + mock_boto3.client.return_value = MagicMock() + mock_uuid4.return_value.hex = "12345678abcdef" + + client = BrowserClient("us-west-2") + mock_response = {"browserIdentifier": "aws.browser.v1", "sessionId": "session-123"} + client.data_plane_client.start_browser_session.return_value = mock_response + + profile_config = {"profileIdentifier": "my-profile-id"} + + # Act + session_id = client.start(profile_configuration=profile_config) + + # Assert + client.data_plane_client.start_browser_session.assert_called_once_with( + browserIdentifier="aws.browser.v1", + name="browser-session-12345678", + sessionTimeoutSeconds=3600, + profileConfiguration=profile_config, + ) + assert session_id == "session-123" + + @patch("bedrock_agentcore.tools.browser_client.get_control_plane_endpoint") + @patch("bedrock_agentcore.tools.browser_client.get_data_plane_endpoint") + @patch("bedrock_agentcore.tools.browser_client.boto3") + @patch("bedrock_agentcore.tools.browser_client.uuid.uuid4") + def test_start_with_all_session_params( + self, mock_uuid4, mock_boto3, mock_get_data_endpoint, mock_get_control_endpoint + ): + # Arrange + mock_boto3.client.return_value = MagicMock() + mock_uuid4.return_value.hex = "12345678abcdef" + + client = BrowserClient("us-west-2") + mock_response = {"browserIdentifier": "aws.browser.v1", "sessionId": "session-123"} + client.data_plane_client.start_browser_session.return_value = mock_response + + viewport = {"width": 1920, "height": 1080} + proxy_config = { + "proxies": [{"externalProxy": {"server": "proxy.example.com", "port": 8080}}], + } + extensions = [{"location": {"s3": {"bucket": "my-bucket", "prefix": "extensions/my-ext"}}}] + profile_config = {"profileIdentifier": "my-profile-id"} + + # Act + session_id = client.start( + viewport=viewport, + proxy_configuration=proxy_config, + extensions=extensions, + profile_configuration=profile_config, + ) + + # Assert + client.data_plane_client.start_browser_session.assert_called_once_with( + browserIdentifier="aws.browser.v1", + name="browser-session-12345678", + sessionTimeoutSeconds=3600, + viewPort=viewport, + proxyConfiguration=proxy_config, + extensions=extensions, + profileConfiguration=profile_config, + ) + assert session_id == "session-123" + + @patch("bedrock_agentcore.tools.browser_client.BrowserClient") + def test_browser_session_context_manager_with_extensions(self, mock_client_class): + # Arrange + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + extensions = [{"location": {"s3": {"bucket": "my-bucket", "prefix": "extensions/my-ext"}}}] + + # Act + with browser_session("us-west-2", extensions=extensions): + pass + + # Assert + mock_client_class.assert_called_once_with("us-west-2") + mock_client.start.assert_called_once_with(extensions=extensions) + mock_client.stop.assert_called_once() + + @patch("bedrock_agentcore.tools.browser_client.BrowserClient") + def test_browser_session_context_manager_with_profile_configuration(self, mock_client_class): + # Arrange + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + profile_config = {"profileIdentifier": "my-profile-id"} + + # Act + with browser_session("us-west-2", profile_configuration=profile_config): + pass + + # Assert + mock_client_class.assert_called_once_with("us-west-2") + mock_client.start.assert_called_once_with(profile_configuration=profile_config) + mock_client.stop.assert_called_once() + + @patch("bedrock_agentcore.tools.browser_client.get_control_plane_endpoint") + @patch("bedrock_agentcore.tools.browser_client.get_data_plane_endpoint") + @patch("bedrock_agentcore.tools.browser_client.boto3") + @patch("bedrock_agentcore.tools.browser_client.uuid.uuid4") + def test_start_with_proxy_configuration_dataclass( + self, mock_uuid4, mock_boto3, mock_get_data_endpoint, mock_get_control_endpoint + ): + # Arrange + mock_boto3.client.return_value = MagicMock() + mock_uuid4.return_value.hex = "12345678abcdef" + + client = BrowserClient("us-west-2") + mock_response = {"browserIdentifier": "aws.browser.v1", "sessionId": "session-123"} + client.data_plane_client.start_browser_session.return_value = mock_response + + proxy_dataclass = ProxyConfiguration( + proxies=[ + ExternalProxy( + server="proxy.example.com", + port=8080, + domain_patterns=[".example.com"], + credentials=ProxyCredentials( + basic_auth=BasicAuth( + secret_arn="arn:aws:secretsmanager:us-east-1:123456789012:secret:proxy-creds" + ) + ), + ) + ], + bypass_patterns=[".amazonaws.com"], + ) + + # Act + session_id = client.start(proxy_configuration=proxy_dataclass) + + # Assert + expected_dict = { + "proxies": [ + { + "externalProxy": { + "server": "proxy.example.com", + "port": 8080, + "domainPatterns": [".example.com"], + "credentials": { + "basicAuth": { + "secretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:proxy-creds" + } + }, + } + } + ], + "bypass": {"domainPatterns": [".amazonaws.com"]}, + } + client.data_plane_client.start_browser_session.assert_called_once_with( + browserIdentifier="aws.browser.v1", + name="browser-session-12345678", + sessionTimeoutSeconds=3600, + proxyConfiguration=expected_dict, + ) + assert session_id == "session-123" + + @patch("bedrock_agentcore.tools.browser_client.get_control_plane_endpoint") + @patch("bedrock_agentcore.tools.browser_client.get_data_plane_endpoint") + @patch("bedrock_agentcore.tools.browser_client.boto3") + @patch("bedrock_agentcore.tools.browser_client.uuid.uuid4") + def test_start_with_extensions_dataclass( + self, mock_uuid4, mock_boto3, mock_get_data_endpoint, mock_get_control_endpoint + ): + # Arrange + mock_boto3.client.return_value = MagicMock() + mock_uuid4.return_value.hex = "12345678abcdef" + + client = BrowserClient("us-west-2") + mock_response = {"browserIdentifier": "aws.browser.v1", "sessionId": "session-123"} + client.data_plane_client.start_browser_session.return_value = mock_response + + extensions = [BrowserExtension(s3_location=ExtensionS3Location(bucket="my-bucket", prefix="extensions/my-ext"))] + + # Act + session_id = client.start(extensions=extensions) + + # Assert + client.data_plane_client.start_browser_session.assert_called_once_with( + browserIdentifier="aws.browser.v1", + name="browser-session-12345678", + sessionTimeoutSeconds=3600, + extensions=[{"location": {"s3": {"bucket": "my-bucket", "prefix": "extensions/my-ext"}}}], + ) + assert session_id == "session-123" + + @patch("bedrock_agentcore.tools.browser_client.BrowserClient") + def test_browser_session_accepts_dataclass_params(self, mock_client_class): + # Arrange + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + viewport = ViewportConfiguration(width=1280, height=720) + proxy = ProxyConfiguration( + proxies=[ExternalProxy(server="proxy.example.com", port=8080)], + ) + profile = ProfileConfiguration(profile_identifier="my-profile") + + # Act + with browser_session( + "us-west-2", + viewport=viewport, + proxy_configuration=proxy, + profile_configuration=profile, + ): + pass + + # Assert -- browser_session passes values through to start(), which handles conversion + mock_client_class.assert_called_once_with("us-west-2") + mock_client.start.assert_called_once_with( + viewport=viewport, + proxy_configuration=proxy, + profile_configuration=profile, + ) + mock_client.stop.assert_called_once() diff --git a/tests/bedrock_agentcore/tools/test_config.py b/tests/bedrock_agentcore/tools/test_config.py index 1938d5a1..4db60428 100644 --- a/tests/bedrock_agentcore/tools/test_config.py +++ b/tests/bedrock_agentcore/tools/test_config.py @@ -1,12 +1,20 @@ import pytest from bedrock_agentcore.tools.config import ( + BasicAuth, BrowserConfiguration, + BrowserExtension, BrowserSigningConfiguration, CodeInterpreterConfiguration, + ExtensionS3Location, + ExternalProxy, NetworkConfiguration, + ProfileConfiguration, + ProxyConfiguration, + ProxyCredentials, RecordingConfiguration, S3Location, + SessionConfiguration, ViewportConfiguration, VpcConfig, create_browser_config, @@ -464,3 +472,123 @@ def test_create_browser_config_full(self): assert result["recording"]["enabled"] is True assert result["browserSigning"]["enabled"] is True assert result["tags"] == {"Environment": "Test"} + + +class TestBasicAuth: + def test_to_dict(self): + auth = BasicAuth(secret_arn="arn:aws:secretsmanager:us-east-1:123456789012:secret:proxy-creds") + assert auth.to_dict() == {"secretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:proxy-creds"} + + +class TestProxyCredentials: + def test_to_dict_with_basic_auth(self): + creds = ProxyCredentials( + basic_auth=BasicAuth(secret_arn="arn:aws:secretsmanager:us-east-1:123456789012:secret:proxy-creds") + ) + assert creds.to_dict() == { + "basicAuth": {"secretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:proxy-creds"} + } + + def test_to_dict_empty(self): + creds = ProxyCredentials() + assert creds.to_dict() == {} + + +class TestExternalProxy: + def test_minimal_to_dict(self): + proxy = ExternalProxy(server="proxy.example.com", port=8080) + assert proxy.to_dict() == {"externalProxy": {"server": "proxy.example.com", "port": 8080}} + + def test_full_to_dict(self): + proxy = ExternalProxy( + server="proxy.example.com", + port=8080, + domain_patterns=[".example.com", ".internal.corp"], + credentials=ProxyCredentials( + basic_auth=BasicAuth(secret_arn="arn:aws:secretsmanager:us-east-1:123:secret:creds") + ), + ) + result = proxy.to_dict() + assert result == { + "externalProxy": { + "server": "proxy.example.com", + "port": 8080, + "domainPatterns": [".example.com", ".internal.corp"], + "credentials": {"basicAuth": {"secretArn": "arn:aws:secretsmanager:us-east-1:123:secret:creds"}}, + } + } + + +class TestProxyConfiguration: + def test_without_bypass(self): + config = ProxyConfiguration(proxies=[ExternalProxy(server="proxy.example.com", port=8080)]) + result = config.to_dict() + assert result == {"proxies": [{"externalProxy": {"server": "proxy.example.com", "port": 8080}}]} + + def test_with_bypass(self): + config = ProxyConfiguration( + proxies=[ExternalProxy(server="proxy.example.com", port=8080)], + bypass_patterns=[".amazonaws.com", "169.254.169.254"], + ) + result = config.to_dict() + assert result == { + "proxies": [{"externalProxy": {"server": "proxy.example.com", "port": 8080}}], + "bypass": {"domainPatterns": [".amazonaws.com", "169.254.169.254"]}, + } + + +class TestExtensionS3Location: + def test_minimal_to_dict(self): + location = ExtensionS3Location(bucket="my-bucket", prefix="extensions/my-ext") + assert location.to_dict() == {"bucket": "my-bucket", "prefix": "extensions/my-ext"} + + def test_with_version_id(self): + location = ExtensionS3Location(bucket="my-bucket", prefix="extensions/my-ext", version_id="abc123") + assert location.to_dict() == { + "bucket": "my-bucket", + "prefix": "extensions/my-ext", + "versionId": "abc123", + } + + +class TestBrowserExtension: + def test_to_dict(self): + ext = BrowserExtension(s3_location=ExtensionS3Location(bucket="my-bucket", prefix="extensions/my-ext")) + assert ext.to_dict() == {"location": {"s3": {"bucket": "my-bucket", "prefix": "extensions/my-ext"}}} + + +class TestSessionConfiguration: + def test_empty_to_dict(self): + config = SessionConfiguration() + assert config.to_dict() == {} + + def test_viewport_only(self): + config = SessionConfiguration(viewport=ViewportConfiguration.desktop_hd()) + assert config.to_dict() == {"viewport": {"width": 1920, "height": 1080}} + + def test_full_to_dict(self): + config = SessionConfiguration( + viewport=ViewportConfiguration(width=1280, height=720), + proxy=ProxyConfiguration( + proxies=[ExternalProxy(server="proxy.example.com", port=8080)], + bypass_patterns=[".amazonaws.com"], + ), + extensions=[BrowserExtension(s3_location=ExtensionS3Location(bucket="my-bucket", prefix="ext/v1"))], + profile=ProfileConfiguration(profile_identifier="my-profile-id"), + ) + result = config.to_dict() + assert result == { + "viewport": {"width": 1280, "height": 720}, + "proxy_configuration": { + "proxies": [{"externalProxy": {"server": "proxy.example.com", "port": 8080}}], + "bypass": {"domainPatterns": [".amazonaws.com"]}, + }, + "extensions": [{"location": {"s3": {"bucket": "my-bucket", "prefix": "ext/v1"}}}], + "profile_configuration": {"profileIdentifier": "my-profile-id"}, + } + + +class TestProfileConfiguration: + def test_to_dict(self): + config = ProfileConfiguration(profile_identifier="my-profile-id") + assert config.to_dict() == {"profileIdentifier": "my-profile-id"} diff --git a/tests_integ/tools/test_browser_proxy.py b/tests_integ/tools/test_browser_proxy.py new file mode 100644 index 00000000..41d635aa --- /dev/null +++ b/tests_integ/tools/test_browser_proxy.py @@ -0,0 +1,674 @@ +"""Integration tests for browser session configuration support. + +Tests proxy_configuration, extensions, profile_configuration, and SessionConfiguration +dataclasses against the live StartBrowserSession API. + +Requires: valid AWS credentials for us-west-2 with Admin role on account 121875801285. + +To run: python3 tests_integ/tools/test_browser_proxy.py +""" + +import sys + +from bedrock_agentcore.tools.browser_client import BrowserClient, browser_session +from bedrock_agentcore.tools.config import ( + BasicAuth, + BrowserExtension, + ExtensionS3Location, + ExternalProxy, + ProfileConfiguration, + ProxyConfiguration, + ProxyCredentials, + SessionConfiguration, + ViewportConfiguration, +) + +REGION = "us-west-2" + +# BrightData proxy config as plain dict (existing passthrough pattern) +BRIGHTDATA_PROXY_CONFIG = { + "proxies": [ + { + "externalProxy": { + "server": "brd.superproxy.io", + "port": 33335, + "domainPatterns": [ + ".icanhazip.com", + ".whoer.net", + ".httpbin.org", + ], + "credentials": { + "basicAuth": { + "secretArn": ( + "arn:aws:secretsmanager:us-west-2:121875801285" + ":secret:genesis1p-browser-proxy-test-brightdata-gJWalz" + ) + } + }, + } + } + ], + "bypass": { + "domainPatterns": [ + "checkip.amazonaws.com", + "169.254.169.254", + ] + }, +} + +# Same config expressed as dataclasses +BRIGHTDATA_PROXY_DATACLASS = ProxyConfiguration( + proxies=[ + ExternalProxy( + server="brd.superproxy.io", + port=33335, + domain_patterns=[".icanhazip.com", ".whoer.net", ".httpbin.org"], + credentials=ProxyCredentials( + basic_auth=BasicAuth( + secret_arn="arn:aws:secretsmanager:us-west-2:121875801285:secret:genesis1p-browser-proxy-test-brightdata-gJWalz" + ) + ), + ) + ], + bypass_patterns=["checkip.amazonaws.com", "169.254.169.254"], +) + + +def test_passthrough_browser_session(): + """Test 1: browser_session() accepts proxy_configuration dict and the API does not reject it.""" + print("Test 1: browser_session() with proxy_configuration (passthrough dict)") + with browser_session(REGION, proxy_configuration=BRIGHTDATA_PROXY_CONFIG) as client: + assert client.session_id is not None, "session_id should be set" + assert client.identifier is not None, "identifier should be set" + + url, headers = client.generate_ws_headers() + assert url.startswith("wss"), f"Expected wss URL, got: {url}" + + live_url = client.generate_live_view_url() + assert live_url.startswith("https"), f"Expected https URL, got: {live_url}" + + print(f" Session ID: {client.session_id}") + print(f" Live View: {live_url[:80]}...") + print(" PASSED") + + +def test_passthrough_client_start(): + """Test 2: BrowserClient.start() accepts proxy_configuration directly.""" + print("\nTest 2: BrowserClient.start() with proxy_configuration (passthrough dict)") + client = BrowserClient(REGION) + try: + session_id = client.start(proxy_configuration=BRIGHTDATA_PROXY_CONFIG) + assert session_id is not None, "session_id should be returned" + print(f" Session ID: {session_id}") + + session_info = client.get_session() + assert session_info["status"] == "READY", f"Expected READY, got: {session_info['status']}" + print(f" Status: {session_info['status']}") + finally: + client.stop() + print(" PASSED") + + +def test_passthrough_no_proxy_unchanged(): + """Test 3: Existing behavior without proxy_configuration still works.""" + print("\nTest 3: browser_session() without proxy_configuration (backward compat)") + with browser_session(REGION) as client: + assert client.session_id is not None + url, headers = client.generate_ws_headers() + assert url.startswith("wss") + print(f" Session ID: {client.session_id}") + print(" PASSED") + + +def test_proxy_with_viewport(): + """Test 4: proxy_configuration works alongside viewport.""" + print("\nTest 4: browser_session() with proxy_configuration + viewport") + with browser_session( + REGION, + viewport={"width": 1280, "height": 720}, + proxy_configuration=BRIGHTDATA_PROXY_CONFIG, + ) as client: + assert client.session_id is not None + print(f" Session ID: {client.session_id}") + print(" PASSED") + + +def test_proxy_dataclass(): + """Test 5: ProxyConfiguration dataclass produces valid API input.""" + print("\nTest 5: ProxyConfiguration dataclass -> start(proxy_configuration=...)") + proxy_dict = BRIGHTDATA_PROXY_DATACLASS.to_dict() + with browser_session(REGION, proxy_configuration=proxy_dict) as client: + assert client.session_id is not None + session_info = client.get_session() + assert session_info["status"] == "READY", f"Expected READY, got: {session_info['status']}" + print(f" Session ID: {client.session_id}") + print(f" Status: {session_info['status']}") + print(" PASSED") + + +def test_session_configuration_proxy_only(): + """Test 6: SessionConfiguration with proxy produces valid start() kwargs.""" + print("\nTest 6: SessionConfiguration(proxy=...) -> start(**config.to_dict())") + config = SessionConfiguration(proxy=BRIGHTDATA_PROXY_DATACLASS) + client = BrowserClient(REGION) + try: + session_id = client.start(**config.to_dict()) + assert session_id is not None + session_info = client.get_session() + assert session_info["status"] == "READY" + print(f" Session ID: {session_id}") + print(f" Status: {session_info['status']}") + finally: + client.stop() + print(" PASSED") + + +def test_session_configuration_proxy_and_viewport(): + """Test 7: SessionConfiguration with proxy + viewport.""" + print("\nTest 7: SessionConfiguration(proxy=..., viewport=...) -> start(**config.to_dict())") + config = SessionConfiguration( + proxy=BRIGHTDATA_PROXY_DATACLASS, + viewport=ViewportConfiguration(width=1280, height=720), + ) + client = BrowserClient(REGION) + try: + session_id = client.start(**config.to_dict()) + assert session_id is not None + session_info = client.get_session() + assert session_info["status"] == "READY" + print(f" Session ID: {session_id}") + print(f" Status: {session_info['status']}") + finally: + client.stop() + print(" PASSED") + + +def test_profile_configuration(): + """Test 8: profile_configuration parameter is accepted by the API. + + Note: Uses a placeholder profile ID -- the API may reject unknown profiles + with a validation error, which is still a valid test of parameter passthrough. + """ + print("\nTest 8: start(profile_configuration=...) parameter passthrough") + client = BrowserClient(REGION) + try: + session_id = client.start(profile_configuration={"profileIdentifier": "test-profile-placeholder"}) + assert session_id is not None + print(f" Session ID: {session_id}") + print(" PASSED (API accepted the parameter)") + except Exception as e: + error_msg = str(e) + # A validation error from the API means the parameter was passed through correctly + if "ValidationException" in error_msg or "validation" in error_msg.lower(): + print(" PASSED (API rejected with validation: parameter was passed through)") + else: + raise + finally: + client.stop() + + +def test_extensions_parameter(): + """Test 9: extensions parameter is accepted by the API. + + Note: Uses a placeholder S3 location -- the API may reject it, which still + validates the parameter passthrough. + """ + print("\nTest 9: start(extensions=...) parameter passthrough") + client = BrowserClient(REGION) + try: + session_id = client.start( + extensions=[{"location": {"s3": {"bucket": "nonexistent-test-bucket", "prefix": "ext/v1"}}}] + ) + assert session_id is not None + print(f" Session ID: {session_id}") + print(" PASSED (API accepted the parameter)") + except Exception as e: + error_msg = str(e) + expected_errors = ["ValidationException", "validation", "Access Denied", "NoSuchBucket"] + if any(e in error_msg or e in error_msg.lower() for e in expected_errors): + print(" PASSED (API rejected with expected error: parameter was passed through)") + else: + raise + finally: + client.stop() + + +def test_browser_session_extensions_param(): + """Test 10: browser_session() accepts extensions parameter.""" + print("\nTest 10: browser_session(extensions=...) parameter passthrough") + try: + with browser_session( + REGION, + extensions=[{"location": {"s3": {"bucket": "nonexistent-test-bucket", "prefix": "ext/v1"}}}], + ) as client: + assert client.session_id is not None + print(f" Session ID: {client.session_id}") + print(" PASSED (API accepted the parameter)") + except Exception as e: + error_msg = str(e) + expected_errors = ["ValidationException", "validation", "Access Denied", "NoSuchBucket"] + if any(e in error_msg or e in error_msg.lower() for e in expected_errors): + print(" PASSED (API rejected with expected error: parameter was passed through)") + else: + raise + + +def test_browser_session_profile_param(): + """Test 11: browser_session() accepts profile_configuration parameter.""" + print("\nTest 11: browser_session(profile_configuration=...) parameter passthrough") + try: + with browser_session( + REGION, + profile_configuration={"profileIdentifier": "test-profile-placeholder"}, + ) as client: + assert client.session_id is not None + print(f" Session ID: {client.session_id}") + print(" PASSED (API accepted the parameter)") + except Exception as e: + error_msg = str(e) + if "ValidationException" in error_msg or "validation" in error_msg.lower(): + print(" PASSED (API rejected with validation: parameter was passed through)") + else: + raise + + +def test_session_configuration_with_extensions_dataclass(): + """Test 12: SessionConfiguration with BrowserExtension dataclass. + + Uses a nonexistent S3 bucket, so expects either success or a + validation/access error -- both confirm the parameter was passed through. + """ + print("\nTest 12: SessionConfiguration(extensions=[BrowserExtension(...)]) dataclass") + config = SessionConfiguration( + extensions=[ + BrowserExtension( + s3_location=ExtensionS3Location( + bucket="nonexistent-test-bucket", + prefix="ext/v1", + ) + ) + ] + ) + client = BrowserClient(REGION) + try: + session_id = client.start(**config.to_dict()) + assert session_id is not None + print(f" Session ID: {session_id}") + print(" PASSED (API accepted the parameter)") + except Exception as e: + error_msg = str(e) + expected_errors = ["ValidationException", "validation", "Access Denied", "NoSuchBucket"] + if any(err in error_msg or err in error_msg.lower() for err in expected_errors): + print(" PASSED (API rejected with expected error: parameter was passed through)") + else: + raise + finally: + client.stop() + + +def test_session_configuration_with_profile_dataclass(): + """Test 13: SessionConfiguration with ProfileConfiguration dataclass. + + Uses a placeholder profile ID -- the API may reject unknown profiles + with a validation error, which still confirms parameter passthrough. + """ + print("\nTest 13: SessionConfiguration(profile=ProfileConfiguration(...)) dataclass") + config = SessionConfiguration( + profile=ProfileConfiguration(profile_identifier="test-profile-placeholder"), + ) + client = BrowserClient(REGION) + try: + session_id = client.start(**config.to_dict()) + assert session_id is not None + print(f" Session ID: {session_id}") + print(" PASSED (API accepted the parameter)") + except Exception as e: + error_msg = str(e) + if "ValidationException" in error_msg or "validation" in error_msg.lower(): + print(" PASSED (API rejected with validation: parameter was passed through)") + else: + raise + finally: + client.stop() + + +def test_session_configuration_all_fields(): + """Test 14: SessionConfiguration with all four fields. + + Combines viewport, proxy (BrightData), extensions (nonexistent bucket), + and profile (placeholder) into a single composite configuration. + """ + print("\nTest 14: SessionConfiguration with all fields (viewport + proxy + extensions + profile)") + config = SessionConfiguration( + viewport=ViewportConfiguration(width=1920, height=1080), + proxy=BRIGHTDATA_PROXY_DATACLASS, + extensions=[ + BrowserExtension( + s3_location=ExtensionS3Location( + bucket="nonexistent-test-bucket", + prefix="ext/v1", + ) + ) + ], + profile=ProfileConfiguration(profile_identifier="test-profile-placeholder"), + ) + client = BrowserClient(REGION) + try: + session_id = client.start(**config.to_dict()) + assert session_id is not None + print(f" Session ID: {session_id}") + print(" PASSED (API accepted the composite configuration)") + except Exception as e: + error_msg = str(e) + expected_errors = ["ValidationException", "validation", "Access Denied", "NoSuchBucket"] + if any(err in error_msg or err in error_msg.lower() for err in expected_errors): + print(" PASSED (API rejected with expected error: composite config was passed through)") + else: + raise + finally: + client.stop() + + +def test_browser_session_with_session_configuration(): + """Test 15: browser_session() driven by SessionConfiguration. + + Uses proxy (BrightData) + viewport to produce a READY session, + proving SessionConfiguration works end-to-end through browser_session(). + """ + print("\nTest 15: browser_session(**SessionConfiguration.to_dict()) end-to-end") + config = SessionConfiguration( + proxy=BRIGHTDATA_PROXY_DATACLASS, + viewport=ViewportConfiguration(width=1280, height=720), + ) + with browser_session(REGION, **config.to_dict()) as client: + assert client.session_id is not None, "session_id should be set" + assert client.identifier is not None, "identifier should be set" + + url, headers = client.generate_ws_headers() + assert url.startswith("wss"), f"Expected wss URL, got: {url}" + assert headers, "Expected non-empty ws headers" + + print(f" Session ID: {client.session_id}") + print(f" WS URL: {url[:80]}...") + print(" PASSED") + + +def test_double_stop_idempotent(): + """Test 16: Calling stop() twice does not raise. + + Verifies that stop() is idempotent -- the second call should return + True without error, whether or not the session is already terminated. + """ + print("\nTest 16: Double stop() is idempotent") + client = BrowserClient(REGION) + session_id = client.start() + assert session_id is not None + print(f" Session ID: {session_id}") + + result1 = client.stop() + assert result1 is True, f"First stop() should return True, got: {result1}" + print(" First stop() returned True") + + result2 = client.stop() + assert result2 is True, f"Second stop() should return True, got: {result2}" + print(" Second stop() returned True") + print(" PASSED") + + +def test_context_manager_cleanup_on_exception(): + """Test 17: browser_session() cleans up the session when an exception occurs. + + Raises inside the context manager and verifies the session was stopped + (identifier and session_id cleared by stop()). + """ + print("\nTest 17: Context manager cleanup on exception") + saved_client = None + saved_session_id = None + + try: + with browser_session(REGION) as client: + saved_client = client + saved_session_id = client.session_id + assert saved_session_id is not None + print(f" Session ID: {saved_session_id}") + raise RuntimeError("Simulated failure inside context manager") + except RuntimeError as e: + assert "Simulated failure" in str(e) + + # After the context manager exits, stop() should have cleared these + assert saved_client.session_id is None, "session_id should be None after cleanup" + assert saved_client.identifier is None, "identifier should be None after cleanup" + print(" Session cleaned up after exception") + print(" PASSED") + + +def test_get_session_after_stop(): + """Test 18: get_session() after stop() raises ValueError. + + After stop() clears session_id and identifier, calling get_session() + without explicit IDs should raise ValueError. + """ + print("\nTest 18: get_session() after stop() raises ValueError") + client = BrowserClient(REGION) + session_id = client.start() + assert session_id is not None + print(f" Session ID: {session_id}") + + client.stop() + + try: + client.get_session() + raise AssertionError("Expected ValueError but get_session() succeeded") + except ValueError as e: + assert "must be provided" in str(e).lower() or "must be provided" in str(e) + print(f" Raised ValueError: {e}") + print(" PASSED") + + +def test_invalid_secret_arn_proxy(): + """Test 19: Proxy with invalid/nonexistent secret ARN. + + Verifies the API rejects the configuration with a clear error rather + than silently starting a broken session. + """ + print("\nTest 19: Proxy with invalid secret ARN") + bad_proxy = ProxyConfiguration( + proxies=[ + ExternalProxy( + server="brd.superproxy.io", + port=33335, + domain_patterns=[".example.com"], + credentials=ProxyCredentials( + basic_auth=BasicAuth( + secret_arn="arn:aws:secretsmanager:us-west-2:121875801285:secret:nonexistent-secret-XXXXXX" + ) + ), + ) + ], + ) + client = BrowserClient(REGION) + try: + session_id = client.start(proxy_configuration=bad_proxy.to_dict()) + # If it starts, check if it reaches a failed state + print(f" Session ID: {session_id}") + session_info = client.get_session() + status = session_info["status"] + print(f" Status: {status}") + # Session may start but fail asynchronously -- either outcome is acceptable + print(" PASSED (API accepted; session may fail asynchronously)") + except Exception as e: + error_msg = str(e) + expected = ["ResourceNotFoundException", "AccessDeniedException", "ValidationException", "validation", "secret"] + if any(err in error_msg or err in error_msg.lower() for err in expected): + print(f" PASSED (API rejected with expected error: {type(e).__name__})") + else: + raise + finally: + client.stop() + + +def test_invalid_proxy_server(): + """Test 20: Proxy with unreachable server host/port. + + Verifies behavior when the proxy server is not reachable. The API may + accept the config (proxy is only used at browse-time) or reject it + during validation. + """ + print("\nTest 20: Proxy with unreachable server") + bad_proxy = ProxyConfiguration( + proxies=[ + ExternalProxy( + server="192.0.2.1", # TEST-NET, guaranteed unreachable + port=99999, + domain_patterns=[".example.com"], + ) + ], + ) + client = BrowserClient(REGION) + try: + session_id = client.start(proxy_configuration=bad_proxy.to_dict()) + print(f" Session ID: {session_id}") + session_info = client.get_session() + status = session_info["status"] + print(f" Status: {status}") + # Unreachable proxy may only fail at browse-time, not at session creation + print(" PASSED (API accepted config; proxy failure would occur at browse-time)") + except Exception as e: + error_msg = str(e) + expected = ["ValidationException", "validation", "port", "server"] + if any(err in error_msg or err in error_msg.lower() for err in expected): + print(f" PASSED (API rejected with validation error: {type(e).__name__})") + else: + raise + finally: + client.stop() + + +def test_malformed_proxy_config(): + """Test 21: Malformed proxy config with missing required fields. + + Passes a proxy dict missing the 'externalProxy' key to verify the API + returns a clean validation error rather than a 500. + """ + print("\nTest 21: Malformed proxy config (missing required fields)") + malformed_config = { + "proxies": [ + { + # Missing 'externalProxy' key entirely + "server": "proxy.example.com", + "port": 8080, + } + ] + } + client = BrowserClient(REGION) + try: + session_id = client.start(proxy_configuration=malformed_config) + print(f" Session ID: {session_id}") + print(" PASSED (API accepted malformed config -- lenient validation)") + except Exception as e: + error_msg = str(e) + # Should get a validation error, not a 500/InternalServerError + if "InternalServer" in error_msg or "500" in error_msg: + print(f" FAILED: Got internal server error instead of validation: {e}") + raise + print(f" PASSED (API rejected with: {type(e).__name__})") + finally: + client.stop() + + +def test_session_configuration_viewport_only(): + """Test 22: SessionConfiguration with viewport only (no proxy). + + Validates that SessionConfiguration works with just a viewport, + producing a READY session without any proxy or other optional fields. + """ + print("\nTest 22: SessionConfiguration(viewport=...) only") + config = SessionConfiguration( + viewport=ViewportConfiguration(width=800, height=600), + ) + client = BrowserClient(REGION) + try: + session_id = client.start(**config.to_dict()) + assert session_id is not None + session_info = client.get_session() + assert session_info["status"] == "READY", f"Expected READY, got: {session_info['status']}" + print(f" Session ID: {session_id}") + print(f" Status: {session_info['status']}") + finally: + client.stop() + print(" PASSED") + + +def test_multiple_extensions(): + """Test 23: SessionConfiguration with multiple extensions. + + Passes two extensions to verify the API handles a multi-element list, + not just a single-element one. + """ + print("\nTest 23: SessionConfiguration with multiple extensions") + config = SessionConfiguration( + extensions=[ + BrowserExtension( + s3_location=ExtensionS3Location(bucket="nonexistent-bucket-a", prefix="ext/a"), + ), + BrowserExtension( + s3_location=ExtensionS3Location(bucket="nonexistent-bucket-b", prefix="ext/b"), + ), + ] + ) + client = BrowserClient(REGION) + try: + session_id = client.start(**config.to_dict()) + assert session_id is not None + print(f" Session ID: {session_id}") + print(" PASSED (API accepted multiple extensions)") + except Exception as e: + error_msg = str(e) + expected_errors = ["ValidationException", "validation", "Access Denied", "NoSuchBucket"] + if any(err in error_msg or err in error_msg.lower() for err in expected_errors): + print(" PASSED (API rejected with expected error: multiple extensions passed through)") + else: + raise + finally: + client.stop() + + +if __name__ == "__main__": + tests = [ + test_passthrough_browser_session, + test_passthrough_client_start, + test_passthrough_no_proxy_unchanged, + test_proxy_with_viewport, + test_proxy_dataclass, + test_session_configuration_proxy_only, + test_session_configuration_proxy_and_viewport, + test_profile_configuration, + test_extensions_parameter, + test_browser_session_extensions_param, + test_browser_session_profile_param, + test_session_configuration_with_extensions_dataclass, + test_session_configuration_with_profile_dataclass, + test_session_configuration_all_fields, + test_browser_session_with_session_configuration, + test_double_stop_idempotent, + test_context_manager_cleanup_on_exception, + test_get_session_after_stop, + test_invalid_secret_arn_proxy, + test_invalid_proxy_server, + test_malformed_proxy_config, + test_session_configuration_viewport_only, + test_multiple_extensions, + ] + + failed = 0 + for test in tests: + try: + test() + except Exception as e: + print(f" FAILED: {e}") + failed += 1 + + print(f"\n{'=' * 40}") + print(f"Results: {len(tests) - failed}/{len(tests)} passed, {failed} failed") + if failed: + sys.exit(1)