Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions examples/human_in_the_loop.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
52 changes: 47 additions & 5 deletions packages/narada/src/narada/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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:
Expand Down
82 changes: 82 additions & 0 deletions packages/narada/tests/test_cloud_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])])
Expand All @@ -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,
Expand Down
Loading