Skip to content
Open
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
43 changes: 33 additions & 10 deletions src/browser_harness/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,16 +194,39 @@ 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 via element.select(), not a synthetic Cmd/Ctrl+A: the
# shortcut never fires select-all over CDP. Measured on Brave 150,
# selectionEnd - selectionStart == 0 on an 11-char field, in all three
# tab states (activated, background, background + focus emulation), so
# this is not background-tab specific. Backspace then deletes at the
# caret and the new text lands beside the old value instead of
# replacing it — silently, and the caret position varies, so the same
# call can yield 'REPLACEDpreexisting' or 'preexistinREPLACED'.
# Only select when there IS content: select() on an empty field leaves
# it in a state where subsequent characters 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: focus() parks the caret at 0, so typed text would
# land before the existing value. Move it to the end.
js(
f"(()=>{{const e=document.querySelector({json.dumps(selector)});"
f"if(!e)return;"
f"if('value'in e&&e.setSelectionRange)"
f"{{try{{e.setSelectionRange(e.value.length,e.value.length)}}catch(_){{}}return}}"
# contenteditable has no setSelectionRange; collapse a Range to the end
# instead, or the caret stays at 0 and 'append' prepends.
f"const r=document.createRange();r.selectNodeContents(e);r.collapse(false);"
f"const s=document.getSelection();s.removeAllRanges();s.addRange(r);}})()"
)
for ch in text:
press_key(ch)
js(
Expand Down
74 changes: 55 additions & 19 deletions tests/unit/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,42 +110,59 @@ 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":
key_events.append(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 synthetic Cmd/Ctrl+A:
# on a focus-emulated background tab that shortcut selects nothing, so
# Backspace deletes at the caret and the new text lands beside the old
# value rather than replacing it.
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 now-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 leaves it in a state where
# subsequent characters don't insert at all.
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 = []

Expand Down Expand Up @@ -350,3 +367,22 @@ def fake_send(req):
"session filter, the background rWS/lF pair would have updated "
"last_activity and prevented the idle window from elapsing."
)


def test_fill_input_append_moves_caret_to_end_for_contenteditable():
js_calls = []

def fake_js(expr, **kwargs):
js_calls.append(expr)
return True

with patch("browser_harness.helpers.cdp", side_effect=lambda *a, **k: {}), \
patch("browser_harness.helpers.js", side_effect=fake_js):
helpers.fill_input("#ce", "x", clear_first=False)

# contenteditable has no setSelectionRange; without a Range fallback the
# caret stays at 0 and "append" silently prepends.
caret = [e for e in js_calls if "setSelectionRange" in e or "collapse" in e]
assert caret, "clear_first=False must move the caret to the end"
assert any("collapse(false)" in e and "selectNodeContents" in e for e in caret), \
"caret move must fall back to a collapsed Range for contenteditable"