From 88294623176d6c91a78949dac07b2117a48117fb Mon Sep 17 00:00:00 2001 From: ddl-michael-noonan <107640482+ddl-michael-noonan@users.noreply.github.com> Date: Tue, 31 Oct 2023 14:50:54 -0700 Subject: [PATCH] QE-13818 Helper template for 2 interacting elements, added draggables (#381) ### Helper function I was looking to implement steps in cucu for draggable elements and I looked a little deeper at the helper code and found that there is a potential enhancement that cucu could have, templatized steps for 2 interacting elements. I started by implementing draggables as a proof of concept as a custom step on e2e-test branch. I then transferred that code over to cucu framework and had it working without any helper function implementation. That same code is now embedded in a helper function called `define_interaction_on_thing_with_name_steps` (final name pending) basically this is what it does... It intertwines 3 functions. 1. find function for finding the element you want to make the action with 2. find function for the finding the element you want the above element to interact with 3. Action function that defines what happens between the two found elements, in my case I found a draggable element, I found another generic element and I performed a drag action between them. It will be easiest to start on the `src/cucu/steps/draggable_steps.py` and then see how the helper function from here `src/cucu/helpers.py` is being used to allow for modularity across different applications. ### Draggable function After going through some trials on different browsers I noticed that the implementations for ActionChains was not consistent enough to be used in cucu. I then pivoted and created a JS_DRAG_DROP global variable that is then referenced in the `drag_element_to_element` function. This took a bit of refining to get right. Waits needs to be put in place to prevent the browser from executing too fast for the Webdriver to realize anything has happened. And with that we need to stay synchronous with python code that is being executed in tandem. That is where the Webdriver wait comes in. I setup a Webdriver wait to keep python executions and javascript executions in sync. `WebDriverWait(driver, 10).until(lambda driver: driver.execute_script("return window.dragAndDropCompleted;"))` This allows the drag and drop to be very consistent across all browser types. --- .gitignore | 1 + CHANGELOG.md | 4 + data/www/draggables.html | 53 +++++ features/browser/draggables.feature | 36 +++ features/browser/duplicate_draggables.feature | 34 +++ pyproject.toml | 2 +- src/cucu/helpers.py | 208 +++++++++++++++++- src/cucu/steps/__init__.py | 1 + src/cucu/steps/draggable_steps.py | 165 ++++++++++++++ 9 files changed, 499 insertions(+), 5 deletions(-) create mode 100644 data/www/draggables.html create mode 100644 features/browser/draggables.feature create mode 100644 features/browser/duplicate_draggables.feature create mode 100644 src/cucu/steps/draggable_steps.py diff --git a/.gitignore b/.gitignore index ab34e037..30ef7c0b 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ pygls.log report results .monitor.png +geckodriver.log # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index fdf87dfa..54b0b681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project closely adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.173.0 +- Add - functions and steps for dragging and dropping elements +- Add - add helper function for two interacting elements + ## 0.172.0 - Add repository and homepage fields to solve an issue with pip/poetry failing to install this package. diff --git a/data/www/draggables.html b/data/www/draggables.html new file mode 100644 index 00000000..9a42bf7a --- /dev/null +++ b/data/www/draggables.html @@ -0,0 +1,53 @@ + + + + + + +
Drag Me 1
+
Drag Me 2
+
Drag Me 3
+ + +
Drop Here 1
+
Drop Here 2
+
Drop Here 3
+
Drop Here 4
+ + + + diff --git a/features/browser/draggables.feature b/features/browser/draggables.feature new file mode 100644 index 00000000..8d70f08e --- /dev/null +++ b/features/browser/draggables.feature @@ -0,0 +1,36 @@ +Feature: Draggables + As a developer I want to make sure the test writer can interact with different + draggable elements + + Background: HTML page with draggables + Given I start a webserver at directory "data/www" and save the port to the variable "PORT" + And I open a browser at the url "http://{HOST_ADDRESS}:{PORT}/draggables.html" + + Scenario: User can drag element + When I wait to see the text "Drag" + Then I should immediately see the "2nd" element "Drag Me" is draggable + And I drag the element "Drag Me 1" to the element "Drop Here 2" + And I drag the element "Drag Me 2" to the element "Drop Here 1" + And I drag the element "Drag Me 3" to the element "Drop Here 3" + And I should immediately see the "2nd" element "Drop Here" is not draggable + + @negative + Scenario: User cannot drag element + When I wait to see the text "Drag" + Then I should immediately see the element "Drop Here 2" is not draggable + And I expect the following step to fail with "Unable to find the element \"Nonexistent drop\"" + """ + When I drag the element "Drag Me 1" to the element "Nonexistent drop" + """ + And I expect the following step to fail with "Unable to find the element \"Nonexistent drag\"" + """ + When I drag the element "Nonexistent drag" to the element "Drop Here 1" + """ + And I expect the following step to fail with "Unable to find the element \"Nonexistent drag\", Unable to find the element \"Nonexistent drop\"" + """ + When I drag the element "Nonexistent drag" to the element "Nonexistent drop" + """ + And I expect the following step to fail with "Drag element Drag Me 1 position did not change" + """ + When I drag the element "Drag Me 1" to the element "Drop Here 4" + """ diff --git a/features/browser/duplicate_draggables.feature b/features/browser/duplicate_draggables.feature new file mode 100644 index 00000000..7cd8239c --- /dev/null +++ b/features/browser/duplicate_draggables.feature @@ -0,0 +1,34 @@ +Feature: Duplicate Draggables + As a developer I want to make sure the test writer can interact with duplicate + draggable elements. + + Background: HTML page with draggables + Given I start a webserver at directory "data/www" and save the port to the variable "PORT" + And I open a browser at the url "http://{HOST_ADDRESS}:{PORT}/draggables.html" + + Scenario: User can drag nth element + When I wait to see the text "Drag" + Then I should immediately see the "2nd" element "Drag Me" is draggable + And I drag the "1st" element "Drag Me" to the "2nd" element "Drop Here" + And I drag the "2nd" element "Drag Me" to the "1st" element "Drop Here" + And I should immediately see the "2nd" element "Drop Here" is not draggable + + @negative + Scenario: User cannot drag nth elements + Then I should immediately see the "3rd" element "Drop Here" is not draggable + And I expect the following step to fail with "Unable to find the \"3rd\" element \"Nonexistent drop\"" + """ + When I drag the "2nd" element "Drag Me" to the "3rd" element "Nonexistent drop" + """ + And I expect the following step to fail with "Unable to find the \"2nd\" element \"Nonexistent drag\"" + """ + When I drag the "2nd" element "Nonexistent drag" to the "3rd" element "Drop Here" + """ + And I expect the following step to fail with "Unable to find the \"2nd\" element \"Nonexistent drag\", Unable to find the \"3rd\" element \"Nonexistent drop\"" + """ + When I drag the "2nd" element "Nonexistent drag" to the "3rd" element "Nonexistent drop" + """ + And I expect the following step to fail with "Drag element Drag Me 1 position did not change" + """ + When I drag the "1st" element "Drag Me" to the "4th" element "Drop Here" + """ diff --git a/pyproject.toml b/pyproject.toml index ccf809a5..0dc0432e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cucu" -version = "0.172.0" +version = "0.173.0" license = "MIT" description = "Easy BDD web testing" authors = ["Domino Data Lab "] diff --git a/src/cucu/helpers.py b/src/cucu/helpers.py index 3b6631d8..43c36204 100644 --- a/src/cucu/helpers.py +++ b/src/cucu/helpers.py @@ -257,7 +257,7 @@ def define_action_on_thing_with_name_steps( parameters: thing(string): name of the thing we're creating the steps for such as button, dialog, etc. - action(stirng): the name of the action being performed, such as: + action(string): the name of the action being performed, such as: click, disable, etc. find_func(function): function that returns the desired element: @@ -552,8 +552,8 @@ def base_should_see_the_in_state(ctx, thing, name, index=0): logger.debug(f'{thing} {name} was in desired state "{state}"') @step(f'I should immediately see the {thing} "{{name}}" is {state}') - def should_immedieately_see_the_in_state(ctx, thing, name, state, index=0): - base_should_see_the_in_state(ctx, thing, name, state, index=0) + def should_immediately_see_the_in_state(ctx, name, index=0): + base_should_see_the_in_state(ctx, thing, name, index=0) @step(f'I should see the {thing} "{{name}}" is {state}') def should_see_the_in_state(ctx, name): @@ -579,7 +579,7 @@ def wait_up_to_seconds_to_see_the_in_state(ctx, seconds, name): if with_nth: @step( - f'I should_immediately see the "{{nth:nth}}" {thing} "{{name}}" is {state}' + f'I should immediately see the "{{nth:nth}}" {thing} "{{name}}" is {state}' ) def base_should_see_the_nth_in_state(ctx, nth, name): base_should_see_the_in_state(ctx, thing, name, index=nth) @@ -669,3 +669,203 @@ def run_if_not_visibile(ctx, name): retry_after_s=float(CONFIG["CUCU_SHORT_UI_RETRY_AFTER_S"]), wait_up_to_s=float(CONFIG["CUCU_SHORT_UI_WAIT_TIMEOUT_S"]), )(ctx, name) + + +def define_two_thing_interaction_steps( + action: str, + action_func, + thing_1, + thing_1_find_func, + preposition: str, + thing_2, + thing_2_find_func, + with_nth=False, +): + """ + defines steps with with the following signatures: + I {action} the {thing_1} "{name_1}" {preposition} the {thing_2} "{name_2}" + I wait to {action} the {thing_1} "{name_1}" {preposition} the {thing_2} "{name_2}" + I wait up to "{seconds}" seconds to {action} the {thing_1} "{name_1}" {preposition} the {thing_2} "{name_2}" + ... + I {action} the {thing_1} "{name_1}" {preposition} the {thing_2} "{name_2}" if they both exist + + + when with_nth=True we also define: + + I {action} the "{nth_1}" {thing_1} "{name_1}" {preposition} the "{nth_2}" {thing_2} "{name_2}" + I wait to {action} the "{nth_1}" {thing_1} "{name_1}" {preposition} the "{nth_2}" {thing_2} "{name_2}" + I wait up to "{seconds}" seconds to {action} the "{nth_1}" {thing_1} "{name_1}" {preposition} the "{nth_2}" {thing_2} "{name_2}" + ... + I {action} the "{nth_1}" {thing_1} "{name_1}" {preposition} the "{nth_2}" {thing_2} "{name_2}" if they both exist + + parameters: + action(string): the name of the action being performed, such as: + click, disable, etc. + action_func(function): function that performs the desired action: + + def action_func(ctx, element, ): + ''' + ctx(object): behave context object + element(object): the element found + ''' + thing_1(string): name of the thing we're creating the steps for such + as button, dialog, etc. + thing_1_find_func(function): function that returns the desired element: + + def thing_1_find_func(ctx, name_1, index_1=): + ''' + ctx(object): behave context object + name_1(string):name of the thing to find + index_1(int): when there are multiple elements + with the same name and you've + specified with_nth=True + ''' + preposition(string): preposition to help with readability as there are + many different prepositions that would be valid for + a desired action + thing_2(string): name of the thing that is being interacted with + from the defined action + thing_2_find_func(function): function that returns the interacted element: + + def thing_2_find_func(ctx, name_2, index_2=): + ''' + ctx(object): behave context object + name_2(string): name of the thing to find + index_1(int): when there are multiple elements + with the same name and you've + specified with_nth=True + ''' + with_nth(bool): when set to True we'll define the expanded set of + "nth" steps. default: False + """ + + # undecorated def for reference below + def base_action_the( + ctx, + thing_1, + name_1, + thing_2, + name_2, + index_1=0, + index_2=0, + ): + prefix_1 = nth_to_ordinal(index_1) + prefix_2 = nth_to_ordinal(index_2) + + element_1 = thing_1_find_func(ctx, name_1, index_1) + element_2 = thing_2_find_func(ctx, name_2, index_2) + + if element_1 is None or element_2 is None: + error_message = [] + if element_1 is None: + error_message.append( + f'Unable to find the {prefix_1}{thing_1} "{name_1}"' + ) + if element_2 is None: + error_message.append( + f'Unable to find the {prefix_2}{thing_2} "{name_2}"' + ) + + raise RuntimeError(", ".join(error_message)) + + else: + action_func(ctx, element_1, element_2) + logger.debug( + f'Successfully executed {action} {prefix_1}{thing_1} "{name_1}" {preposition} {prefix_2}{thing_2} "{name_2}"' + ) + + @step( + f'I immediately {action} the {thing_1} "{{name_1}}" {preposition} the {thing_2} "{{name_2}}"' + ) + def immediately_action_the(ctx, name_1, name_2): + base_action_the(ctx, thing_1, name_1, thing_2, name_2) + + @step( + f'I {action} the {thing_1} "{{name_1}}" {preposition} the {thing_2} "{{name_2}}"' + ) + def action_the(ctx, name_1, name_2): + retry( + base_action_the, + retry_after_s=float(CONFIG["CUCU_SHORT_UI_RETRY_AFTER_S"]), + wait_up_to_s=float(CONFIG["CUCU_SHORT_UI_WAIT_TIMEOUT_S"]), + )(ctx, thing_1, name_1, thing_2, name_2) + + @step( + f'I wait to {action} the {thing_1} "{{name_1}}" {preposition} the {thing_2} "{{name_2}}"' + ) + def wait_to_action_the(ctx, name_1, name_2): + retry(base_action_the)(ctx, thing_1, name_1, thing_2, name_2) + + @step( + f'I wait up to "{{seconds}}" seconds to {action} the {thing_1} "{{name_1}}" {preposition} the {thing_2} "{{name_2}}"' + ) + def wait_up_to_seconds_to_action_the(ctx, seconds, name_1, name_2): + seconds = float(seconds) + retry(base_action_the, wait_up_to_s=seconds)( + ctx, thing_1, name_1, thing_2, name_2 + ) + + if with_nth: + + @step( + f'I immediately {action} the "{{nth_1:nth}}" {thing_1} "{{name_1}}" {preposition} the "{{nth_2:nth}}" {thing_2} "{{name_2}}"' + ) + def immediately_action_the_nth_i_nth(ctx, nth_1, name_1, nth_2, name_2): + base_action_the( + ctx, + thing_1, + name_1, + thing_2, + name_2, + index_1=nth_1, + index_2=nth_2, + ) + + @step( + f'I {action} the "{{nth_1:nth}}" {thing_1} "{{name_1}}" {preposition} the "{{nth_2:nth}}" {thing_2} "{{name_2}}"' + ) + def action_the_nth_i_nth(ctx, nth_1, name_1, nth_2, name_2): + retry( + base_action_the, + retry_after_s=float(CONFIG["CUCU_SHORT_UI_RETRY_AFTER_S"]), + wait_up_to_s=float(CONFIG["CUCU_SHORT_UI_WAIT_TIMEOUT_S"]), + )( + ctx, + thing_1, + name_1, + thing_2, + name_2, + index_1=nth_1, + index_2=nth_2, + ) + + @step( + f'I wait to {action} the "{{nth_1:nth}}" {thing_1} "{{name_1}}" {preposition} the "{{nth_2:nth}}" {thing_2} "{{name_2}}"' + ) + def wait_to_action_the_nth_ith(ctx, nth_1, name_1, nth_2, name_2): + retry(base_action_the)( + ctx, + thing_1, + name_1, + thing_2, + name_2, + index_1=nth_1, + index_2=nth_2, + ) + + @step( + f'I wait up to "{{seconds}}" seconds to {action} the "{{nth_1:nth}}" {thing_1} "{{name_1}}" {preposition} the "{{nth_2:nth}}" {thing_2} "{{name_2}}"' + ) + def wait_up_to_action_the_nth_i_nth( + ctx, seconds, nth_1, name_1, nth_2, name_2 + ): + seconds = float(seconds) + retry(base_action_the, wait_up_to_s=seconds)( + ctx, + thing_1, + name_1, + thing_2, + name_2, + index_1=nth_1, + index_2=nth_2, + ) diff --git a/src/cucu/steps/__init__.py b/src/cucu/steps/__init__.py index 7da4e683..63043952 100644 --- a/src/cucu/steps/__init__.py +++ b/src/cucu/steps/__init__.py @@ -9,6 +9,7 @@ import cucu.steps.button_steps import cucu.steps.command_steps import cucu.steps.checkbox_steps +import cucu.steps.draggable_steps import cucu.steps.dropdown_steps import cucu.steps.filesystem_steps import cucu.steps.image_steps diff --git a/src/cucu/steps/draggable_steps.py b/src/cucu/steps/draggable_steps.py new file mode 100644 index 00000000..5f0b1a42 --- /dev/null +++ b/src/cucu/steps/draggable_steps.py @@ -0,0 +1,165 @@ +from selenium.webdriver.support.ui import WebDriverWait + +from cucu import fuzzy, helpers, logger + + +def find_draggable_element(ctx, name, index=0): + """ + find a draggable element on screen by fuzzy matching on the name provided + and the target element: + + * <* draggable="true"> + + parameters: + ctx(object): behave context object used to share data between steps + name(str): name that identifies the desired draggable element on screen + index(str): the index of the draggable element if there are duplicates + returns: + the WebElement that matches the provided arguments. + """ + ctx.check_browser_initialized() + _element = fuzzy.find( + ctx.browser, name, ['*[draggable="true"]'], index=index + ) + + return _element + + +def is_draggable(element): + """ + Checks if an element is draggable. + Args: + element (WebElement): The element to check. + Returns: + bool: True if the element is draggable, False otherwise. + """ + return element.get_attribute("draggable") == "true" + + +def is_not_draggable(element): + """ + Checks if an element is not draggable. + Args: + element (WebElement): The element to check. + Returns: + bool: True if the element is not draggable, False otherwise. + """ + return not is_draggable(element) + + +def find_target_element(ctx, name, index=0): + ctx.check_browser_initialized() + _element = fuzzy.find(ctx.browser, name, ["*"], index=index) + + return _element + + +JS_DRAG_AND_DROP = """ + const cucuDragAndDrop = async (dragElem, dropElem) => { + + function triggerEvent(elem, eventName, dataTransfer = null) { + return new Promise((resolve) => { + const eventObj = new DragEvent(eventName, { + bubbles: true, + cancelable: true, + composed: true, + dataTransfer: dataTransfer, + }); + + function listener() { + resolve(eventObj); + elem.removeEventListener(eventName, listener); + } + + elem.addEventListener(eventName, listener); + elem.dispatchEvent(eventObj); + }); + } + + const dragstartObj = await triggerEvent(dragElem, 'dragstart', new DataTransfer()); + await triggerEvent(dropElem, 'dragenter', dragstartObj.dataTransfer); + const dropeventObj = await triggerEvent(dropElem, 'drop', dragstartObj.dataTransfer); + await triggerEvent(dragElem, 'dragend', dropeventObj.dataTransfer); + + window.dragAndDropCompleted = true; + }; + + cucuDragAndDrop(arguments[0], arguments[1]); +""" + + +def drag_element_to_element(ctx, drag_name, drop_name): + driver = ctx.browser.driver + + driver.execute_script("window.dragAndDropCompleted = false;") + + start_drag_rect = drag_name.rect + logger.debug( + f"Start location of drag element {drag_name.text}: {start_drag_rect}" + ) + logger.debug("Executing drag-and-drop via JavaScript.") + + driver.execute_script(JS_DRAG_AND_DROP, drag_name, drop_name) + + # Wait for the JavaScript flag to be set to True + WebDriverWait(driver, 10).until( + lambda driver: driver.execute_script( + "return window.dragAndDropCompleted;" + ) + ) + + end_drag_rect = drag_name.rect + logger.debug( + f"End location of drag element {drag_name.text}: {end_drag_rect}" + ) + + if start_drag_rect == end_drag_rect: + raise RuntimeError( + f"Drag element {drag_name.text} position did not change" + ) + else: + logger.debug("Drag element position changed successfully.") + + logger.debug("Drag-and-drop operation executed.") + + +helpers.define_thing_with_name_in_state_steps( + "element", "draggable", find_target_element, is_draggable +) + +helpers.define_thing_with_name_in_state_steps( + "element", "not draggable", find_target_element, is_not_draggable +) + +helpers.define_thing_with_name_in_state_steps( + "element", "draggable", find_target_element, is_draggable, with_nth=True +) + +helpers.define_thing_with_name_in_state_steps( + "element", + "not draggable", + find_target_element, + is_not_draggable, + with_nth=True, +) + +helpers.define_two_thing_interaction_steps( + "drag", + drag_element_to_element, + "element", + find_draggable_element, + "to", + "element", + find_target_element, +) + +helpers.define_two_thing_interaction_steps( + "drag", + drag_element_to_element, + "element", + find_draggable_element, + "to", + "element", + find_target_element, + with_nth=True, +)