diff --git a/examples/human_in_the_loop.py b/examples/human_in_the_loop.py index 96684cb..3bcf12a 100644 --- a/examples/human_in_the_loop.py +++ b/examples/human_in_the_loop.py @@ -1,8 +1,7 @@ import asyncio -from narada_core.actions.models import PromptForUserInputVariable - from narada import Agent, Narada, UserAbortedError +from narada_core.actions.models import PromptForUserInputVariable async def main() -> None: diff --git a/packages/narada/src/narada/client.py b/packages/narada/src/narada/client.py index a1b2093..1068201 100644 --- a/packages/narada/src/narada/client.py +++ b/packages/narada/src/narada/client.py @@ -177,11 +177,18 @@ async def open_and_initialize_cloud_browser_window( session_timeout: int | None = None, require_extension: bool = True, ) -> CloudBrowserWindow: - """Creates a cloud browser by calling the backend. - - The backend creates a cloud browser session and returns - a CDP WebSocket URL. This method connects to it, initializes the extension, - and returns a CloudBrowserWindow instance. + """Create a cloud browser session and return a ``CloudBrowserWindow``. + + With ``require_extension=True`` (default), calls + ``POST /cloud-browser/create-cloud-browser-session``, then connects local Playwright + over CDP, opens ``login_url``, and waits for ``#narada-browser-window-id`` (extension + install retries apply). ``config`` controls interactive prompts and related behavior. + + With ``require_extension=False``, calls + ``POST /cloud-browser/create-and-initialize-cloud-browser-session`` instead: the API + provisions the browser and runs the same CDP initialization on the server, returning + ``session_id`` and ``browser_window_id`` in the JSON body. Local Playwright is not used + for that path, and ``config`` is ignored. """ config = config or BrowserConfig() base_url = os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2") @@ -190,6 +197,41 @@ async def open_and_initialize_cloud_browser_window( "session_name": session_name, "session_timeout": session_timeout, } + + if not require_extension: + endpoint_url = ( + f"{base_url}/cloud-browser/create-and-initialize-cloud-browser-session" + ) + async with aiohttp.ClientSession() as session: + async with session.post( + endpoint_url, + headers=self._auth_headers, + json=request_body, + timeout=aiohttp.ClientTimeout(total=180), + ) as resp: + if not resp.ok: + error_text = await resp.text() + if resp.status == HTTPStatus.FORBIDDEN: + error = ApiErrorPayload.from_error_text(error_text) + err = RuntimeError( + f"Failed to create cloud browser session: {resp.status} {error_text}\n" + f"Endpoint URL: {endpoint_url}" + ) + err.status_code = resp.status # type: ignore[attr-defined] + err.detail = error.detail # type: ignore[attr-defined] + raise err + raise RuntimeError( + f"Failed to create cloud browser session: {resp.status} {error_text}\n" + f"Endpoint URL: {endpoint_url}" + ) + response_data = await resp.json() + + return CloudBrowserWindow( + browser_window_id=response_data["browser_window_id"], + session_id=response_data["session_id"], + auth_headers=self._auth_headers, + ) + endpoint_url = f"{base_url}/cloud-browser/create-cloud-browser-session" async with aiohttp.ClientSession() as session: diff --git a/packages/narada/tests/test_cloud_browser.py b/packages/narada/tests/test_cloud_browser.py index 7588610..db54062 100644 --- a/packages/narada/tests/test_cloud_browser.py +++ b/packages/narada/tests/test_cloud_browser.py @@ -7,6 +7,42 @@ from narada_core.errors import NaradaTimeoutError +class _FakeResponse: + ok = True + status = 200 + + def __init__(self, payload: dict, *args, **kwargs) -> None: + self._payload = payload + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + async def json(self): + return self._payload + + async def text(self): + return "" + + +class _FakeClientSession: + def __init__(self, payload: dict) -> None: + self.payload = payload + self.posts = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + def post(self, url: str, **kwargs): + self.posts.append({"url": url, **kwargs}) + return _FakeResponse(self.payload) + + def _build_client_with_cloud_page(page: AsyncMock) -> Narada: client = Narada(auth_headers={"x-api-key": "test-key"}) browser = SimpleNamespace(contexts=[SimpleNamespace(pages=[page])]) @@ -16,6 +52,52 @@ def _build_client_with_cloud_page(page: AsyncMock) -> Narada: return client +@pytest.mark.asyncio +async def test_extensionless_cloud_browser_uses_backend_initialization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import narada.client as client_module + + fake_session = _FakeClientSession( + { + "session_id": "session-123", + "session_name": "fast-session", + "browser_window_id": "browser-window-123", + } + ) + monkeypatch.setattr(client_module.aiohttp, "ClientSession", lambda: fake_session) + + async def fail_if_client_initializes(*args, **kwargs): + raise AssertionError( + "extensionless cloud sessions should initialize server-side" + ) + + narada = Narada(auth_headers={"x-api-key": "test-key"}) + monkeypatch.setattr( + narada, "_initialize_cloud_browser_window", fail_if_client_initializes + ) + + window = await narada.open_and_initialize_cloud_browser_window( + session_name="fast-session", + session_timeout=300, + require_extension=False, + ) + + assert window.browser_window_id == "browser-window-123" + assert window.cloud_browser_session_id == "session-123" + assert len(fake_session.posts) == 1 + post = fake_session.posts[0] + assert post["url"].endswith( + "/cloud-browser/create-and-initialize-cloud-browser-session" + ) + assert post["headers"] == {"x-api-key": "test-key"} + assert post["json"] == { + "require_extension": False, + "session_name": "fast-session", + "session_timeout": 300, + } + + @pytest.mark.asyncio async def test_initialize_cloud_browser_window_uses_domcontentloaded_for_login_navigation( monkeypatch: pytest.MonkeyPatch,