From c65ef6cfe4b049e1ec363f1a85e409ea810ce6ec Mon Sep 17 00:00:00 2001 From: Joshua Fehler Date: Sun, 23 Jun 2024 08:36:21 -0400 Subject: [PATCH 1/2] Allow WebDriverElement to be used with Browser().execute_script() --- docs/javascript.rst | 85 +++++++++++--- splinter/driver/__init__.py | 36 ++++-- splinter/driver/webdriver/__init__.py | 40 ++++++- tests/tests_webdriver/test_javascript.py | 14 --- .../test_javascript/test_evaluate_script.py | 76 +++++++++++++ .../test_javascript/test_execute_script.py | 105 ++++++++++++++++++ 6 files changed, 315 insertions(+), 41 deletions(-) delete mode 100644 tests/tests_webdriver/test_javascript.py create mode 100644 tests/tests_webdriver/test_javascript/test_evaluate_script.py create mode 100644 tests/tests_webdriver/test_javascript/test_execute_script.py diff --git a/docs/javascript.rst b/docs/javascript.rst index 33b074f1e..2f896aab2 100644 --- a/docs/javascript.rst +++ b/docs/javascript.rst @@ -3,47 +3,106 @@ license that can be found in the LICENSE file. .. meta:: - :description: Executing javascript + :description: Execute JavaScript In The Browser :keywords: splinter, python, tutorial, javascript ++++++++++++++++++ Execute JavaScript ++++++++++++++++++ -You can easily execute JavaScript, in drivers which support it: +When using WebDriver-based drivers, you can run JavaScript inside the web +browser. + +Execute +======= + +The `execute_script()` method takes a string containing JavaScript code and +executes it. + +JSON-serializable objects and WebElements can be sent to the browser and used +by the JavaScript. + +Examples +-------- + +Change the Background Color of an Element +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. highlight:: python :: - browser.execute_script("$('body').empty()") + browser = Browser() + + browser.execute_script( + "document.querySelector('body').setAttribute('style', 'background-color: red')", + ) -You can return the result of the script: +Sending a WebElement to the browser +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. highlight:: python :: - browser.evaluate_script("4+4") == 8 + browser = Browser() + + elem = browser.find_by_tag('body').first + browser.execute_script( + "arguments[0].setAttribute('style', 'background-color: red')", + elem, + ) + + + +Evaluate +======== + +The `evaluate_script()` method takes a string containing a JavaScript +expression and runs it, then returns the result. + +JSON-serializable objects and WebElements can be sent to the browser and used +by the JavaScript. + +Examples +-------- + +Get the href from the browser +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. highlight:: python + +:: + + browser = Browser() + + href = browser.evaluate_script("document.location.href") + +Cookbook +======== -Example: Manipulate text fields with JavaScript -+++++++++++++++++++++++++++++++++++++++++++++++++ +Manipulate text fields with JavaScript +-------------------------------------- -Some text input actions cannot be "typed" thru ``browser.fill()``, like new lines and tab characters. Below is en example how to work around this using ``browser.execute_script()``. This is also much faster than ``browser.fill()`` as there is no simulated key typing delay, making it suitable for longer texts. +Some text input actions cannot be "typed" thru ``browser.fill()``, like new lines and tab characters. +Below is en example how to work around this using ``browser.execute_script()``. +This is also much faster than ``browser.fill()`` as there is no simulated key typing delay, making it suitable for longer texts. :: - def fast_fill_by_javascript(browser: DriverAPI, elem_id: str, text: str): + def fast_fill(browser, query: str, text: str): """Fill text field with copy-paste, not by typing key by key. Otherwise you cannot type enter or tab. - :param id: CSS id of the textarea element to fill + Arguments: + query: CSS id of the textarea element to fill """ text = text.replace("\t", "\\t") text = text.replace("\n", "\\n") - # Construct a JavaScript snippet that is executed on the browser sdie - snippet = f"""document.querySelector("#{elem_id}").value = "{text}";""" - browser.execute_script(snippet) + elem = browser.find_by_css(query).first + # Construct a JavaScript snippet that is executed on the browser side + script = f"arguments[0].value = "{text}";" + browser.execute_script(script, elem) diff --git a/splinter/driver/__init__.py b/splinter/driver/__init__.py index a57ea263f..0a7c9c5a7 100644 --- a/splinter/driver/__init__.py +++ b/splinter/driver/__init__.py @@ -107,35 +107,47 @@ def get_iframe(self, name: Any) -> Any: """ raise NotImplementedError("%s doesn't support frames." % self.driver_name) - def execute_script(self, script: str, *args: str) -> Any: - """Execute a piece of JavaScript in the browser. + def execute_script(self, script: str, *args: Any) -> Any: + """Execute JavaScript in the current browser window. + + The code is assumed to be synchronous. Arguments: - script (str): The piece of JavaScript to execute. + script (str): The JavaScript code to execute. + args: Any of: + - JSON-serializable objects. + - WebElement. + These will be available to the JavaScript as the `arguments` object. Example: - >>> browser.execute_script('document.getElementById("body").innerHTML = "

