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,` | 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