diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b980fd0..b0b2d3d87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,59 @@ TODOs: ## 2.0.0rc6 (released on 25.01.2024) +### Experimental browser._actions + +`browser._actions` is an instance of experimental _Actions class – an alternative implementation of ActionChains from Selenium... + +So you can use: + +```python +from selene import browser +from selene.support.shared.jquery_style import s + +browser._actions.move_to(s('#point1')).pause(1).click_and_hold(s('#point1')).pause(1).move_by_offset(0, 5).move_to(s('#point2')).pause(1).release().perform() +``` + +instead of something like: + +```python +from selene import browser +from selene.support.shared.jquery_style import s +from selenium.webdriver.common.action_chains import ActionChains + +ActionChains(browser.driver).move_to_element(s('#point1').locate()).pause(1).click_and_hold(s('#point1').locate()).pause(1).move_by_offset(0, 5).move_to_element(s('#point2').locate()).pause(1).release().perform() +``` + +or actually even instead of this: + +```python +from selene import browser, be +from selene.support.shared.jquery_style import s +from selenium.webdriver.common.action_chains import ActionChains + +ActionChains(browser.driver).move_to_element(s('#point1').should(be.in_dom).locate()).pause(1).click_and_hold(s('#point1').should(be.in_dom).locate()).pause(1).move_by_offset(0, 5).move_to_element(s('#point2').should(be.in_dom).locate()).pause(1).release().perform() +``` + +Here are advantages of Selene's _actions over Selenium's ActionChains: + +* the code is more concise +* you can pass Selene's elements to it, instead of Selenium's webelements +* adding new command to the chain automatically includes automatic waiting for element to be in DOM +* if some error happens inside `.perform` – it will be automatically retried in context of common Selene's implicit waiting logic + +Here are some open points regarding this implementation and why this feature is marked as experimental: +* the implicit waiting are yet not same powerful as in other Selene's commands + * error messages are less readable, too low level + * not sure if retry logic inside `.perform` is needed at all... can hardly imagine any failure there that can be fixed by retrying +* not sure how will it work with Appium drivers... + +### Some inner refactoring... + +* moved Browser class from selene.core.entity.py to selene.core._browser.py + (yet the module is named as experimental, yet the safest way to import Browser is `from selene import Browser` that is unchanged!) + +## 2.0.0rc6 (released on 25.01.2024) + ### Goodbye python 3.7 and webdriver-manager 👋🏻 * drop py3.7 support + upgrade selenium>=4.12.0 diff --git a/selene/__init__.py b/selene/__init__.py index d071d8521..7c3ca5d6f 100644 --- a/selene/__init__.py +++ b/selene/__init__.py @@ -28,9 +28,7 @@ Config = _CustomConfigForCustomBrowser -from selene.core.entity import ( - Browser as _CustomBrowser, -) +from .core._browser import Browser as _CustomBrowser Browser = _CustomBrowser diff --git a/selene/_managed.py b/selene/_managed.py index dd01f6624..f0d3cba0a 100644 --- a/selene/_managed.py +++ b/selene/_managed.py @@ -1,6 +1,5 @@ from selene.core.configuration import Config -from selene.core.entity import Browser - +from selene.core._browser import Browser config = Config() browser = Browser(config) diff --git a/selene/api/__init__.py b/selene/api/__init__.py index f8d648c24..7167af7fc 100644 --- a/selene/api/__init__.py +++ b/selene/api/__init__.py @@ -22,7 +22,7 @@ # --- BASE -- # -from selene.core.entity import Browser +from selene.core._browser import Browser from selene.core.configuration import Config from selene.support import by diff --git a/selene/api/base/__init__.py b/selene/api/base/__init__.py index 6bca31be8..78c998f85 100644 --- a/selene/api/base/__init__.py +++ b/selene/api/base/__init__.py @@ -20,7 +20,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from selene.core.entity import Browser +from selene.core._browser import Browser from selene.core.configuration import Config from selene.support import by diff --git a/selene/core/_actions.py b/selene/core/_actions.py new file mode 100644 index 000000000..59e48a2b4 --- /dev/null +++ b/selene/core/_actions.py @@ -0,0 +1,355 @@ +# MIT License +# +# Copyright (c) 2024 Iakiv Kramarenko +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from __future__ import annotations +from typing import Optional, List, Union, overload + +from selenium.webdriver.common.actions.wheel_input import ScrollOrigin +from selenium.webdriver.remote.webelement import WebElement + +from selenium.webdriver import ActionChains +from selenium.webdriver.common.action_chains import AnyDevice + +from selene.core.entity import Element +from selene.core.configuration import Config +from selene.core.exceptions import _SeleneError +from selene.core.wait import Command, Query + + +@overload +def _ensure_located(element: Element | WebElement) -> WebElement: + ... + + +@overload +def _ensure_located(element: Element | WebElement | None) -> WebElement | None: + ... + + +def _ensure_located(element): + return ( + element.get(Query('locate webelement', lambda it: it.locate())) + if isinstance(element, Element) + else element + ) + + +# TODO: refactor docstrings to the style used in Selene +# TODO: how will it work with Appium? +# TODO: should not we name it ActionsChain to differentiate from w3c actions? +# Selene does not like aliases because of PEP20, but here, in order to provide a better API, +# yet keeping compatibility with Selenium's ActionChains, we are using a lot of aliases +# TODO: but should we? +class _Actions: + def __init__( + self, + config: Config, + *, + duration: int = 250, # TODO: do we need this? + # should we have it in config instead? + devices: Optional[List[AnyDevice]] = None, + ): + # TODO: below we pass driver as non lazy instance, + # that may lead to some issues in some multi-browser scenarios... + # should we bother about them? + self._config = config + # TODO: should we check here if driver is appium based + # and create proper instance correspondingly? + self._chain = ActionChains(config.driver, duration, devices) + + def perform(self) -> None: + def actions(chain: ActionChains): + """ + if this fn will fail, the final error on wait will be something like: + + Timed out after 1s, while waiting for: + + .{'actions': [[{'type': 'pointerMove', 'duration': 250, 'x': 0, 'y': 0, + 'origin': {'element-6066-11e4-a52e-4f735466cecf': + '7F810F7CF30DD4907173DF4247841EB2_element_3'}}, + {'type': 'pointerDown', 'duration': 0, 'button': 0}, + {'type': 'pointerUp', 'duration': 0, 'button': 0}], + [{'type': 'pause', 'duration': 0}, {'type': 'pause', 'duration': 0}, + {'type': 'pause', 'duration': 0}]]} + + TODO: can and should we improve it? + """ + chain.perform() + + self._config.wait(self._chain).for_(Command(str(self.__encoded), actions)) # type: ignore + + @property + def __encoded(self): + return { + 'actions': list( + filter( + None, + [ + device.encode()['actions'] or None + for device in self._chain.w3c_actions.devices + ], + ) + ) + } + + def click(self, on_element: Element | WebElement | None = None) -> _Actions: + """Clicks an element. + + :Args: + - on_element: The element to click. + If None, clicks on current mouse position. + """ + self._chain.click(_ensure_located(on_element)) + return self + + def click_and_hold( + self, on_element: Element | WebElement | None = None + ) -> _Actions: + """Holds down the left mouse button on an element. + + :Args: + - on_element: The element to mouse down. + If None, clicks on current mouse position. + """ + self._chain.click_and_hold(_ensure_located(on_element)) + return self + + def context_click(self, on_element: Element | WebElement | None = None) -> _Actions: + """Performs a context-click (right click) on an element. + + :Args: + - on_element: The element to context-click. + If None, clicks on current mouse position. + """ + self._chain.context_click(_ensure_located(on_element)) + return self + + def double_click(self, on_element: Element | WebElement | None = None) -> _Actions: + """Double-clicks an element. + + :Args: + - on_element: The element to double-click. + If None, clicks on current mouse position. + """ + self._chain.double_click(_ensure_located(on_element)) + return self + + def drag_and_drop( + self, source: Element | WebElement, target: Element | WebElement + ) -> _Actions: + """Holds down the left mouse button on the source element, then moves + to the target element and releases the mouse button. + + :Args: + - source: The element to mouse down. + - target: The element to mouse up. + """ + self._chain.drag_and_drop(_ensure_located(source), _ensure_located(target)) + return self + + def drag_and_drop_by_offset( + self, source: Element | WebElement, x: int, y: int + ) -> _Actions: + """Holds down the left mouse button on the source element, then moves + to the target offset and releases the mouse button. + + :Args: + - source: The element to mouse down. + - xoffset: X offset to move to. + - yoffset: Y offset to move to. + """ + self._chain.drag_and_drop_by_offset(_ensure_located(source), x, y) + return self + + def key_down( + self, value: str, element: Element | WebElement | None = None + ) -> _Actions: + """Sends a key press only, without releasing it. Should only be used + with modifier keys (Control, Alt and Shift). + + :Args: + - value: The modifier key to send. Values are defined in `Keys` class. + - element: The element to send keys. + If None, sends a key to current focused element. + + Example, pressing ctrl+c:: + + ActionChains(driver).key_down(Keys.CONTROL).send_keys('c').key_up(Keys.CONTROL).perform() + """ + self._chain.key_down(value, _ensure_located(element)) + return self + + def key_up( + self, value: str, element: Element | WebElement | None = None + ) -> _Actions: + """Releases a modifier key. + + :Args: + - value: The modifier key to send. Values are defined in Keys class. + - element: The element to send keys. + If None, sends a key to current focused element. + + Example, pressing ctrl+c:: + + ActionChains(driver).key_down(Keys.CONTROL).send_keys('c').key_up(Keys.CONTROL).perform() + """ + self._chain.key_up(value, _ensure_located(element)) + return self + + def move_by_offset(self, x: int, y: int) -> _Actions: + """Moving the mouse to an offset from current mouse position. + + :Args: + - x: X offset to move to, as a positive or negative integer. + - y: Y offset to move to, as a positive or negative integer. + """ + self._chain.move_by_offset(x, y) + return self + + def move_to_element(self, to_element: Element | WebElement) -> _Actions: + """Moving the mouse to the middle of an element. + + :Args: + - to_element: The WebElement to move to. + """ + self._chain.move_to_element(_ensure_located(to_element)) + return self + + def move_to(self, element: Element | WebElement) -> _Actions: + """Alias for move_to_element""" + return self.move_to_element(element) + + def move_to_element_with_offset( + self, to_element: Element | WebElement, xoffset: int, yoffset: int + ) -> _Actions: + """Move the mouse by an offset of the specified element. Offsets are + relative to the in-view center point of the element. + + :Args: + - to_element: The WebElement to move to. + - xoffset: X offset to move to, as a positive or negative integer. + - yoffset: Y offset to move to, as a positive or negative integer. + """ + self._chain.move_to_element_with_offset( + _ensure_located(to_element), xoffset, yoffset + ) + return self + + def move_with_offset_to( + self, element: Element | WebElement, x: int, y: int + ) -> _Actions: + """Alias for move_to_element_with_offset""" + return self.move_to_element_with_offset(element, x, y) + + def pause(self, seconds: float | int) -> _Actions: + """Pause all inputs for the specified duration in seconds.""" + self._chain.pause(seconds) + return self + + def release(self, on_element: Element | WebElement | None = None) -> _Actions: + """Releasing a held mouse button on an element. + + :Args: + - on_element: The element to mouse up. + If None, releases on current mouse position. + """ + self._chain.release(_ensure_located(on_element)) + return self + + def send_keys(self, *keys_to_send: str) -> _Actions: + """Sends keys to current focused element. + + :Args: + - keys_to_send: The keys to send. Modifier keys constants can be found in the + 'Keys' class. + """ + self._chain.send_keys(*keys_to_send) + return self + + def send_keys_to_element( + self, element: Element | WebElement, *keys_to_send: str + ) -> _Actions: + """Sends keys to an element. + + :Args: + - element: The element to send keys. + - keys_to_send: The keys to send. Modifier keys constants can be found in the + 'Keys' class. + """ + self.click(element) + self.send_keys(*keys_to_send) + return self + + def send_keys_to( + self, element: Element | WebElement, *keys_to_send: str + ) -> _Actions: + """Alias for send_keys_to_element""" + return self.send_keys_to_element(element, *keys_to_send) + + def scroll_to_element(self, element: Element | WebElement) -> _Actions: + """If the element is outside the viewport, scrolls the bottom of the + element to the bottom of the viewport. + + :Args: + - element: Which element to scroll into the viewport. + """ + self._chain.scroll_to_element(_ensure_located(element)) + return self + + def scroll_to(self, element: Element | WebElement) -> _Actions: + """Alias for scroll_to_element""" + return self.scroll_to_element(element) + + def scroll_by_amount(self, delta_x: int, delta_y: int) -> _Actions: + """Scrolls by provided amounts with the origin in the top left corner + of the viewport. + + :Args: + - delta_x: Distance along X axis to scroll using the wheel. A negative value scrolls left. + - delta_y: Distance along Y axis to scroll using the wheel. A negative value scrolls up. + """ + self._chain.scroll_by_amount(delta_x, delta_y) + return self + + def scroll_by(self, delta_x: int, delta_y: int) -> _Actions: + """Alias for scroll_by_amount""" + return self.scroll_by_amount(delta_x, delta_y) + + def scroll_from_origin( + self, scroll_origin: ScrollOrigin, delta_x: int, delta_y: int + ) -> _Actions: + """Scrolls by provided amount based on a provided origin. The scroll + origin is either the center of an element or the upper left of the + viewport plus any offsets. If the origin is an element, and the element + is not in the viewport, the bottom of the element will first be + scrolled to the bottom of the viewport. + + :Args: + - origin: Where scroll originates (viewport or element center) plus provided offsets. + - delta_x: Distance along X axis to scroll using the wheel. A negative value scrolls left. + - delta_y: Distance along Y axis to scroll using the wheel. A negative value scrolls up. + + :Raises: If the origin with offset is outside the viewport. + - MoveTargetOutOfBoundsException - If the origin with offset is outside the viewport. + """ + self._chain.scroll_from_origin(scroll_origin, delta_x, delta_y) + return self diff --git a/selene/core/_browser.py b/selene/core/_browser.py new file mode 100644 index 000000000..f327de770 --- /dev/null +++ b/selene/core/_browser.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +import warnings +from typing import Optional, Union, Tuple + +from selenium.webdriver.remote.switch_to import SwitchTo +from selenium.webdriver.remote.webdriver import WebDriver + +from selene.common.helpers import to_by +from selene.core._actions import _Actions +from selene.core.configuration import Config +from selene.core.entity import WaitingEntity, Element, Collection +from selene.core.locator import Locator +from selene.support.webdriver import WebHelper + + +class Browser(WaitingEntity['Browser']): + def __init__(self, config: Optional[Config] = None): + config = Config() if config is None else config + super().__init__(config) + + def with_(self, config: Optional[Config] = None, **config_as_kwargs) -> Browser: + return ( + Browser(config) + if config + else Browser(self.config.with_(**config_as_kwargs)) + ) + + def __str__(self): + return 'browser' + + @property + def driver(self) -> WebDriver: + return self.config.driver + + # TODO: consider making it callable (self.__call__() to be shortcut to self.__raw__ ...) + + @property + def __raw__(self): + return self.config.driver + + # @property + # def actions(self) -> ActionChains: + # """ + # It's kind of just a shortcut for pretty low level actions from selenium webdriver + # Yet unsure about this property here:) + # comparing to usual high level Selene API... + # Maybe later it would be better to make own Actions with selene-style retries, etc. + # """ + # return ActionChains(self.config.driver) + + @property + def _actions(self) -> _Actions: + return _Actions(self.config) + + # --- Element builders --- # + + # TODO: consider None by default, + # and *args, **kwargs to be able to pass custom things + # to be processed by config.location_strategy + # and by default process none as "element to skip all actions on it" + def element( + self, css_or_xpath_or_by: Union[str, Tuple[str, str], Locator] + ) -> Element: + if isinstance(css_or_xpath_or_by, Locator): + return Element(css_or_xpath_or_by, self.config) + + by = to_by(css_or_xpath_or_by) + + return Element( + Locator(f'{self}.element({by})', lambda: self.driver.find_element(*by)), + self.config, + ) + + def all( + self, css_or_xpath_or_by: Union[str, Tuple[str, str], Locator] + ) -> Collection: + if isinstance(css_or_xpath_or_by, Locator): + return Collection(css_or_xpath_or_by, self.config) + + by = to_by(css_or_xpath_or_by) + + return Collection( + Locator(f'{self}.all({by})', lambda: self.driver.find_elements(*by)), + self.config, + ) + + # --- High Level Commands--- # + + def open(self, relative_or_absolute_url: Optional[str] = None) -> Browser: + # TODO: should we keep it less pretty but more KISS? like: + # self.config._driver_get_url_strategy(self.config)(relative_or_absolute_url) + self.config._executor.get_url(relative_or_absolute_url) + + return self + + def switch_to_next_tab(self) -> Browser: + from selene.core import query + + self.driver.switch_to.window(query.next_tab(self)) + + # TODO: should we use waiting version here (and in other similar cases)? + # self.perform(Command( + # 'open next tab', + # lambda browser: browser.driver.switch_to.window(query.next_tab(self)))) + + return self + + def switch_to_previous_tab(self) -> Browser: + from selene.core import query + + self.driver.switch_to.window(query.previous_tab(self)) + return self + + def switch_to_tab(self, index_or_name: Union[int, str]) -> Browser: + if isinstance(index_or_name, int): + index = index_or_name + from selene.core import query + + self.driver.switch_to.window(query.tab(index)(self)) + else: + self.driver.switch_to.window(index_or_name) + + return self + + # TODO: consider deprecating + @property + def switch_to(self) -> SwitchTo: + return self.driver.switch_to + + # TODO: should we add also a shortcut for self.driver.switch_to.alert ? + # if we don't need to switch_to.'back' after switch to alert - then for sure we should... + # question is - should we implement our own alert as waiting entity? + + def quit(self) -> None: + """ + Quits the driver. + + If the driver was not even set, will build it just to quit it:D. + + Will fail if the driver was already quit or crashed. + """ + self.driver.quit() + + # TODO: consider deprecating, it does not close browser, it closes current tab/window + def close(self) -> Browser: + self.driver.close() + return self + + # --- Deprecated --- # + + # TODO: should we keep it? + def execute_script(self, script, *args): + warnings.warn( + 'consider using browser.driver.execute_script ' + 'instead of browser.execute_script', + PendingDeprecationWarning, + ) + return self.driver.execute_script(script, *args) + + # TODO: should we move it to query.* and/or command.*? + # like `browser.get(query.screenshot)` ? + # like `browser.perform(command.save_screenshot)` ? + # TODO: deprecate file name, use path + # because we can path folder path not file path and it will work + def save_screenshot(self, file: Optional[str] = None): + warnings.warn( + 'browser.save_screenshot is deprecated, ' + 'use browser.get(query.screenshot_saved())', + DeprecationWarning, + ) + + from selene.core import query # type: ignore + + return self.get(query.screenshot_saved()) # type: ignore + + @property + def last_screenshot(self) -> str: + warnings.warn( + 'browser.last_screenshot is deprecated, ' + 'use browser.config.last_screenshot', + DeprecationWarning, + ) + return self.config.last_screenshot # type: ignore + + # TODO: consider moving this to browser command.save_page_source(filename) + def save_page_source(self, file: Optional[str] = None) -> Optional[str]: + warnings.warn( + 'browser.save_page_source is deprecated, ' + 'use browser.get(query.page_source_saved())', + DeprecationWarning, + ) + + if file is None: + file = self.config._generate_filename(suffix='.html') # type: ignore + + saved_file = WebHelper(self.driver).save_page_source(file) + + self.config.last_page_source = saved_file # type: ignore + + return saved_file + + @property + def last_page_source(self) -> str: + warnings.warn( + 'browser.last_page_source is deprecated, ' + 'use browser.config.last_page_source', + DeprecationWarning, + ) + return self.config.last_page_source # type: ignore + + def close_current_tab(self) -> Browser: + warnings.warn( + 'deprecated because the «tab» term is not relevant for mobile; ' + 'use a `browser.close()` or `browser.driver.close()` instead', + DeprecationWarning, + ) + self.driver.close() + return self + + def clear_local_storage(self) -> Browser: + warnings.warn( + 'deprecated because of js nature and not-relevance for mobile; ' + 'use `browser.perform(command.js.clear_local_storage)` instead', + DeprecationWarning, + ) + from selene.core import command + + self.perform(command.js.clear_local_storage) + return self + + def clear_session_storage(self) -> Browser: + warnings.warn( + 'deprecated because of js nature and not-relevance for mobile; ' + 'use `browser.perform(command.js.clear_session_storage)` instead', + DeprecationWarning, + ) + from selene.core import command + + self.perform(command.js.clear_session_storage) + return self diff --git a/selene/core/command.py b/selene/core/command.py index 6d2b41505..0aefb310a 100644 --- a/selene/core/command.py +++ b/selene/core/command.py @@ -27,7 +27,8 @@ from selenium.webdriver.support import expected_conditions from selenium.webdriver.support.wait import WebDriverWait -from selene.core.entity import Element, Collection, Browser +from selene.core.entity import Element, Collection +from selene.core._browser import Browser from selene.core.exceptions import _SeleneError from selene.core.wait import Command from selenium.webdriver import ActionChains diff --git a/selene/core/conditions.py b/selene/core/conditions.py index acecf4939..9db2cb624 100644 --- a/selene/core/conditions.py +++ b/selene/core/conditions.py @@ -20,7 +20,8 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. from selene.core.condition import Condition -from selene.core.entity import Browser, Element, Collection +from selene.core.entity import Element, Collection +from selene.core._browser import Browser class ElementCondition(Condition[Element]): diff --git a/selene/core/entity.py b/selene/core/entity.py index 74747adee..867a2ec3b 100644 --- a/selene/core/entity.py +++ b/selene/core/entity.py @@ -21,7 +21,6 @@ # SOFTWARE. from __future__ import annotations -import os import typing import warnings @@ -31,7 +30,6 @@ from selenium.webdriver.remote.webdriver import WebDriver from selenium.webdriver import ActionChains from selenium.webdriver.common.keys import Keys -from selenium.webdriver.remote.switch_to import SwitchTo from selenium.webdriver.remote.webelement import WebElement from selene.common.fp import pipe @@ -41,10 +39,8 @@ from selene.core.condition import Condition from selene.core.locator import Locator -from selene.common.helpers import to_by, flatten, is_absolute_url +from selene.common.helpers import to_by, flatten from selene.core.exceptions import TimeoutException, _SeleneError -from selene.support.webdriver import WebHelper - E = TypeVar('E', bound='Assertable') R = TypeVar('R') @@ -1078,226 +1074,3 @@ def all_first(self, selector: Union[str, Tuple[str, str]]) -> Collection: ), self.config, ) - - -class Browser(WaitingEntity['Browser']): - def __init__(self, config: Optional[Config] = None): - config = Config() if config is None else config - super().__init__(config) - - def with_(self, config: Optional[Config] = None, **config_as_kwargs) -> Browser: - return ( - Browser(config) - if config - else Browser(self.config.with_(**config_as_kwargs)) - ) - - def __str__(self): - return 'browser' - - @property - def driver(self) -> WebDriver: - return self.config.driver - - # TODO: consider making it callable (self.__call__() to be shortcut to self.__raw__ ...) - - @property - def __raw__(self): - return self.config.driver - - # @property - # def actions(self) -> ActionChains: - # """ - # It's kind of just a shortcut for pretty low level actions from selenium webdriver - # Yet unsure about this property here:) - # comparing to usual high level Selene API... - # Maybe later it would be better to make own Actions with selene-style retries, etc. - # """ - # return ActionChains(self.config.driver) - - # --- Element builders --- # - - # TODO: consider None by default, - # and *args, **kwargs to be able to pass custom things - # to be processed by config.location_strategy - # and by default process none as "element to skip all actions on it" - def element( - self, css_or_xpath_or_by: Union[str, Tuple[str, str], Locator] - ) -> Element: - if isinstance(css_or_xpath_or_by, Locator): - return Element(css_or_xpath_or_by, self.config) - - by = to_by(css_or_xpath_or_by) - - return Element( - Locator(f'{self}.element({by})', lambda: self.driver.find_element(*by)), - self.config, - ) - - def all( - self, css_or_xpath_or_by: Union[str, Tuple[str, str], Locator] - ) -> Collection: - if isinstance(css_or_xpath_or_by, Locator): - return Collection(css_or_xpath_or_by, self.config) - - by = to_by(css_or_xpath_or_by) - - return Collection( - Locator(f'{self}.all({by})', lambda: self.driver.find_elements(*by)), - self.config, - ) - - # --- High Level Commands--- # - - def open(self, relative_or_absolute_url: Optional[str] = None) -> Browser: - # TODO: should we keep it less pretty but more KISS? like: - # self.config._driver_get_url_strategy(self.config)(relative_or_absolute_url) - self.config._executor.get_url(relative_or_absolute_url) - - return self - - def switch_to_next_tab(self) -> Browser: - from selene.core import query - - self.driver.switch_to.window(query.next_tab(self)) - - # TODO: should we use waiting version here (and in other similar cases)? - # self.perform(Command( - # 'open next tab', - # lambda browser: browser.driver.switch_to.window(query.next_tab(self)))) - - return self - - def switch_to_previous_tab(self) -> Browser: - from selene.core import query - - self.driver.switch_to.window(query.previous_tab(self)) - return self - - def switch_to_tab(self, index_or_name: Union[int, str]) -> Browser: - if isinstance(index_or_name, int): - index = index_or_name - from selene.core import query - - self.driver.switch_to.window(query.tab(index)(self)) - else: - self.driver.switch_to.window(index_or_name) - - return self - - # TODO: consider deprecating - @property - def switch_to(self) -> SwitchTo: - return self.driver.switch_to - - # TODO: should we add also a shortcut for self.driver.switch_to.alert ? - # if we don't need to switch_to.'back' after switch to alert - then for sure we should... - # question is - should we implement our own alert as waiting entity? - - def quit(self) -> None: - """ - Quits the driver. - - If the driver was not even set, will build it just to quit it:D. - - Will fail if the driver was already quit or crashed. - """ - self.driver.quit() - - # TODO: consider deprecating, it does not close browser, it closes current tab/window - def close(self) -> Browser: - self.driver.close() - return self - - # --- Deprecated --- # - - # TODO: should we keep it? - def execute_script(self, script, *args): - warnings.warn( - 'consider using browser.driver.execute_script ' - 'instead of browser.execute_script', - PendingDeprecationWarning, - ) - return self.driver.execute_script(script, *args) - - # TODO: should we move it to query.* and/or command.*? - # like `browser.get(query.screenshot)` ? - # like `browser.perform(command.save_screenshot)` ? - # TODO: deprecate file name, use path - # because we can path folder path not file path and it will work - def save_screenshot(self, file: Optional[str] = None): - warnings.warn( - 'browser.save_screenshot is deprecated, ' - 'use browser.get(query.screenshot_saved())', - DeprecationWarning, - ) - - from selene.core import query # type: ignore - - return self.get(query.screenshot_saved()) # type: ignore - - @property - def last_screenshot(self) -> str: - warnings.warn( - 'browser.last_screenshot is deprecated, ' - 'use browser.config.last_screenshot', - DeprecationWarning, - ) - return self.config.last_screenshot # type: ignore - - # TODO: consider moving this to browser command.save_page_source(filename) - def save_page_source(self, file: Optional[str] = None) -> Optional[str]: - warnings.warn( - 'browser.save_page_source is deprecated, ' - 'use browser.get(query.page_source_saved())', - DeprecationWarning, - ) - - if file is None: - file = self.config._generate_filename(suffix='.html') # type: ignore - - saved_file = WebHelper(self.driver).save_page_source(file) - - self.config.last_page_source = saved_file # type: ignore - - return saved_file - - @property - def last_page_source(self) -> str: - warnings.warn( - 'browser.last_page_source is deprecated, ' - 'use browser.config.last_page_source', - DeprecationWarning, - ) - return self.config.last_page_source # type: ignore - - def close_current_tab(self) -> Browser: - warnings.warn( - 'deprecated because the «tab» term is not relevant for mobile; ' - 'use a `browser.close()` or `browser.driver.close()` instead', - DeprecationWarning, - ) - self.driver.close() - return self - - def clear_local_storage(self) -> Browser: - warnings.warn( - 'deprecated because of js nature and not-relevance for mobile; ' - 'use `browser.perform(command.js.clear_local_storage)` instead', - DeprecationWarning, - ) - from selene.core import command - - self.perform(command.js.clear_local_storage) - return self - - def clear_session_storage(self) -> Browser: - warnings.warn( - 'deprecated because of js nature and not-relevance for mobile; ' - 'use `browser.perform(command.js.clear_session_storage)` instead', - DeprecationWarning, - ) - from selene.core import command - - self.perform(command.js.clear_session_storage) - return self diff --git a/selene/core/match.py b/selene/core/match.py index a6e3f249a..64c1329ec 100644 --- a/selene/core/match.py +++ b/selene/core/match.py @@ -30,7 +30,8 @@ CollectionCondition, BrowserCondition, ) -from selene.core.entity import Collection, Element, Browser +from selene.core.entity import Collection, Element +from selene.core._browser import Browser # TODO: consider moving to selene.match.element.is_visible, etc... element_is_visible: Condition[Element] = ElementCondition.raise_if_not( diff --git a/selene/core/query.py b/selene/core/query.py index 0139df920..b333b5179 100644 --- a/selene/core/query.py +++ b/selene/core/query.py @@ -24,7 +24,8 @@ from selenium.webdriver.remote.webelement import WebElement -from selene.core.entity import Browser, Element, Collection +from selene.core.entity import Element, Collection +from selene.core._browser import Browser from selene.core.wait import Query diff --git a/selene/support/conditions/have.py b/selene/support/conditions/have.py index 4e8962556..56bc2c122 100644 --- a/selene/support/conditions/have.py +++ b/selene/support/conditions/have.py @@ -24,7 +24,8 @@ from selene.core import match from selene.core.condition import Condition -from selene.core.entity import Element, Collection, Browser +from selene.core.entity import Element, Collection +from selene.core._browser import Browser from selene.support.conditions import not_ as _not_ no = _not_ diff --git a/selene/support/conditions/not_.py b/selene/support/conditions/not_.py index 87adf9088..8acd6910f 100644 --- a/selene/support/conditions/not_.py +++ b/selene/support/conditions/not_.py @@ -26,7 +26,8 @@ # --- be.* conditions --- # from selene.core.condition import Condition -from selene.core.entity import Element, Collection, Browser +from selene.core.entity import Element, Collection +from selene.core._browser import Browser # TODO: consider refactoring to class for better extendability # when creating custom conditions diff --git a/selene/support/shared/browser.py b/selene/support/shared/browser.py index 352bb714f..5e32c4ff8 100644 --- a/selene/support/shared/browser.py +++ b/selene/support/shared/browser.py @@ -21,7 +21,7 @@ # SOFTWARE. import warnings -from selene.core.entity import Browser +from selene.core._browser import Browser class SharedBrowser(Browser): diff --git a/tests/integration/browser__actions_test.py b/tests/integration/browser__actions_test.py new file mode 100644 index 000000000..4cb2f2af1 --- /dev/null +++ b/tests/integration/browser__actions_test.py @@ -0,0 +1,160 @@ +import pytest + +from selene import be, have, query +from selene.core.exceptions import TimeoutException +from tests.integration.helpers.givenpage import GivenPage + + +def test_browser_actions_drags_source_and_drops_it_to_target_with_implicit_waiting( + session_browser, +): + browser = session_browser.with_( + timeout=3, # GIVEN + ) + page = GivenPage(browser.driver) + page.opened_empty() + page.add_style_to_head( + """ + #target1, #target2 { + float: left; + width: 100px; + height: 35px; + margin: 10px; + padding: 10px; + border: 1px solid black; + } + """ + ) + page.add_script_to_head( + """ + function allowDrop(ev) { + ev.preventDefault(); + } + + function drag(ev) { + ev.dataTransfer.setData('text', ev.target.id); + } + + function drop(ev) { + ev.preventDefault(); + var data = ev.dataTransfer.getData('text'); + ev.target.appendChild(document.getElementById(data)); + } + """ + ) + page.load_body_with_timeout( + ''' +

