From 6b4be0c9c2fde717cb16fdd399d2b5b186f57a5e Mon Sep 17 00:00:00 2001 From: Joshua Fehler <jsfehler@gmail.com> Date: Tue, 11 Jun 2024 11:20:50 -0400 Subject: [PATCH] Add keyboard class and Element.press() --- docs/index.rst | 1 + docs/keyboard.rst | 99 ++++++++++++++++++++ splinter/driver/__init__.py | 14 +++ splinter/driver/webdriver/__init__.py | 6 ++ splinter/driver/webdriver/keyboard.py | 122 +++++++++++++++++++++++++ tests/static/index.html | 16 ++++ tests/tests_webdriver/test_keyboard.py | 66 +++++++++++++ 7 files changed, 324 insertions(+) create mode 100644 docs/keyboard.rst create mode 100644 splinter/driver/webdriver/keyboard.py create mode 100644 tests/tests_webdriver/test_keyboard.py diff --git a/docs/index.rst b/docs/index.rst index 30c857991..7146506a7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -38,6 +38,7 @@ cookies screenshot javascript + keyboard selenium-keys iframes-and-alerts http-status-code-and-exception diff --git a/docs/keyboard.rst b/docs/keyboard.rst new file mode 100644 index 000000000..620daacf9 --- /dev/null +++ b/docs/keyboard.rst @@ -0,0 +1,99 @@ +.. Copyright 2024 splinter authors. All rights reserved. + Use of this source code is governed by a BSD-style + license that can be found in the LICENSE file. + +.. meta:: + :description: Keyboard + :keywords: splinter, python, tutorial, documentation, selenium integration, selenium keys, keyboard events + +++++++++ +Keyboard +++++++++ + +The browser provides an interface for using the keyboard. + +However, input is limited to the page. You cannot control the browser or your +operating system using this. + +Down +---- + +Hold a key down. + +.. code-block:: python + + from splinter import Browser + + + browser = Browser() + browser.visit("https://duckduckgo.com/") + browser.keyboard.down("CONTROL") + + +Up +-- + +Release a key. If the key is not held down, this will do nothing. + +.. code-block:: python + + from splinter import Browser + + + browser = Browser() + browser.visit("https://duckduckgo.com/") + browser.keyboard.down("CONTROL") + browser.keyboard.up("CONTROL") + + +Press +----- + +Hold and then release a key pattern. + +.. code-block:: python + + from splinter import Browser + + + browser = Browser() + browser.visit("https://duckduckgo.com/") + browser.keyboard.press("CONTROL") + +Key patterns are keys separated by the '+' symbol. +This allows multiple presses to be chained together: + +.. code-block:: python + + from splinter import Browser + + + browser = Browser() + browser.visit("https://duckduckgo.com/") + browser.keyboard.press("CONTROL+a") + +.. warning:: + Although a key pattern such as "SHIFT+awesome" will be accepted, + the press method is designed for single keys. There may be unintended + side effects to using it in place of Element.fill() or Element.type(). + +Element.press() +~~~~~~~~~~~~~~~ + +Elements can be pressed directly. + +.. code-block:: python + + from splinter import Browser + + + browser = Browser() + browser.visit("https://duckduckgo.com/") + elem = browser.find_by_css("#searchbox_input") + elem.fill("splinter python") + elem.press("ENTER") + + results = browser.find_by_xpath("//section[@data-testid='mainline']/ol/li") + + # Open in a new tab behind the current one. + results.first.press("CONTROL+ENTER") diff --git a/splinter/driver/__init__.py b/splinter/driver/__init__.py index a57ea263f..44f8fed7c 100644 --- a/splinter/driver/__init__.py +++ b/splinter/driver/__init__.py @@ -871,6 +871,20 @@ def type(self, value: str, slowly: bool = False) -> str: # NOQA: A003 """ raise NotImplementedError + def press(self, key_pattern: str, delay: int = 0) -> None: + """Focus the element, hold, and then release the specified key pattern. + + Arguments: + key_pattern: Pattern of keys to hold and release. + delay: Time, in seconds, to wait between key down and key up. + + Example: + + >>> browser.find_by_css('.my_element').press('CONTROL+a') + + """ + raise NotImplementedError + def select(self, value: str, slowly: bool = False) -> None: """ Select an ``<option>`` element in the element using the ``value`` of the ``<option>``. diff --git a/splinter/driver/webdriver/__init__.py b/splinter/driver/webdriver/__init__.py index cd8049f9c..fac392b1c 100644 --- a/splinter/driver/webdriver/__init__.py +++ b/splinter/driver/webdriver/__init__.py @@ -25,6 +25,7 @@ from splinter.driver import ElementAPI from splinter.driver.find_links import FindLinks from splinter.driver.webdriver.cookie_manager import CookieManager +from splinter.driver.webdriver.keyboard import Keyboard from splinter.driver.xpath_utils import _concat_xpath_from_str from splinter.element_list import ElementList from splinter.exceptions import ElementDoesNotExist @@ -266,6 +267,7 @@ def __init__(self, driver=None, wait_time=2): self.wait_time = wait_time self.links = FindLinks(self) + self.keyboard = Keyboard(driver) self.driver = driver self._find_elements = self.driver.find_elements @@ -792,6 +794,10 @@ def type(self, value, slowly=False): # NOQA: A003 self._element.send_keys(value) return value + def press(self, key_pattern: str, delay: int = 0) -> None: + keyboard = Keyboard(self.driver, self._element) + keyboard.press(key_pattern, delay) + def click(self): """Click an element. diff --git a/splinter/driver/webdriver/keyboard.py b/splinter/driver/webdriver/keyboard.py new file mode 100644 index 000000000..c8ffe990a --- /dev/null +++ b/splinter/driver/webdriver/keyboard.py @@ -0,0 +1,122 @@ +from typing import Union + +from selenium.webdriver.common.action_chains import ActionChains +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.remote.webelement import WebElement + + +class Keyboard: + """Representation of a keyboard. + + Requires a WebDriver instance to use. + + Arguments: + driver: The WebDriver instance to use. + element: Optionally, a WebElement to act on. + """ + + def __init__(self, driver, element: Union[WebElement, None] = None) -> None: + self.driver = driver + + self.element = element + + def _resolve_key_down_action(self, action_chain: ActionChains, key: str) -> ActionChains: + """Given the string <key>, select the correct action for key down. + + For modifier keys, use ActionChains.key_down(). + For other keys, use ActionChains.send_keys() or ActionChains.send_keys_to_element() + """ + key_value = getattr(Keys, key, None) + + if key_value: + chain = action_chain.key_down(key_value, self.element) + elif self.element: + chain = action_chain.send_keys_to_element(self.element, key) + else: + chain = action_chain.send_keys(key) + + return chain + + def _resolve_key_up_action(self, action_chain: ActionChains, key: str) -> ActionChains: + """Given the string <key>, select the correct action for key up. + + For modifier keys, use ActionChains.key_up(). + For other keys, use ActionChains.send_keys() or ActionChains.send_keys_to_element() + """ + key_value = getattr(Keys, key, None) + + chain = action_chain + if key_value: + chain = action_chain.key_up(key_value, self.element) + + return chain + + def down(self, key: str) -> "Keyboard": + """Hold down on a key. + + Arguments: + key: The name of a key to hold. + + Example: + + >>> b = Browser() + >>> Keyboard(b.driver).down('SHIFT') + """ + chain = ActionChains(self.driver) + chain = self._resolve_key_down_action(chain, key) + chain.perform() + return self + + def up(self, key: str) -> "Keyboard": + """Release a held key. + + If <key> is not held down, this method has no effect. + + Arguments: + key: The name of a key to release. + + Example: + + >>> b = Browser() + >>> Keyboard(b.driver).down('SHIFT') + >>> Keyboard(b.driver).up('SHIFT') + """ + chain = ActionChains(self.driver) + chain = self._resolve_key_up_action(chain, key) + chain.perform() + return self + + def press(self, key_pattern: str, delay: int = 0) -> "Keyboard": + """Hold and release a key pattern. + + Key patterns are strings of key names separated by '+'. + The following are examples of key patterns: + - 'CONTROL' + - 'CONTROL+a' + - 'CONTROL+a+BACKSPACE+b' + + Arguments: + key_pattern: Pattern of keys to hold and release. + delay: Time, in seconds, to wait between the hold and release. + + Example: + + >>> b = Browser() + >>> Keyboard(b.driver).press('CONTROL+a') + """ + keys_names = key_pattern.split("+") + + chain = ActionChains(self.driver) + + for item in keys_names: + chain = self._resolve_key_down_action(chain, item) + + if delay: + chain = chain.pause(delay) + + for item in keys_names: + chain = self._resolve_key_up_action(chain, item) + + chain.perform() + + return self diff --git a/tests/static/index.html b/tests/static/index.html index a8265e73e..9e547c1a1 100644 --- a/tests/static/index.html +++ b/tests/static/index.html @@ -32,6 +32,22 @@ addShadowRoot(); }, false); + document.onkeydown = function (e) { + if (e.key === "Control") { + $('body').append('<div id="keypress_detect">Added when "Control" key is pressed down.</div>'); + } + if (e.key === "a") { + $('body').append('<div id="keypress_detect_a">Added when "a" key is pressed down.</div>'); + } + }; + + document.onkeyup = function (e) { + e = e || window.event; + if (e.key === "Control") { + $('body').append('<div id="keyup_detect">Added when "Control" key is released.</div>'); + } + }; + $(document).ready(function() { $(".draggable").draggable(); $(".droppable").droppable({ diff --git a/tests/tests_webdriver/test_keyboard.py b/tests/tests_webdriver/test_keyboard.py new file mode 100644 index 000000000..e29d1fb42 --- /dev/null +++ b/tests/tests_webdriver/test_keyboard.py @@ -0,0 +1,66 @@ +import platform + +from splinter.driver.webdriver import Keyboard + + +def test_keyboard_down_modifier(browser, app_url): + browser.visit(app_url) + + keyboard = Keyboard(browser.driver) + + keyboard.down("CONTROL") + + elem = browser.find_by_css("#keypress_detect") + assert elem.first + + +def test_keyboard_up_modifier(browser, app_url): + browser.visit(app_url) + + keyboard = Keyboard(browser.driver) + + keyboard.down("CONTROL") + keyboard.up("CONTROL") + + elem = browser.find_by_css("#keyup_detect") + assert elem.first + + +def test_keyboard_press_modifier(browser, app_url): + browser.visit(app_url) + + keyboard = Keyboard(browser.driver) + + keyboard.press("CONTROL") + + elem = browser.find_by_css("#keyup_detect") + assert elem.first + + +def test_element_press_combo(browser, app_url): + browser.visit(app_url) + + keyboard = Keyboard(browser.driver) + + keyboard.press("CONTROL+a") + + elem = browser.find_by_css("#keypress_detect_a") + assert elem.first + + +def test_element_copy_paste(browser, app_url): + control_key = "META" if platform.system() == "Darwin" else "CONTROL" + + browser.visit(app_url) + + elem = browser.find_by_name("q") + elem.fill("Copy this value") + elem.press(f"{control_key}+a") + elem.press(f"{control_key}+c") + elem.clear() + + assert elem.first.value == "" + + elem.press(f"{control_key}+v") + + assert elem.first.value == "Copy this Value"