diff --git a/README.md b/README.md
index 31f1cef..3179e56 100644
--- a/README.md
+++ b/README.md
@@ -53,6 +53,13 @@ Botasaurus Driver provides several handy methods for web automation tasks such a
element.select_option("select#fruits", index=2) # Select an option
```
+ `type`/`send_keys` dispatch real keydown/keyup events (not a synthetic char per character), so pages that listen for `keydown`/`keyup` or inspect `event.code`/`event.keyCode` see a faithful sequence. Characters without a physical key (emoji, non-Latin scripts) fall back to `insertText`. `\n` and `\r\n` each send one Enter:
+ ```python
+ driver.type("textarea", "line one\nline two")
+ driver.type("textarea", "line one\r\nline two") # CRLF collapsed to a single Enter
+ driver.type("input[name='note']", "你好 👋") # Unmapped characters use insertText
+ ```
+
- Retrieving element properties:
```python
header_text = driver.get_text("h1") # Get text content
diff --git a/botasaurus_driver/core/element.py b/botasaurus_driver/core/element.py
index 95e73de..359018f 100644
--- a/botasaurus_driver/core/element.py
+++ b/botasaurus_driver/core/element.py
@@ -9,6 +9,7 @@
from ..driver_utils import create_screenshot_filename, get_download_directory, get_download_filename
from . import util
+from . import keys
from ._contradict import ContraDict
from .config import PathLike
from .. import cdp
@@ -758,9 +759,7 @@ def send_keys(self, text: str):
"""
self.raise_if_disconnected()
self.apply("(elem) => elem.focus()")
- for char in list(text):
- self._tab.send(cdp.input_.dispatch_key_event("char", text=char))
-
+ keys.type_text(self._tab, text)
self.update()
def send_file(self, *file_paths):
"""
diff --git a/botasaurus_driver/core/keys.py b/botasaurus_driver/core/keys.py
new file mode 100644
index 0000000..3ce9b48
--- /dev/null
+++ b/botasaurus_driver/core/keys.py
@@ -0,0 +1,124 @@
+"""US-layout key maps and resolver for real CDP key-event typing.
+
+Mapped characters are dispatched as keyDown (with text) + keyUp, with Shift
+wrapping when needed. Unmapped characters (Unicode, emoji, dead-key output)
+fall back to Input.insertText.
+"""
+from .. import cdp
+
+KEYBOARD_LAYOUT = {
+ "a": "KeyA", "b": "KeyB", "c": "KeyC", "d": "KeyD", "e": "KeyE", "f": "KeyF",
+ "g": "KeyG", "h": "KeyH", "i": "KeyI", "j": "KeyJ", "k": "KeyK", "l": "KeyL",
+ "m": "KeyM", "n": "KeyN", "o": "KeyO", "p": "KeyP", "q": "KeyQ", "r": "KeyR",
+ "s": "KeyS", "t": "KeyT", "u": "KeyU", "v": "KeyV", "w": "KeyW", "x": "KeyX",
+ "y": "KeyY", "z": "KeyZ",
+ "0": "Digit0", "1": "Digit1", "2": "Digit2", "3": "Digit3", "4": "Digit4",
+ "5": "Digit5", "6": "Digit6", "7": "Digit7", "8": "Digit8", "9": "Digit9",
+ " ": "Space", "\n": "Enter", "\r": "Enter", "\t": "Tab",
+ ".": "Period", ",": "Comma", "-": "Minus", "=": "Equal", "/": "Slash",
+ "\\": "Backslash", ";": "Semicolon", "'": "Quote",
+ "[": "BracketLeft", "]": "BracketRight", "`": "Backquote",
+ "@": "Digit2", "!": "Digit1", "#": "Digit3", "$": "Digit4", "%": "Digit5",
+ "^": "Digit6", "&": "Digit7", "*": "Digit8", "(": "Digit9", ")": "Digit0",
+ "_": "Minus", "+": "Equal",
+}
+
+SHIFT_CHARS = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()_+{}|:"<>?~')
+
+VK_CODES = {
+ "0": 48, "1": 49, "2": 50, "3": 51, "4": 52, "5": 53, "6": 54,
+ "7": 55, "8": 56, "9": 57,
+ "a": 65, "b": 66, "c": 67, "d": 68, "e": 69, "f": 70, "g": 71,
+ "h": 72, "i": 73, "j": 74, "k": 75, "l": 76, "m": 77, "n": 78,
+ "o": 79, "p": 80, "q": 81, "r": 82, "s": 83, "t": 84, "u": 85,
+ "v": 86, "w": 87, "x": 88, "y": 89, "z": 90,
+ " ": 32, "\n": 13, "\r": 13, "\t": 9,
+ ".": 190, ",": 188, "-": 189, "=": 187, "/": 191, "\\": 220,
+ ";": 186, "'": 222, "`": 192, "[": 219, "]": 221,
+ "@": 50, "!": 49, "#": 51, "$": 52, "%": 53, "^": 54, "&": 55,
+ "*": 56, "(": 57, ")": 48, "_": 189, "+": 187,
+ "{": 219, "}": 221, "|": 220, ":": 186, '"': 222,
+ "<": 188, ">": 190, "?": 191, "~": 192,
+}
+
+
+def resolve_key(char):
+ """Return (code, key, vk, needs_shift) for a US-layout char, else None."""
+ if char.isalpha():
+ code = KEYBOARD_LAYOUT.get(char.lower())
+ else:
+ code = KEYBOARD_LAYOUT.get(char)
+ if code is None:
+ return None
+ if char in ("\n", "\r"):
+ return ("Enter", "Enter", 13, False)
+ if char == "\t":
+ return ("Tab", "Tab", 9, False)
+ vk = VK_CODES[char.lower() if char.isalpha() else char]
+ return (code, char, vk, char in SHIFT_CHARS)
+
+
+def dispatch_key(tab, char):
+ """Dispatch keyDown + keyUp (with Shift wrapping) for one mapped char."""
+ code, key_val, vk, needs_shift = resolve_key(char)
+ text_val = "\r" if char in ("\n", "\r") else char
+ modifiers = 8 if needs_shift else 0
+ if needs_shift:
+ tab.send(
+ cdp.input_.dispatch_key_event(
+ "rawKeyDown",
+ code="ShiftLeft",
+ key="Shift",
+ windows_virtual_key_code=16,
+ native_virtual_key_code=16,
+ modifiers=8,
+ location=1,
+ )
+ )
+ tab.send(
+ cdp.input_.dispatch_key_event(
+ "keyDown",
+ text=text_val,
+ unmodified_text=char,
+ code=code,
+ key=key_val,
+ windows_virtual_key_code=vk,
+ native_virtual_key_code=vk,
+ modifiers=modifiers,
+ )
+ )
+ tab.send(
+ cdp.input_.dispatch_key_event(
+ "keyUp",
+ code=code,
+ key=key_val,
+ windows_virtual_key_code=vk,
+ native_virtual_key_code=vk,
+ modifiers=modifiers,
+ )
+ )
+ if needs_shift:
+ tab.send(
+ cdp.input_.dispatch_key_event(
+ "keyUp",
+ code="ShiftLeft",
+ key="Shift",
+ windows_virtual_key_code=16,
+ native_virtual_key_code=16,
+ location=1,
+ )
+ )
+
+
+def type_text(tab, text):
+ """Type ``text`` via real key events, collapsing ``\\r\\n`` to one Enter."""
+ i = 0
+ n = len(text)
+ while i < n:
+ char = text[i]
+ crlf = char == "\r" and i + 1 < n and text[i + 1] == "\n"
+ if resolve_key(char) is None:
+ tab.send(cdp.input_.insert_text(char))
+ else:
+ dispatch_key(tab, char)
+ i += 2 if crlf else 1
diff --git a/botasaurus_driver/core/tab.py b/botasaurus_driver/core/tab.py
index 8e510e6..2cf875b 100644
--- a/botasaurus_driver/core/tab.py
+++ b/botasaurus_driver/core/tab.py
@@ -10,6 +10,7 @@
from ..driver_utils import create_screenshot_filename, get_download_directory, get_download_filename
from . import element
+from . import keys
from . import util
from .config import PathLike
from .connection import Connection
@@ -1557,7 +1558,12 @@ def bypass_insecure_connection_warning(self):
:rtype:
"""
body = self.select("body")
- body.send_keys("thisisunsafe")
+ body.apply("(elem) => elem.focus()")
+ # Type directly on the tab: typing "thisisunsafe" makes Chrome advance
+ # past the interstitial, which navigates the page and invalidates the
+ # body node, so Element.send_keys' trailing self.update() would raise a
+ # stale-node error. Going through the keyboard helper avoids that.
+ keys.type_text(self, "thisisunsafe")
def mouse_move(self, x: float, y: float, steps=10, flash=False):
self.send(cdp.input_.dispatch_mouse_event("mouseMoved", x=x, y=y))
diff --git a/demo_typing.py b/demo_typing.py
new file mode 100644
index 0000000..770e75d
--- /dev/null
+++ b/demo_typing.py
@@ -0,0 +1,386 @@
+#!/usr/bin/env python3
+"""Gate for real key-event Element.send_keys. Run: python3 demo_typing.py
+
+Exits non-zero on FAIL. SKIP (unreachable badssl host) is not a failure.
+"""
+import os
+import socket
+import string
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+_ROOT = os.path.dirname(os.path.abspath(__file__))
+if _ROOT not in sys.path:
+ sys.path.insert(0, _ROOT)
+
+from botasaurus_driver import Driver
+
+# Characters that are intentionally unmapped and go through Input.insertText.
+INSERT_TEXT_CHARS = set('{}|:"<>?~\x0b\x0c')
+BADSSL_HOST = "self-signed.badssl.com"
+BADSSL_URL = "https://self-signed.badssl.com/"
+SAMPLE = "A+b@c_D.e"
+UNICODE_SAMPLE = "é中😀"
+
+HARNESS_HTML = """
+
+
typing gate
+
+
+
+
+
+
+
+
+"""
+
+
+class Skip(Exception):
+ pass
+
+
+def check(cond, msg):
+ if not cond:
+ raise AssertionError(msg)
+
+
+def mapped_printable():
+ return [c for c in string.printable if c not in INSERT_TEXT_CHARS]
+
+
+def js(driver, expr):
+ return driver.run_js("return " + expr)
+
+
+def get_events(driver):
+ ev = js(driver, "window.__getEvents()")
+ return ev if ev else []
+
+
+def key_seq(events):
+ return [
+ (e.get("type"), e.get("key"))
+ for e in events
+ if e.get("type") in ("keydown", "keypress", "keyup")
+ ]
+
+
+def key_edges(events):
+ return [e for e in events if e.get("type") in ("keydown", "keyup")]
+
+
+def reset_capture(driver):
+ driver.run_js("window.__reset();")
+
+
+def type_in(driver, selector, text):
+ el = driver.select(selector)
+ check(el is not None, "missing element " + selector)
+ el.type(text)
+ return el
+
+
+def clear_value(driver, selector):
+ el = driver.select(selector)
+ check(el is not None, "missing element " + selector)
+ el.run_js("(el) => { el.value = ''; }")
+ return el
+
+
+def load_harness(driver, uri):
+ driver.get(uri)
+ reset_capture(driver)
+
+
+def test_ordering_and_count(driver):
+ clear_value(driver, "#type-input")
+ reset_capture(driver)
+ type_in(driver, "#type-input", "ab")
+ got = key_seq(get_events(driver))
+ want = [
+ ("keydown", "a"),
+ ("keypress", "a"),
+ ("keyup", "a"),
+ ("keydown", "b"),
+ ("keypress", "b"),
+ ("keyup", "b"),
+ ]
+ check(got == want, "ordering/count for 'ab': got %r want %r" % (got, want))
+
+
+def test_code_never_empty(driver):
+ for ch in mapped_printable():
+ el = clear_value(driver, "#type-input")
+ el.run_js("(el) => el.focus()")
+ reset_capture(driver)
+ el.type(ch)
+ events = get_events(driver)
+ edges = key_edges(events)
+ check(
+ edges,
+ "mapped char %r produced no keydown/keyup (missing KEYBOARD_LAYOUT entry?)"
+ % ch,
+ )
+ for e in edges:
+ check(
+ e.get("code"),
+ "empty code for %r on %s (key=%r)"
+ % (ch, e.get("type"), e.get("key")),
+ )
+
+
+def test_keycode_fidelity(driver):
+ clear_value(driver, "#type-input")
+ reset_capture(driver)
+ type_in(driver, "#type-input", "a")
+ events = get_events(driver)
+ downs = [e for e in events if e.get("type") == "keydown" and e.get("key") == "a"]
+ presses = [e for e in events if e.get("type") == "keypress" and e.get("key") == "a"]
+ check(downs, "no keydown for 'a'")
+ check(presses, "no keypress for 'a'")
+ check(
+ downs[0].get("keyCode") == 65,
+ "keydown.keyCode for 'a' is %r want 65" % downs[0].get("keyCode"),
+ )
+ check(
+ presses[0].get("keyCode") == 97,
+ "keypress.keyCode for 'a' is %r want 97" % presses[0].get("keyCode"),
+ )
+
+
+def test_shift_state_and_pairing(driver):
+ clear_value(driver, "#type-input")
+ reset_capture(driver)
+ type_in(driver, "#type-input", "@")
+ events = [
+ e
+ for e in get_events(driver)
+ if e.get("type") in ("keydown", "keypress", "keyup")
+ ]
+ shift_downs = [
+ i
+ for i, e in enumerate(events)
+ if e.get("type") == "keydown" and e.get("key") == "Shift"
+ ]
+ at_idxs = [i for i, e in enumerate(events) if e.get("key") == "@"]
+ check(shift_downs, "no Shift keydown before @")
+ check(at_idxs, "no @ events")
+ check(
+ shift_downs[0] < at_idxs[0],
+ "Shift keydown must precede @ (Shift at %s, @ at %s)"
+ % (shift_downs[0], at_idxs[0]),
+ )
+ for e in (events[i] for i in at_idxs):
+ check(e.get("shiftKey") is True, "@ event missing shiftKey: %r" % e)
+ check(e.get("code") == "Digit2", "@ code is %r want Digit2" % e.get("code"))
+ check(e.get("key") == "@", "@ key is %r" % e.get("key"))
+ shift_events = [e for e in events if e.get("key") == "Shift"]
+ ups = [e for e in shift_events if e.get("type") == "keyup"]
+ downs = [e for e in shift_events if e.get("type") == "keydown"]
+ check(ups, "no Shift keyup after @")
+ check(ups[-1].get("shiftKey") is False, "Shift keyup should have shiftKey false")
+ check(len(downs) == len(ups), "orphan Shift: %s downs, %s ups" % (len(downs), len(ups)))
+ check(
+ shift_events[-1].get("type") == "keyup",
+ "last Shift event is %s, not keyup" % shift_events[-1].get("type"),
+ )
+
+
+def test_newline_contract(driver):
+ for text in ("\n", "\r", "\r\n"):
+ clear_value(driver, "#form-input")
+ reset_capture(driver)
+ type_in(driver, "#form-input", text)
+ events = get_events(driver)
+ downs = [
+ e
+ for e in events
+ if e.get("type") == "keydown"
+ and e.get("key") == "Enter"
+ and e.get("code") == "Enter"
+ ]
+ submits = js(driver, "window.__submits")
+ label = repr(text)
+ check(len(downs) == 1, "%s: want 1 Enter keydown, got %s" % (label, len(downs)))
+ check(submits == 1, "%s: want 1 form submit, got %r" % (label, submits))
+
+ reset_capture(driver)
+ type_in(driver, "#type-input", "\t")
+ events = get_events(driver)
+ tabs = [
+ e
+ for e in events
+ if e.get("type") in ("keydown", "keyup") and e.get("key") == "Tab"
+ ]
+ check(tabs, "no Tab keydown/keyup")
+ check(
+ all(e.get("key") == "Tab" and e.get("code") == "Tab" for e in tabs),
+ "Tab event key/code mismatch: %r" % tabs,
+ )
+ active = js(driver, "document.activeElement && document.activeElement.id")
+ check(
+ active and active != "type-input",
+ "Tab did not move document.activeElement (still %r)" % active,
+ )
+
+ reset_capture(driver)
+ type_in(driver, "#space-btn", " ")
+ clicks = js(driver, "window.__clicks")
+ check(clicks == 1, "space on button: want 1 click, got %r" % clicks)
+
+
+def test_unicode_round_trip(driver):
+ el = clear_value(driver, "#type-input")
+ reset_capture(driver)
+ el.type(UNICODE_SAMPLE)
+ check(
+ el.value == UNICODE_SAMPLE,
+ "unicode value %r want %r" % (el.value, UNICODE_SAMPLE),
+ )
+
+
+def test_final_value(driver):
+ el = clear_value(driver, "#type-input")
+ reset_capture(driver)
+ el.type(SAMPLE)
+ check(el.value == SAMPLE, "final value %r want %r" % (el.value, SAMPLE))
+
+
+def _badssl_reachable():
+ try:
+ socket.create_connection((BADSSL_HOST, 443), timeout=5).close()
+ return True
+ except OSError:
+ return False
+
+
+def test_bypass_insecure_connection_warning(driver):
+ if not _badssl_reachable():
+ raise Skip("%s unreachable" % BADSSL_URL)
+ try:
+ driver.get(BADSSL_URL, timeout=20)
+ except Exception as e:
+ raise Skip("could not open %s: %s" % (BADSSL_URL, e))
+ driver.allow_insecure_connections()
+ time.sleep(1.5)
+ blob = " ".join(
+ [
+ str(driver.title or ""),
+ str(driver.page_text or ""),
+ str(js(driver, "location.hostname") or ""),
+ ]
+ ).lower()
+ still_blocked = (
+ "your connection is not private" in blob
+ or "net::err_cert" in blob
+ or "privacy error" in blob
+ )
+ check(not still_blocked, "still on certificate warning after bypass")
+ check(
+ "badssl" in blob or "self-signed" in blob,
+ "bypass did not reach the badssl page (title/text=%r)" % blob[:200],
+ )
+
+
+HARNESS_TESTS = (
+ test_ordering_and_count,
+ test_code_never_empty,
+ test_keycode_fidelity,
+ test_shift_state_and_pairing,
+ test_newline_contract,
+ test_unicode_round_trip,
+ test_final_value,
+)
+
+
+def main():
+ html_path = None
+ driver = None
+ failed = 0
+ fd, html_path = tempfile.mkstemp(suffix=".html")
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
+ fh.write(HARNESS_HTML)
+ uri = Path(html_path).as_uri()
+ driver = Driver(headless=True)
+
+ results = []
+ for fn in HARNESS_TESTS:
+ load_harness(driver, uri)
+ results.append(_run(fn, driver))
+ results.append(_run(test_bypass_insecure_connection_warning, driver))
+
+ for status, name, detail in results:
+ if detail:
+ print("%s %s: %s" % (status, name, detail))
+ else:
+ print("%s %s" % (status, name))
+ if status == "FAIL":
+ failed += 1
+ finally:
+ if driver is not None:
+ try:
+ driver.close()
+ except Exception:
+ pass
+ if html_path and os.path.exists(html_path):
+ os.remove(html_path)
+ sys.exit(1 if failed else 0)
+
+
+def _run(fn, driver):
+ name = fn.__name__
+ try:
+ fn(driver)
+ return ("PASS", name, "")
+ except Skip as e:
+ return ("SKIP", name, str(e))
+ except Exception as e:
+ return ("FAIL", name, str(e))
+
+
+if __name__ == "__main__":
+ main()