Drag and Drop

+

Drag the image back and forth between the two div elements.

+ +
+ +
+ +
+ ''', + timeout=0.5, # GIVEN + ) + + # WHEN + browser._actions.drag_and_drop( + browser.element('#draggable'), browser.element('#target2') + ).perform() + + browser.element('#target1').element('#draggable').should(be.not_.present) + browser.element('#target2').element('#draggable').should(be.present) + + # WHEN + browser._actions.click_and_hold(browser.element('#draggable')).release( + browser.element('#target1') + ).perform() + + browser.element('#target1').element('#draggable').should(be.present) + browser.element('#target2').element('#draggable').should(be.not_.present) + + +def test_browser_actions_fails_to_wait_for_drag_and_drop_before_perform( + session_browser, +): + browser = session_browser.with_( + timeout=0.5, # GIVEN + ) + page = GivenPage(browser.driver) + page.opened_empty() + page.add_style_to_head( + """ + #target1, #target2 { + float: left; + width: 100px; + height: 35px; + margin: 10px; + padding: 10px; + border: 1px solid black; + } + """ + ) + page.add_script_to_head( + """ + function allowDrop(ev) { + ev.preventDefault(); + } + + function drag(ev) { + ev.dataTransfer.setData('text', ev.target.id); + } + + function drop(ev) { + ev.preventDefault(); + var data = ev.dataTransfer.getData('text'); + ev.target.appendChild(document.getElementById(data)); + } + """ + ) + page.load_body_with_timeout( + ''' +

Drag and Drop

+

Drag the image back and forth between the two div elements.

+ +
+ +
+ +
+ ''', + timeout=1.0, # GIVEN + ) + + # WHEN + try: + browser._actions.drag_and_drop( + browser.element('#draggable'), browser.element('#target2') + ).perform() + pytest.fail('should fail with timeout') + + # THEN + except TimeoutException as error: + assert ( + "browser.element(('css selector', '#draggable')).locate webelement\n" + "\n" + "Reason: NoSuchElementException: Message: " + "no such element: Unable to locate element: " + "{\"method\":\"css selector\",\"selector\":\"#draggable\"}\n" + ) in str(error) + # TODO: should we see in error something more like: + # actions.drag_and_drop( browser.element('#draggable'), browser.element('#target2') ) + + +# TODO: add test that simulate failure inside perform, +# not inside actions registration in context of waiting for located webelement diff --git a/tests/integration/element__perform__drop_file_test.py b/tests/integration/element__perform__js__drop_file_test.py similarity index 100% rename from tests/integration/element__perform__drop_file_test.py rename to tests/integration/element__perform__js__drop_file_test.py