From 25884ea8af805d2b5cbf051dd60c5389800d52b6 Mon Sep 17 00:00:00 2001 From: matheus-conin Date: Tue, 7 Jul 2026 17:59:54 -0400 Subject: [PATCH 1/4] Background tabs by default: stop stealing the user's OS focus Every switch_tab() called Target.activateTarget unconditionally, which on macOS raises the automation Chrome window and steals the user's focus on every navigation (much worse with two daemons driving one Chrome). Window activation is never needed for automation: attach, screenshot, click and navigation all work on background tabs. - switch_tab(target, activate=False): Target.activateTarget is now opt-in, for when the user explicitly asks to SEE the tab - new_tab() / attach_first_page(): Target.createTarget with background=True - _open_chrome_inspect(): print instructions instead of osascript-activating the user's Chrome on a transient attach failure - docs updated (SKILL.md, interaction-skills/tabs.md, connection.md) --- SKILL.md | 2 ++ interaction-skills/connection.md | 8 ++++---- interaction-skills/tabs.md | 12 +++++++----- src/browser_harness/admin.py | 30 ++++++++++++++---------------- src/browser_harness/daemon.py | 3 ++- src/browser_harness/helpers.py | 11 ++++++++--- 6 files changed, 37 insertions(+), 29 deletions(-) diff --git a/SKILL.md b/SKILL.md index b30fac57..736a38f1 100644 --- a/SKILL.md +++ b/SKILL.md @@ -124,6 +124,8 @@ If you get stuck on a browser mechanic, check https://github.com/browser-use/bro - `chrome://inspect/#remote-debugging` must be enabled for local Chrome control. - Chrome may show an "Allow remote debugging?" popup; wait for the user to click Allow. +- Tabs open in BACKGROUND by design. `new_tab`/`switch_tab` never raise the Chrome window; screenshot, click, and navigation all work on background tabs. Only `switch_tab(tid, activate=True)` brings a tab to the foreground - use it exclusively when the user asked to SEE the tab; it steals their OS focus. +- Rare visibility-sensitive page (pauses video/animation when hidden): escalate with `switch_tab(tid, activate=True)` for that step only. - Omnibox popups are not real work tabs. - CDP target order is not Chrome's visible tab-strip order. - `BU_CDP_URL` is an HTTP DevTools endpoint; the daemon resolves it to WebSocket. diff --git a/interaction-skills/connection.md b/interaction-skills/connection.md index 85e264c2..f3480d28 100644 --- a/interaction-skills/connection.md +++ b/interaction-skills/connection.md @@ -4,7 +4,7 @@ When Chrome opens fresh, the only CDP `type: "page"` targets are `chrome://inspect` and `chrome://omnibox-popup.top-chrome/` (a 1px invisible viewport). If the daemon attaches to the omnibox popup, all subsequent work — including `new_tab()` and `goto_url()` — happens on tabs that exist in CDP but may not be visible in the Chrome UI. -The daemon's `attach_first_page()` handles this by creating an `about:blank` tab when no real pages exist. If you still end up on an invisible tab, use `switch_tab()` which calls `Target.activateTarget` to bring the tab to front. +The daemon's `attach_first_page()` handles this by creating an `about:blank` tab (in background) when no real pages exist. If you still end up on an invisible tab, `switch_tab()` re-attaches to a real one; it does NOT bring the tab to front unless you pass `activate=True`. ## Startup sequence @@ -12,7 +12,7 @@ The daemon's `attach_first_page()` handles this by creating an `about:blank` tab 2. If stale sockets exist but daemon is dead, clean them up 3. List open tabs with `list_tabs()` to see what's available 4. `ensure_real_tab()` attaches to a real page -5. `switch_tab(target_id)` both attaches AND activates (brings to front) +5. `switch_tab(target_id)` attaches in background; `switch_tab(target_id, activate=True)` also brings it to front (steals the user's OS focus — only when they asked to see it) ```python if not daemon_alive(): @@ -29,9 +29,9 @@ for t in tabs: tab = ensure_real_tab() ``` -## Bringing Chrome to front +## Bringing Chrome to front (opt-in ONLY) -If Chrome is behind other windows or on another desktop: +Never do this as part of normal automation — it steals the user's focus and interrupts whatever they are doing. Only when the user explicitly asks to watch: ```python import subprocess diff --git a/interaction-skills/tabs.md b/interaction-skills/tabs.md index 39ed5b3b..372d040f 100644 --- a/interaction-skills/tabs.md +++ b/interaction-skills/tabs.md @@ -7,13 +7,15 @@ Use **CDP for control**, **UI automation for user-visible order**. ```python tabs = list_tabs() # includes chrome:// pages too real_tabs = list_tabs(include_chrome=False) -tid = new_tab("https://example.com") # create + attach -switch_tab(tid) # attach harness to tab -cdp("Target.activateTarget", targetId=tid) # show it in Chrome +tid = new_tab("https://example.com") # create + attach (in background — never raises the window) +switch_tab(tid) # attach harness to tab (background by default) +switch_tab(tid, activate=True) # attach AND visibly show it in Chrome (steals OS focus!) print(current_tab()) print(page_info()) ``` +**Focus discipline:** automation never needs `activate=True` — screenshots, clicks, and navigation all work on background tabs. Only pass `activate=True` (or call `Target.activateTarget`) when the user explicitly asked to SEE the tab; on macOS it yanks the Chrome window to the foreground and steals the user's focus. + What CDP is good at: - attach to a tab - open a tab @@ -61,8 +63,8 @@ Typical tools: ## Rules that held up in practice -- `switch_tab()` is **not enough** if the user expects Chrome to visibly change. -- `Target.activateTarget` is the CDP-side "show this tab". +- `switch_tab()` is **not enough** if the user expects Chrome to visibly change — use `switch_tab(tid, activate=True)` for that, and only then. +- `Target.activateTarget` is the CDP-side "show this tab". It steals OS focus on macOS; opt-in only. - `list_tabs()` includes `chrome://newtab/` by default; ask for `include_chrome=False` when you want only real pages. - `chrome://omnibox-popup.top-chrome/` can appear as a fake page target; ignore it for user-facing tab lists. - If a page has `w=0 h=0`, you may be attached to the wrong target or a non-window surface. diff --git a/src/browser_harness/admin.py b/src/browser_harness/admin.py index 2c2c22fc..9bc786f8 100644 --- a/src/browser_harness/admin.py +++ b/src/browser_harness/admin.py @@ -759,23 +759,21 @@ def _chrome_running(): def _open_chrome_inspect(): - """Open chrome://inspect/#remote-debugging so the user can tick the checkbox.""" - import platform, subprocess, webbrowser + """Point the user at chrome://inspect/#remote-debugging without stealing focus. + + Used to `osascript ... activate` Chrome and open the URL itself, but that + yanks the user's real Chrome to the foreground on a transient attach + failure — exactly the focus-stealing this harness must never do. The + dedicated-profile setup (chrome-debug, port 9333) doesn't need the + consent checkbox at all, so just print instructions and let the user act.""" + import sys url = "chrome://inspect/#remote-debugging" - if platform.system() == "Darwin": - try: - subprocess.run([ - "osascript", - "-e", 'tell application "Google Chrome" to activate', - "-e", f'tell application "Google Chrome" to open location "{url}"', - ], timeout=5, check=False) - return - except Exception: - pass - try: - webbrowser.open(url, new=2) - except Exception: - pass + print( + f"browser-harness: open {url} in the target Chrome yourself, " + "or (preferred) run `chrome-debug` to use the dedicated automation profile " + "that never needs the consent checkbox.", + file=sys.stderr, + ) def run_doctor(): diff --git a/src/browser_harness/daemon.py b/src/browser_harness/daemon.py index 832aa6bf..223b1342 100644 --- a/src/browser_harness/daemon.py +++ b/src/browser_harness/daemon.py @@ -224,7 +224,8 @@ async def attach_first_page(self): pages = [t for t in targets if is_real_page(t)] if not pages: # No real pages - create one instead of attaching to omnibox popup. - tid = (await self.cdp.send_raw("Target.createTarget", {"url": "about:blank"}))["targetId"] + # background=True keeps it from raising the Chrome window. + tid = (await self.cdp.send_raw("Target.createTarget", {"url": "about:blank", "background": True}))["targetId"] log(f"no real pages found, created about:blank ({tid})") pages = [{"targetId": tid, "url": "about:blank", "type": "page"}] self.session = (await self.cdp.send_raw( diff --git a/src/browser_harness/helpers.py b/src/browser_harness/helpers.py index 89cbc5f4..6c8b096d 100644 --- a/src/browser_harness/helpers.py +++ b/src/browser_harness/helpers.py @@ -291,7 +291,7 @@ def _mark_tab(): try: cdp("Runtime.evaluate", expression="if(!document.title.startsWith('\U0001F434'))document.title='\U0001F434 '+document.title") except Exception: pass -def switch_tab(target): +def switch_tab(target, activate=False): # Accept either a raw targetId string or the dict returned by current_tab() / list_tabs(), # so `switch_tab(current_tab())` works without a manual ["targetId"] dance. target_id = (target.get("targetId") or target.get("target_id")) if isinstance(target, dict) else target @@ -299,7 +299,11 @@ def switch_tab(target): # plus the trailing space = 3 code units, so slice(3) cleanly removes the prefix. try: cdp("Runtime.evaluate", expression="if(document.title.startsWith('\U0001F434 '))document.title=document.title.slice(3)") except Exception: pass - cdp("Target.activateTarget", targetId=target_id) + # activate=True raises the Chrome window to the macOS foreground (steals the + # user's focus). Automation never needs it — attach/screenshot/click all work + # on background tabs — so only pass it when the user asked to SEE the tab. + if activate: + cdp("Target.activateTarget", targetId=target_id) sid = cdp("Target.attachToTarget", targetId=target_id, flatten=True)["sessionId"] _send({"meta": "set_session", "session_id": sid, "target_id": target_id}) _mark_tab() @@ -318,7 +322,8 @@ def new_tab(url="about:blank"): return cur.get("targetId") or cur.get("target_id") except Exception: pass - tid = cdp("Target.createTarget", url="about:blank")["targetId"] + # background=True keeps the new tab from raising the Chrome window. + tid = cdp("Target.createTarget", url="about:blank", background=True)["targetId"] switch_tab(tid) if url != "about:blank": goto_url(url) From 54eef202ddc16bf2db0032146f2503494e0fb9e5 Mon Sep 17 00:00:00 2001 From: matheus-conin Date: Tue, 7 Jul 2026 17:59:54 -0400 Subject: [PATCH 2/4] Make input work on background tabs: focus emulation + deterministic insertText With tabs no longer activated, input silently broke: a hidden RenderWidget doesn't process Input.dispatchMouseEvent, clicks didn't fire, and typed text never landed. Fixes validated empirically (16/16 concurrent test matrix across two daemons): - daemon: enable Emulation.setFocusEmulationEnabled on every session attach; without it mouse events to a background tab never even ack - click_at_xy: dispatch mouseMoved before press/release (the click doesn't fire on background tabs without it), then focus the focusable element under the point (compositor clicks don't move DOM focus on background tabs, breaking click-then-type) - press_key: deliver printable characters via Input.insertText between keyDown/keyUp; text on keyDown plus a char event double-inserts under focus emulation, and a char event alone doesn't insert after JS focus - fill_input: clear via element.select() instead of a Cmd/Ctrl+A key dance (the modifier stayed latched, turning every char into a shortcut); only select when the field has content (select() on an empty field drops all subsequent insertions); park the caret at the end for append mode --- src/browser_harness/daemon.py | 24 +++++++++++++- src/browser_harness/helpers.py | 58 +++++++++++++++++++++++++++------- 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/src/browser_harness/daemon.py b/src/browser_harness/daemon.py index 223b1342..edcd3009 100644 --- a/src/browser_harness/daemon.py +++ b/src/browser_harness/daemon.py @@ -258,7 +258,29 @@ async def enable_one(d): ) except Exception as e: log(f"enable {d} on {session_id}: {e}") - await asyncio.gather(*(enable_one(d) for d in ("Page", "DOM", "Runtime", "Network"))) + + async def enable_focus_emulation(): + # Tabs stay in background by design (never activated, so the user's + # focus is never stolen). A hidden RenderWidget doesn't process + # Input.dispatchMouseEvent — the event never acks and clicks are + # dropped. Focus emulation makes the renderer treat the page as + # focused/active so compositor-level input works on background tabs. + try: + await asyncio.wait_for( + self.cdp.send_raw( + "Emulation.setFocusEmulationEnabled", + {"enabled": True}, + session_id=session_id, + ), + timeout=4, + ) + except Exception as e: + log(f"enable focus emulation on {session_id}: {e}") + + await asyncio.gather( + *(enable_one(d) for d in ("Page", "DOM", "Runtime", "Network")), + enable_focus_emulation(), + ) async def start(self): self.stop = asyncio.Event() diff --git a/src/browser_harness/helpers.py b/src/browser_harness/helpers.py index 6c8b096d..201aaac9 100644 --- a/src/browser_harness/helpers.py +++ b/src/browser_harness/helpers.py @@ -168,8 +168,25 @@ def click_at_xy(x, y, button="left", clicks=1): except Exception as e: print(f"[debug_click] overlay failed: {e}") _debug_click_counter += 1 + # mouseMoved primes the pointer position; on background tabs (never + # activated, focus-emulated by the daemon) press/release without a prior + # move is processed but the click doesn't fire. + cdp("Input.dispatchMouseEvent", type="mouseMoved", x=x, y=y) cdp("Input.dispatchMouseEvent", type="mousePressed", x=x, y=y, button=button, clickCount=clicks) cdp("Input.dispatchMouseEvent", type="mouseReleased", x=x, y=y, button=button, clickCount=clicks) + if button == "left" and clicks == 1: + # On background tabs the compositor click runs handlers/navigation but + # does NOT move DOM focus (activeElement stays BODY), so click-then- + # type_text breaks. Mirror what a real click does: focus the focusable + # element under the point. No-op on active tabs (already focused). + try: + js( + f"(()=>{{const e=document.elementFromPoint({x},{y});" + f"if(!e)return;const t=e.closest('input,textarea,select,button,[contenteditable],[tabindex]');" + f"if(t&&document.activeElement!==t)t.focus();}})()" + ) + except Exception: + pass def type_text(text): cdp("Input.insertText", text=text) @@ -194,16 +211,30 @@ def fill_input(selector, text, clear_first=True, timeout=0.0): if not focused: raise RuntimeError(f"fill_input: element not found: {selector!r}") if clear_first: - # Dispatch select-all directly — NOT via press_key, which always emits a - # `char` event for single-char keys. With Ctrl/Cmd held, that `char` - # makes Chrome treat the input as a printable "a" instead of firing the - # select-all shortcut, leaving the field uncleared. - mods = 4 if sys.platform == "darwin" else 2 # Cmd on macOS, Ctrl elsewhere - select_all = {"key": "a", "code": "KeyA", "modifiers": mods, - "windowsVirtualKeyCode": 65, "nativeVirtualKeyCode": 65} - cdp("Input.dispatchKeyEvent", type="rawKeyDown", **select_all) - cdp("Input.dispatchKeyEvent", type="keyUp", **select_all) - press_key("Backspace") + # Select-all via element.select(), not a Cmd/Ctrl+A key dance: the key + # sequence never sent Meta's own keyDown/keyUp, so on focus-emulated + # background tabs the modifier stayed latched and every subsequent + # char became a Cmd+ shortcut (field silently stayed empty). + # Only select when there IS content: select() on an empty field leaves + # it in a state where subsequent char events don't insert at all. + had_content = js( + f"(()=>{{const e=document.querySelector({json.dumps(selector)});" + f"if(!e)return false;" + f"const v=('value'in e)?e.value:e.textContent;" + f"if(!v)return false;" + f"if(e.select)e.select();else document.getSelection().selectAllChildren(e);" + f"return true;}})()" + ) + if had_content: + press_key("Backspace") + else: + # Append semantics: JS focus() parks the caret at position 0, so typed + # text would land BEFORE the existing value. Move the caret to the end. + js( + f"(()=>{{const e=document.querySelector({json.dumps(selector)});" + f"if(e&&e.setSelectionRange&&'value'in e)" + f"try{{e.setSelectionRange(e.value.length,e.value.length)}}catch(_){{}}}})()" + ) for ch in text: press_key(ch) js( @@ -229,9 +260,14 @@ def press_key(key, modifiers=0): base = {"key": key, "code": code, "modifiers": modifiers, "windowsVirtualKeyCode": vk, "nativeVirtualKeyCode": vk} shortcut_modifiers = modifiers & (1 | 2 | 4) # Alt/Ctrl/Meta turn single keys into shortcuts. printable_char = len(key) == 1 and bool(text) and not shortcut_modifiers + # keyDown/keyUp feed key listeners and editing actions (Backspace, Enter, + # arrows); the printable character itself is delivered via Input.insertText. + # On focus-emulated background tabs, text carried on keyDown or char events + # inserts unreliably (empty fields drop it, and keyDown+text plus a char + # event double-inserts) - insertText is deterministic. cdp("Input.dispatchKeyEvent", type="keyDown", **base, **({} if printable_char or not text else {"text": text})) if printable_char: - cdp("Input.dispatchKeyEvent", type="char", text=text, **{k: v for k, v in base.items() if k != "text"}) + cdp("Input.insertText", text=text) cdp("Input.dispatchKeyEvent", type="keyUp", **base) def scroll(x, y, dy=-300, dx=0): From 95bbf0bf5a161fed4d8e5dbb637244c557bd35b1 Mon Sep 17 00:00:00 2001 From: matheus-conin Date: Wed, 8 Jul 2026 10:32:00 -0400 Subject: [PATCH 3/4] Address review: align tests and docs, retry focus emulation - Replace the Cmd/Ctrl+A clearing test with tests for the new element.select() behavior, including the empty-field case where Backspace must be skipped - Update the set_session parallelism tests: the gather now carries 4 enables + focus emulation (peak 5 on attach, 6 on switch) - tabs.md: soften 'never needs activate=True' to match the visibility-sensitive-page exception documented in SKILL.md - daemon: retry Emulation.setFocusEmulationEnabled once and log a loud WARNING naming the consequence (background input dropped) and the workaround when it still fails --- interaction-skills/tabs.md | 2 +- src/browser_harness/daemon.py | 31 +++++++++++++------- tests/unit/test_daemon.py | 14 ++++----- tests/unit/test_helpers.py | 55 +++++++++++++++++++++++------------ 4 files changed, 64 insertions(+), 38 deletions(-) diff --git a/interaction-skills/tabs.md b/interaction-skills/tabs.md index 372d040f..1a71f0e0 100644 --- a/interaction-skills/tabs.md +++ b/interaction-skills/tabs.md @@ -14,7 +14,7 @@ print(current_tab()) print(page_info()) ``` -**Focus discipline:** automation never needs `activate=True` — screenshots, clicks, and navigation all work on background tabs. Only pass `activate=True` (or call `Target.activateTarget`) when the user explicitly asked to SEE the tab; on macOS it yanks the Chrome window to the foreground and steals the user's focus. +**Focus discipline:** automation almost never needs `activate=True` — screenshots, clicks, and navigation all work on background tabs. The one exception is visibility-sensitive pages that pause video/animation when hidden (see SKILL.md). Only pass `activate=True` (or call `Target.activateTarget`) when the user explicitly asked to SEE the tab; on macOS it yanks the Chrome window to the foreground and steals the user's focus. What CDP is good at: - attach to a tab diff --git a/src/browser_harness/daemon.py b/src/browser_harness/daemon.py index edcd3009..cda42ae6 100644 --- a/src/browser_harness/daemon.py +++ b/src/browser_harness/daemon.py @@ -265,17 +265,26 @@ async def enable_focus_emulation(): # Input.dispatchMouseEvent — the event never acks and clicks are # dropped. Focus emulation makes the renderer treat the page as # focused/active so compositor-level input works on background tabs. - try: - await asyncio.wait_for( - self.cdp.send_raw( - "Emulation.setFocusEmulationEnabled", - {"enabled": True}, - session_id=session_id, - ), - timeout=4, - ) - except Exception as e: - log(f"enable focus emulation on {session_id}: {e}") + # One retry, then a loud log: without focus emulation, clicks and + # typing on this tab's background surface silently do nothing. + for attempt in (1, 2): + try: + await asyncio.wait_for( + self.cdp.send_raw( + "Emulation.setFocusEmulationEnabled", + {"enabled": True}, + session_id=session_id, + ), + timeout=4, + ) + return + except Exception as e: + log(f"enable focus emulation on {session_id} (attempt {attempt}): {e}") + log( + f"WARNING: focus emulation NOT active on {session_id} — " + "clicks/typing will be dropped while this tab is in background; " + "switch_tab(tid, activate=True) is the workaround" + ) await asyncio.gather( *(enable_one(d) for d in ("Page", "DOM", "Runtime", "Network")), diff --git a/tests/unit/test_daemon.py b/tests/unit/test_daemon.py index 90c5bc85..6dc64d46 100644 --- a/tests/unit/test_daemon.py +++ b/tests/unit/test_daemon.py @@ -178,7 +178,7 @@ async def run(): # in-flight. Cap iterations to avoid hanging if parallelization breaks. for _ in range(50): await asyncio.sleep(0) - # 5 = Network.disable on OLD + 4 enables on NEW. + # 6 = Network.disable on OLD + 4 enables + focus emulation on NEW. if d.cdp.in_flight >= 5: break peak = d.cdp.max_concurrent @@ -187,10 +187,10 @@ async def run(): return peak, d.cdp.calls peak, calls = asyncio.run(run()) - assert peak == 5, ( - f"set_session must run disable + 4 enables concurrently via gather " - f"(observed peak in-flight = {peak}; expected 5 = 1 disable on OLD + " - f"4 enables on NEW). Sequential await would peak at 1." + assert peak == 6, ( + f"set_session must run disable + 4 enables + focus emulation " + f"concurrently via gather (observed peak in-flight = {peak}; expected " + f"6 = 1 disable on OLD + 5 on NEW). Sequential await would peak at 1." ) # Sanity: the right calls were made. methods = sorted({m for (m, _p, _s) in calls}) @@ -239,8 +239,8 @@ async def run(): return peak peak = asyncio.run(run()) - assert peak == 4, ( - f"first set_session must run 4 enables concurrently " + assert peak == 5, ( + f"first set_session must run 4 enables + focus emulation concurrently " f"(observed peak = {peak}). No Network.disable should fire." ) diff --git a/tests/unit/test_helpers.py b/tests/unit/test_helpers.py index 4a45ee07..5346f933 100644 --- a/tests/unit/test_helpers.py +++ b/tests/unit/test_helpers.py @@ -110,10 +110,9 @@ def fake_js(expr, **kwargs): helpers.fill_input("#missing", "hello") -def test_fill_input_clear_first_sends_select_all_then_backspace(): - import sys - +def test_fill_input_clear_first_selects_via_js_then_backspace(): key_events = [] + js_calls = [] def fake_cdp(method, **kwargs): if method == "Input.dispatchKeyEvent": @@ -121,31 +120,49 @@ def fake_cdp(method, **kwargs): return {} def fake_js(expr, **kwargs): - return True # element found + js_calls.append(expr) + return True # element found / field has content with patch("browser_harness.helpers.cdp", side_effect=fake_cdp), \ patch("browser_harness.helpers.js", side_effect=fake_js): helpers.fill_input("#inp", "x", clear_first=True) - # The "a" must be dispatched with the platform-correct modifier (Meta=4 on - # macOS, Ctrl=2 elsewhere). Without the modifier, the field would never get - # selected — it would just receive a literal "a". - expected_mod = 4 if sys.platform == "darwin" else 2 - a_events = [e for e in key_events if e.get("key") == "a"] - assert a_events, "expected an 'a' key event for select-all" - assert all(e.get("modifiers") == expected_mod for e in a_events), \ - f"select-all 'a' must carry modifiers={expected_mod}; got {[e.get('modifiers') for e in a_events]}" - - # Crucial: no `char` event for the "a" — emitting one makes Chrome treat - # Cmd/Ctrl+A as a printable letter instead of a shortcut. - assert not any(e.get("type") == "char" and e.get("text") == "a" for e in key_events), \ - "select-all must not emit a 'char' event with text='a' (would cancel the shortcut)" - - # Backspace still fires (via press_key, which uses keyDown). + # Clearing selects via element.select() in JS, NOT a Cmd/Ctrl+A key dance: + # the key sequence never sent Meta's own keyDown/keyUp, so on + # focus-emulated background tabs the modifier stayed latched and every + # subsequent char became a Cmd+ shortcut. + assert any(".select()" in e or "selectAllChildren" in e for e in js_calls), \ + "clear_first must select the field content via JS" + assert not any(e.get("key") == "a" for e in key_events), \ + "clear_first must not dispatch Cmd/Ctrl+A key events" + + # Backspace still fires to delete the selected content. keys_down = [e.get("key") for e in key_events if e.get("type") in ("keyDown", "rawKeyDown")] assert "Backspace" in keys_down +def test_fill_input_clear_first_skips_backspace_on_empty_field(): + key_events = [] + + def fake_cdp(method, **kwargs): + if method == "Input.dispatchKeyEvent": + key_events.append(kwargs) + return {} + + def fake_js(expr, **kwargs): + # focus() lookup finds the element; the select-content check reports + # an empty field (select() on an empty field would leave it in a state + # where subsequent insertions are dropped). + return False if ".select()" in expr or "selectAllChildren" in expr else True + + with patch("browser_harness.helpers.cdp", side_effect=fake_cdp), \ + patch("browser_harness.helpers.js", side_effect=fake_js): + helpers.fill_input("#inp", "x", clear_first=True) + + keys_down = [e.get("key") for e in key_events if e.get("type") in ("keyDown", "rawKeyDown")] + assert "Backspace" not in keys_down, "empty field must not receive Backspace" + + def test_fill_input_no_clear_skips_ctrl_a(): key_events = [] From a1721f78a7c4667c4fb00e7507cc571363edbcbb Mon Sep 17 00:00:00 2001 From: matheus-conin Date: Wed, 8 Jul 2026 10:40:02 -0400 Subject: [PATCH 4/4] Keep set_session bounded by one 4s round trip: retry focus emulation off-path The sequential retry gave a single gather member up to 8s, exceeding the helper IPC socket's 5s read timeout on the synchronous set_session path. First attempt stays inline (same 4s budget as the other enables); the retry and the loud failure WARNING move to a background task. --- src/browser_harness/daemon.py | 49 ++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/src/browser_harness/daemon.py b/src/browser_harness/daemon.py index cda42ae6..bb1149be 100644 --- a/src/browser_harness/daemon.py +++ b/src/browser_harness/daemon.py @@ -259,32 +259,45 @@ async def enable_one(d): except Exception as e: log(f"enable {d} on {session_id}: {e}") + async def set_focus_emulation(): + await asyncio.wait_for( + self.cdp.send_raw( + "Emulation.setFocusEmulationEnabled", + {"enabled": True}, + session_id=session_id, + ), + timeout=4, + ) + async def enable_focus_emulation(): # Tabs stay in background by design (never activated, so the user's # focus is never stolen). A hidden RenderWidget doesn't process # Input.dispatchMouseEvent — the event never acks and clicks are # dropped. Focus emulation makes the renderer treat the page as # focused/active so compositor-level input works on background tabs. - # One retry, then a loud log: without focus emulation, clicks and - # typing on this tab's background surface silently do nothing. - for attempt in (1, 2): + try: + await set_focus_emulation() + return + except Exception as e: + log(f"enable focus emulation on {session_id} (attempt 1): {e}") + + # Retry OFF the synchronous set_session path: the helper's IPC + # socket read times out at 5s, so this gather must stay bounded by + # one 4s round trip. A second failure gets a loud log — without + # focus emulation, clicks/typing on this tab silently do nothing. + async def retry_in_background(): try: - await asyncio.wait_for( - self.cdp.send_raw( - "Emulation.setFocusEmulationEnabled", - {"enabled": True}, - session_id=session_id, - ), - timeout=4, - ) - return + await set_focus_emulation() + log(f"focus emulation enabled on {session_id} (background retry)") except Exception as e: - log(f"enable focus emulation on {session_id} (attempt {attempt}): {e}") - log( - f"WARNING: focus emulation NOT active on {session_id} — " - "clicks/typing will be dropped while this tab is in background; " - "switch_tab(tid, activate=True) is the workaround" - ) + log( + f"WARNING: focus emulation NOT active on {session_id} " + f"({e}) — clicks/typing will be dropped while this tab " + "is in background; switch_tab(tid, activate=True) is " + "the workaround" + ) + + asyncio.create_task(retry_in_background()) await asyncio.gather( *(enable_one(d) for d in ("Page", "DOM", "Runtime", "Network")),