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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added
- `brow setup` command to install Chromium (no more manual `patchright install`)
- `session new --reclaim` closes a stale session holding the profile and takes it over (#31)
- `navigate --wait domcontentloaded|load|networkidle` settle strategy (#33)
- `eval` namespace helpers `text(sel)` and `texts(sel)` for quick extraction (#32)

### Changed
- License reverted to MIT (was Elastic License 2.0)
- `session new` returns a clear "Chromium is not installed" message instead of a raw 500 when the browser binary is missing
- click/fill/select accept a snapshot ref directly: `click "[1]"` resolves to `--ref 1` (#30)
- Profile-conflict error now suggests `--reclaim` and the exact `session delete` command (#31)
- `eval` errors involving an un-awaited coroutine now hint to add `await` (#32)

## [1.1.0] - 2026-04-08

Expand Down
18 changes: 14 additions & 4 deletions brow/src/brow/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,15 @@ def daemon_status():


@session_app.command("new")
def session_new(profile: str = "default", headed: bool = False, url: Optional[str] = None):
def session_new(
profile: str = "default",
headed: bool = False,
url: Optional[str] = None,
reclaim: bool = typer.Option(False, "--reclaim", help="Close any existing session holding this profile"),
):
ensure_daemon()
c = _client()
payload = {"profile": profile, "headless": not headed}
payload = {"profile": profile, "headless": not headed, "reclaim": reclaim}
if url:
payload["url"] = url
result = run_async(c.post("/sessions", json=payload))
Expand Down Expand Up @@ -167,10 +172,15 @@ def session_cleanup():


@app.command("navigate")
def navigate(url: str, s: Optional[str] = session_opt, timeout: int = 30000):
def navigate(
url: str,
s: Optional[str] = session_opt,
timeout: int = 30000,
wait: str = typer.Option("load", "--wait", help="Settle strategy: domcontentloaded | load | networkidle"),
):
ensure_daemon()
c = _client()
result = run_async(c.post(f"/browser/{s}/navigate", json={"url": url, "timeout": timeout}))
result = run_async(c.post(f"/browser/{s}/navigate", json={"url": url, "timeout": timeout, "wait": wait}))
typer.echo(f"{result['url']} [{result.get('status', '')}]")
if result.get("snapshot"):
typer.echo(result["snapshot"])
Expand Down
13 changes: 13 additions & 0 deletions brow/src/brow/routes/browser.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
import time
from pathlib import Path
from typing import Optional
Expand Down Expand Up @@ -236,10 +237,16 @@ def _get_page(session):
return page


_REF_RE = re.compile(r"^\s*\[?(\d+)\]?\s*$")


def _resolve_selector(body):
if hasattr(body, "ref") and body.ref is not None:
return f'[data-brow-ref="{body.ref}"]'
if hasattr(body, "selector") and body.selector is not None:
m = _REF_RE.match(body.selector)
if m:
return f'[data-brow-ref="{m.group(1)}"]'
return body.selector
raise HTTPException(400, "Either 'ref' or 'selector' must be provided")

Expand All @@ -254,6 +261,7 @@ def _log_action(session, action: str, **kwargs):
class NavigateReq(BaseModel):
url: str
timeout: int = DEFAULT_TIMEOUT
wait: str = "load"


class WaitReq(BaseModel):
Expand Down Expand Up @@ -341,6 +349,11 @@ async def navigate(req: Request, sid: str, body: NavigateReq):
logging.error(f"Navigate to {body.url} failed: {e}")
raise HTTPException(502, f"Navigation failed: {e}")
status = r.status if r else None
if body.wait in ("load", "networkidle"):
try:
await page.wait_for_load_state(body.wait, timeout=min(body.timeout, 10000))
except Exception:
pass
_log_action(session, "navigate", url=body.url, status=status)
formatted, truncated, node_count = await _take_snapshot(page)
resp = {"url": page.url, "status": status, "snapshot": formatted}
Expand Down
15 changes: 14 additions & 1 deletion brow/src/brow/routes/eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,23 @@ async def eval_code(req: Request, sid: str, body: EvalReq):

page = session.page
context = session.context

async def text(selector):
el = await page.query_selector(selector)
return (await el.inner_text()).strip() if el else None

async def texts(selector):
return [(await el.inner_text()).strip() for el in await page.query_selector_all(selector)]

sandbox = {
"page": page,
"context": context,
"browser": session.browser,
"state": session.state,
"pages": session.pages,
"asyncio": asyncio,
"text": text,
"texts": texts,
}

stdout_capture = io.StringIO()
Expand All @@ -50,7 +60,10 @@ async def eval_code(req: Request, sid: str, body: EvalReq):
except asyncio.TimeoutError:
raise HTTPException(408, "Eval timed out")
except Exception as e:
raise HTTPException(400, str(e))
msg = str(e)
if "'coroutine' object" in msg:
msg += " — page methods are async; did you forget 'await'? (or use the text()/texts() helpers)"
raise HTTPException(400, msg)

return {
"result": sandbox.get("result"),
Expand Down
6 changes: 6 additions & 0 deletions brow/src/brow/routes/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,19 @@ class CreateSession(BaseModel):
profile: str = "default"
headless: bool = True
url: Optional[str] = None
reclaim: bool = False


@router.post("")
async def create(req: Request, body: CreateSession):
mgr = req.app.state.manager
profiles = req.app.state.profiles
pw = req.app.state.pw
if body.reclaim:
stale = mgr.find_by_profile(body.profile)
if stale:
await stale.close()
mgr.delete(stale.id)
try:
sid = mgr.create(body.profile, body.headless)
except RuntimeError as e:
Expand Down
15 changes: 12 additions & 3 deletions brow/src/brow/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,21 @@ def __init__(self):
self.sessions: dict[str, Session] = {}
self._counter = 0

def find_by_profile(self, profile):
for s in self.sessions.values():
if s.profile == profile:
return s
return None

def create(self, profile, headless=True):
if len(self.sessions) >= MAX_SESSIONS:
raise RuntimeError(f"Max sessions ({MAX_SESSIONS}) reached")
for s in self.sessions.values():
if s.profile == profile:
raise RuntimeError(f"Profile '{profile}' already in use by session {s.id}")
existing = self.find_by_profile(profile)
if existing:
raise RuntimeError(
f"Profile '{profile}' already in use by session {existing.id}. "
f"Retry with reclaim, or run: brow session delete {existing.id}"
)
self._counter += 1
sid = str(self._counter)
self.sessions[sid] = Session(id=sid, profile=profile, headless=headless)
Expand Down
18 changes: 18 additions & 0 deletions brow/tests/test_routes_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,24 @@ async def test_fill_by_ref(client, session_id):
assert r.json()["ok"] is True


@pytest.mark.asyncio
async def test_click_bracket_ref_as_selector(client, session_id):
html = "data:text/html,<body><button id='btn' onclick='document.title=\"clicked\"'>Click</button></body>"
await client.post(f"/browser/{session_id}/navigate", json={"url": html})
await client.get(f"/browser/{session_id}/snapshot")
r = await client.post(f"/browser/{session_id}/click", json={"selector": "[1]"})
assert r.status_code == 200
assert r.json()["ok"] is True


@pytest.mark.asyncio
async def test_navigate_wait_networkidle(client, session_id):
r = await client.post(
f"/browser/{session_id}/navigate", json={"url": "data:text/html,<h1>Hi</h1>", "wait": "networkidle"}
)
assert r.status_code == 200


@pytest.mark.asyncio
async def test_select(client, session_id):
html = (
Expand Down
18 changes: 18 additions & 0 deletions brow/tests/test_routes_eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,21 @@ async def test_eval_error(client, session_id):
r = await client.post(f"/eval/{session_id}", json={"code": "raise ValueError('boom')"})
assert r.status_code == 400
assert "boom" in r.json()["detail"]


@pytest.mark.asyncio
async def test_eval_text_helper(client, session_id):
await client.post(f"/browser/{session_id}/navigate", json={"url": "data:text/html,<h1>Hello World</h1>"})
r = await client.post(f"/eval/{session_id}", json={"code": "result = await text('h1')"})
assert r.status_code == 200
assert r.json()["result"] == "Hello World"


@pytest.mark.asyncio
async def test_eval_coroutine_hint(client, session_id):
await client.post(f"/browser/{session_id}/navigate", json={"url": "data:text/html,<h1>Hi</h1>"})
r = await client.post(
f"/eval/{session_id}", json={"code": "el = page.query_selector('h1')\nresult = el.inner_text()"}
)
assert r.status_code == 400
assert "await" in r.json()["detail"]
20 changes: 20 additions & 0 deletions brow/tests/test_routes_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,26 @@ async def test_list_sessions(client):
assert len(r.json()) == 1


@pytest.mark.asyncio
async def test_profile_conflict(client):
await client.post("/sessions", json={"profile": "dup", "headless": True})
r = await client.post("/sessions", json={"profile": "dup", "headless": True})
assert r.status_code == 400
assert "already in use" in r.json()["detail"]


@pytest.mark.asyncio
async def test_reclaim_profile(client):
r1 = await client.post("/sessions", json={"profile": "dup", "headless": True})
old = r1.json()["id"]
r2 = await client.post("/sessions", json={"profile": "dup", "headless": True, "reclaim": True})
assert r2.status_code == 200
sessions = (await client.get("/sessions")).json()
ids = [s["id"] for s in sessions]
assert old not in ids
assert r2.json()["id"] in ids


@pytest.mark.asyncio
async def test_delete_session(client):
r = await client.post("/sessions", json={"profile": "default", "headless": True})
Expand Down
12 changes: 12 additions & 0 deletions brow/tests/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,15 @@ def test_duplicate_profile(manager):
)
def test_is_browser_missing_error(msg, expected):
assert is_browser_missing_error(Exception(msg)) is expected


def test_find_by_profile(manager):
sid = manager.create("gmail", headless=True)
assert manager.find_by_profile("gmail").id == sid
assert manager.find_by_profile("nope") is None


def test_conflict_message_suggests_recovery(manager):
sid = manager.create("gmail", headless=True)
with pytest.raises(RuntimeError, match=f"session delete {sid}"):
manager.create("gmail", headless=True)
8 changes: 5 additions & 3 deletions skills/brow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ brow session delete 1
| `websocket -s <id> [--count N] [--search <str>] [--clear]` | Get WebSocket messages |
| `actions -s <id> [--json] [--clear]` | View recorded action log for current session |
| `replay -s <id> <playbook.yaml> [--var k=v]` | Replay a playbook YAML (with optional var overrides) |
| `eval -s <id> <code>` | Run Playwright Python (side effects only — no stdout return) |
| `eval -s <id> <code>` | Run Playwright Python; returns `result` + stdout. Helpers: `text(sel)`, `texts(sel)` |

## Scrolling

Expand Down Expand Up @@ -128,13 +128,15 @@ brow session delete 1
brow session new --profile gmail
```

**Profile conflict:** `session new` fails if the profile is already in use: `Profile 'default' already in use by session N`. Fix: use a unique `--profile <name>` per concurrent session.
**Profile conflict:** `session new` fails if the profile is already in use: `Profile 'default' already in use by session N`. Fix: use a unique `--profile <name>` per concurrent session, or pass `--reclaim` to close the stale session and take over the profile.

## Tips

- Use `--headed` to see the browser while debugging
- Use `snapshot` over `screenshot` — it's faster and uses fewer tokens
- `eval` runs arbitrary Playwright Python but output is not returned to stdout — use for side effects only
- Click/fill accept the snapshot ref directly: `click "[1]"` works (same as `click --ref 1`)
- `eval` returns `result` and stdout. `page` is async — `await` everything, or use the `text(sel)` / `texts(sel)` helpers for quick extraction
- `navigate --wait networkidle` for JS-heavy pages instead of guessing with `sleep`
- Session IDs are simple integers: 1, 2, 3...

## API Scouting
Expand Down
Loading