Hello world!

"') + >>> Browser().execute_script('document.querySelector("body").innerHTML = "

Hello world!

"') """ raise NotImplementedError( - "%s doesn't support execution of arbitrary JavaScript." % self.driver_name, + f"{self.driver_name} doesn't support execution of arbitrary JavaScript.", ) - def evaluate_script(self, script: str, *args: str) -> Any: - """ - Similar to :meth:`execute_script ` method. + def evaluate_script(self, script: str, *args: Any) -> Any: + """Evaluate JavaScript in the current browser window and return the completion value. - Execute javascript in the browser and return the value of the expression. + The code is assumed to be synchronous. Arguments: - script (str): The piece of JavaScript to execute. + script (str): The JavaScript code to execute. + args: Any of: + - JSON-serializable objects. + - WebElement. + These will be available to the JavaScript as the `arguments` object. + + Returns: + The result of the code's execution. Example: - >>> assert 4 == browser.evaluate_script('2 + 2') + >>> assert 4 == Browser().evaluate_script('2 + 2') """ raise NotImplementedError( - "%s doesn't support evaluation of arbitrary JavaScript." % self.driver_name, + f"{self.driver_name} doesn't support evaluation of arbitrary JavaScript.", ) def find_by_css(self, css_selector: str) -> ElementList: diff --git a/splinter/driver/webdriver/__init__.py b/splinter/driver/webdriver/__init__.py index cd8049f9c..dd306e44a 100644 --- a/splinter/driver/webdriver/__init__.py +++ b/splinter/driver/webdriver/__init__.py @@ -316,11 +316,29 @@ def forward(self): def reload(self): self.driver.refresh() + def _script_prepare_args(self, args) -> list: + """Modify user arguments sent to execute_script() and evaluate_script(). + + If a WebDriverElement or ShadowRootElement is given, + replace it with their Element ID. + """ + result = [] + + for item in args: + if isinstance(item, (WebDriverElement, ShadowRootElement)): + result.append(item._as_id_dict()) + else: + result.append(item) + + return result + def execute_script(self, script, *args): - return self.driver.execute_script(script, *args) + converted_args = self._script_prepare_args(args) + return self.driver.execute_script(script, *converted_args) def evaluate_script(self, script, *args): - return self.driver.execute_script("return %s" % script, *args) + converted_args = self._script_prepare_args(args) + return self.driver.execute_script(f"return {script}", *converted_args) def is_element_present(self, finder, selector, wait_time=None): wait_time = wait_time or self.wait_time @@ -694,6 +712,15 @@ def __init__(self, element, parent): self.wait_time = self.parent.wait_time self.element_class = self.parent.element_class + def _as_id_dict(self) -> dict[str, str]: + """Get the canonical object to identify an element by it's ID. + + When sent to the browser, it will be used to build an Element object. + + Not to be confused with the 'id' tag on an element. + """ + return {"shadow-6066-11e4-a52e-4f735466cecf": self._element._id} + def _find(self, by: By, selector, wait_time=None): return self.find_by( self._element.find_elements, @@ -743,6 +770,15 @@ def _set_value(self, value): def __getitem__(self, attr): return self._element.get_attribute(attr) + def _as_id_dict(self) -> dict[str, str]: + """Get the canonical object to identify an element by it's ID. + + When sent to the browser, it will be used to build an Element object. + + Not to be confused with the 'id' tag on an element. + """ + return {"element-6066-11e4-a52e-4f735466cecf": self._element._id} + @property def text(self): return self._element.text diff --git a/tests/tests_webdriver/test_javascript.py b/tests/tests_webdriver/test_javascript.py deleted file mode 100644 index 9d086bfc3..000000000 --- a/tests/tests_webdriver/test_javascript.py +++ /dev/null @@ -1,14 +0,0 @@ -def test_can_execute_javascript(browser, app_url): - "should be able to execute javascript" - browser.visit(app_url) - browser.execute_script("$('body').empty()") - assert "" == browser.find_by_tag("body").value - - -def test_can_evaluate_script(browser): - "should evaluate script" - assert 8 == browser.evaluate_script("4+4") - - -def test_execute_script_returns_result_if_present(browser): - assert browser.execute_script("return 42") == 42 diff --git a/tests/tests_webdriver/test_javascript/test_evaluate_script.py b/tests/tests_webdriver/test_javascript/test_evaluate_script.py new file mode 100644 index 000000000..3a91a0714 --- /dev/null +++ b/tests/tests_webdriver/test_javascript/test_evaluate_script.py @@ -0,0 +1,76 @@ +import pytest + +from selenium.common.exceptions import JavascriptException + + +def test_evaluate_script_valid(browser, app_url): + """Scenario: Evaluating JavaScript Returns The Code's Result + + When I evaluate JavaScript code + Then the result of the evaluation is returned + """ + browser.visit(app_url) + + document_href = browser.evaluate_script("document.location.href") + assert app_url == document_href + + +def test_evaluate_script_valid_args(browser, app_url): + """Scenario: Execute Valid JavaScript With Arguments + + When I execute valid JavaScript code which modifies the DOM + And I send arguments to the web browser + Then the arguments are available for use + """ + browser.visit(app_url) + + browser.evaluate_script( + "document.querySelector('body').innerHTML = arguments[0] + arguments[1]", + "A String And ", + "Another String", + ) + + elem = browser.find_by_tag("body").first + assert elem.value == "A String And Another String" + + +def test_evaluate_script_valid_args_element(browser, app_url): + """Scenario: Execute Valid JavaScript + + When I execute valid JavaScript code + And I send an Element to the browser as an argument + Then the modifications are seen in the document + """ + browser.visit(app_url) + + elem = browser.find_by_id("firstheader").first + elem_text = browser.evaluate_script("arguments[0].innerHTML", elem) + assert elem_text == "Example Header" + + +def test_evaluate_script_invalid(browser, app_url): + """Scenario: Evaluate Invalid JavaScript. + + When I evaluate invalid JavaScript code + Then an error is raised + """ + browser.visit(app_url) + + with pytest.raises(JavascriptException): + browser.evaluate_script("invalid.thisIsNotGood()") + + +def test_evaluate_script_invalid_args(browser, app_url): + """Scenario: Execute Valid JavaScript + + When I execute valid JavaScript code which modifies the DOM + And I send an object to the browser which is not JSON serializable + Then an error is raised + """ + browser.visit(app_url) + + def unserializable(): + "You can't JSON serialize a function." + + with pytest.raises(TypeError): + browser.evaluate_script("arguments[0]", unserializable) diff --git a/tests/tests_webdriver/test_javascript/test_execute_script.py b/tests/tests_webdriver/test_javascript/test_execute_script.py new file mode 100644 index 000000000..add022794 --- /dev/null +++ b/tests/tests_webdriver/test_javascript/test_execute_script.py @@ -0,0 +1,105 @@ +import pytest + +from selenium.common.exceptions import JavascriptException + + +def test_execute_script_valid(browser, app_url): + """Scenario: Execute Valid JavaScript + + When I execute valid JavaScript code which modifies the DOM + Then the modifications are seen in the document + """ + browser.visit(app_url) + + browser.execute_script("document.querySelector('body').innerHTML = ''") + + elem = browser.find_by_tag("body").first + assert elem.value == "" + + +def test_execute_script_return_value(browser, app_url): + """Scenario: Execute Valid JavaScript With No Return Value + + When I execute valid JavaScript code + And the code does not return a value + Then no value is returned to the driver + """ + browser.visit(app_url) + + result = browser.execute_script("document.querySelector('body').innerHTML") + assert result is None + + +def test_execute_script_return_value_if_explicit(browser): + """Scenario: Execute Valid JavaScript With A Return Value + + When I execute JavaScript code + And the code returns a value + Then the value is returned from the web browser + """ + result = browser.execute_script("return 42") + assert result == 42 + + +def test_execute_script_valid_args(browser, app_url): + """Scenario: Execute Valid JavaScript With Arguments + + When I execute valid JavaScript code which modifies the DOM + And I send arguments to the web browser + Then the arguments are available for use + """ + browser.visit(app_url) + + browser.execute_script( + "document.querySelector('body').innerHTML = arguments[0] + arguments[1]", + "A String And ", + "Another String", + ) + + elem = browser.find_by_tag("body").first + assert elem.value == "A String And Another String" + + +def test_execute_script_valid_args_element(browser, app_url): + """Scenario: Execute Valid JavaScript With Arguments - Send Element + + When I execute valid JavaScript code + And I send an Element to the web browser as an argument + Then the argument is available for use + """ + browser.visit(app_url) + + elem = browser.find_by_id("firstheader").first + assert elem.value == "Example Header" + browser.execute_script("arguments[0].innerHTML = 'A New Header'", elem) + + elem = browser.find_by_id("firstheader").first + assert elem.value == "A New Header" + + +def test_execute_script_invalid(browser, app_url): + """Scenario: Evaluate Invalid JavaScript + + When I execute invalid JavaScript code + Then an error is raised + """ + browser.visit(app_url) + + with pytest.raises(JavascriptException): + browser.execute_script("invalid.thisIsNotGood()") + + +def test_execute_script_invalid_args(browser, app_url): + """Scenario: Execute Valid JavaScript With Invalid Arguments + + When I execute valid JavaScript code which modifies the DOM + And I send an object to the browser which is not JSON serializable + Then an error is raised + """ + browser.visit(app_url) + + def unserializable(): + "You can't JSON serialize a function." + + with pytest.raises(TypeError): + browser.execute_script("arguments[0]", unserializable) From c82ef3369a46a05600dc2bf5499802205b62c3ae Mon Sep 17 00:00:00 2001 From: Joshua Fehler Date: Sun, 23 Jun 2024 21:47:32 -0400 Subject: [PATCH 2/2] fixup: py38 --- splinter/driver/webdriver/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/splinter/driver/webdriver/__init__.py b/splinter/driver/webdriver/__init__.py index dd306e44a..bfdfa12a4 100644 --- a/splinter/driver/webdriver/__init__.py +++ b/splinter/driver/webdriver/__init__.py @@ -7,7 +7,7 @@ import time import warnings from contextlib import contextmanager -from typing import Optional +from typing import Dict, Optional from selenium.common.exceptions import ElementClickInterceptedException from selenium.common.exceptions import MoveTargetOutOfBoundsException @@ -712,7 +712,7 @@ def __init__(self, element, parent): self.wait_time = self.parent.wait_time self.element_class = self.parent.element_class - def _as_id_dict(self) -> dict[str, str]: + def _as_id_dict(self) -> Dict[str, str]: """Get the canonical object to identify an element by it's ID. When sent to the browser, it will be used to build an Element object. @@ -770,7 +770,7 @@ def _set_value(self, value): def __getitem__(self, attr): return self._element.get_attribute(attr) - def _as_id_dict(self) -> dict[str, str]: + def _as_id_dict(self) -> Dict[str, str]: """Get the canonical object to identify an element by it's ID. When sent to the browser, it will be used to build an Element object.