diff --git a/CHANGELOG.md b/CHANGELOG.md index d32906e..e34ca6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/brow/src/brow/cli.py b/brow/src/brow/cli.py index e00aff9..7ae262e 100644 --- a/brow/src/brow/cli.py +++ b/brow/src/brow/cli.py @@ -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)) @@ -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"]) diff --git a/brow/src/brow/routes/browser.py b/brow/src/brow/routes/browser.py index 7199592..ec1b5d5 100644 --- a/brow/src/brow/routes/browser.py +++ b/brow/src/brow/routes/browser.py @@ -1,3 +1,4 @@ +import re import time from pathlib import Path from typing import Optional @@ -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") @@ -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): @@ -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} diff --git a/brow/src/brow/routes/eval.py b/brow/src/brow/routes/eval.py index 88f2eab..c7cbc7d 100644 --- a/brow/src/brow/routes/eval.py +++ b/brow/src/brow/routes/eval.py @@ -24,6 +24,14 @@ 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, @@ -31,6 +39,8 @@ async def eval_code(req: Request, sid: str, body: EvalReq): "state": session.state, "pages": session.pages, "asyncio": asyncio, + "text": text, + "texts": texts, } stdout_capture = io.StringIO() @@ -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"), diff --git a/brow/src/brow/routes/sessions.py b/brow/src/brow/routes/sessions.py index 1e7c857..b29ad50 100644 --- a/brow/src/brow/routes/sessions.py +++ b/brow/src/brow/routes/sessions.py @@ -15,6 +15,7 @@ class CreateSession(BaseModel): profile: str = "default" headless: bool = True url: Optional[str] = None + reclaim: bool = False @router.post("") @@ -22,6 +23,11 @@ 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: diff --git a/brow/src/brow/session.py b/brow/src/brow/session.py index 77b66af..e3a3849 100644 --- a/brow/src/brow/session.py +++ b/brow/src/brow/session.py @@ -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) diff --git a/brow/tests/test_routes_browser.py b/brow/tests/test_routes_browser.py index 7808859..7a867e4 100644 --- a/brow/tests/test_routes_browser.py +++ b/brow/tests/test_routes_browser.py @@ -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," + 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,

Hi

", "wait": "networkidle"} + ) + assert r.status_code == 200 + + @pytest.mark.asyncio async def test_select(client, session_id): html = ( diff --git a/brow/tests/test_routes_eval.py b/brow/tests/test_routes_eval.py index c54195b..3b193d4 100644 --- a/brow/tests/test_routes_eval.py +++ b/brow/tests/test_routes_eval.py @@ -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,

Hello World

"}) + 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,

Hi

"}) + 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"] diff --git a/brow/tests/test_routes_sessions.py b/brow/tests/test_routes_sessions.py index 0516297..4a99637 100644 --- a/brow/tests/test_routes_sessions.py +++ b/brow/tests/test_routes_sessions.py @@ -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}) diff --git a/brow/tests/test_session.py b/brow/tests/test_session.py index 8f7d283..a96bee4 100644 --- a/brow/tests/test_session.py +++ b/brow/tests/test_session.py @@ -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) diff --git a/skills/brow/SKILL.md b/skills/brow/SKILL.md index f83edc8..a7f7794 100644 --- a/skills/brow/SKILL.md +++ b/skills/brow/SKILL.md @@ -51,7 +51,7 @@ brow session delete 1 | `websocket -s [--count N] [--search ] [--clear]` | Get WebSocket messages | | `actions -s [--json] [--clear]` | View recorded action log for current session | | `replay -s [--var k=v]` | Replay a playbook YAML (with optional var overrides) | -| `eval -s ` | Run Playwright Python (side effects only — no stdout return) | +| `eval -s ` | Run Playwright Python; returns `result` + stdout. Helpers: `text(sel)`, `texts(sel)` | ## Scrolling @@ -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 ` 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 ` 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