diff --git a/.github/workflows/test_suite.yml b/.github/workflows/test_suite.yml index 441a7bf8..ab04c1aa 100644 --- a/.github/workflows/test_suite.yml +++ b/.github/workflows/test_suite.yml @@ -38,7 +38,8 @@ jobs: - name: UnitTest - Python-${{ matrix.python-version }}-${{ matrix.browser }} run: | pip install -e .[test] - pytest -n 5 -sqvv --browser=${{ matrix.browser }} --headless testing/test_foo.py testing/test_locator.py --cov=src --cov-report=xml + cd testing + pytest -n 5 -sqvv --browser=${{ matrix.browser }} --headless test_xpath.py test_version.py test_widget_descriptor.py test_version_pick.py test_fillable.py test_utils.py test_log.py test_browser.py test_locator.py --cov=src --cov-report=xml # - name: Upload coverage to Codecov # uses: codecov/codecov-action@v4 diff --git a/src/widgetastic/browser.py b/src/widgetastic/browser.py index 0aa2dc44..8ed606bb 100644 --- a/src/widgetastic/browser.py +++ b/src/widgetastic/browser.py @@ -1,3 +1,23 @@ +""" +Widgetastic Browser Implementation +================================== + +This module provides the core Browser class that wraps Playwright's Page functionality +with the widgetastic API. It serves as the main interface for web element interaction, +page navigation, and browser control in the widgetastic framework. + +Key Features: +- Playwright Page wrapper with widgetastic API compatibility +- SmartLocator integration for flexible element location +- Plugin system for extending browser behavior +- Frame-aware element operations +- Network activity monitoring and page safety checks + +TODO Items: +- Alert handling implementation (currently placeholder) +- iframe handling +""" + import inspect from logging import Logger from textwrap import dedent @@ -11,62 +31,29 @@ from typing import Type from typing import TYPE_CHECKING from typing import Union +import warnings from cached_property import cached_property -from selenium.webdriver.common.action_chains import ActionChains -from selenium.webdriver.common.alert import Alert -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.remote.file_detector import LocalFileDetector -from selenium.webdriver.remote.file_detector import UselessFileDetector -from selenium.webdriver.remote.webdriver import WebDriver -from selenium.webdriver.remote.webelement import WebElement -from selenium.webdriver.support import expected_conditions -from selenium.webdriver.support.wait import WebDriverWait -from smartloc import Locator +from playwright.sync_api import ElementHandle, FrameLocator +from playwright.sync_api import Error as PlaywrightError +from playwright.sync_api import Locator +from playwright.sync_api import Page + +from .locator import SmartLocator from wait_for import TimedOutError -from wait_for import wait_for -from .exceptions import ElementNotInteractableException from .exceptions import LocatorNotImplemented -from .exceptions import MoveTargetOutOfBoundsException -from .exceptions import NoAlertPresentException from .exceptions import NoSuchElementException -from .exceptions import StaleElementReferenceException -from .exceptions import UnexpectedAlertPresentException -from .exceptions import WebDriverException +from .exceptions import WidgetOperationFailed + + from .log import create_widget_logger from .log import null_logger from .types import ElementParent from .types import LocatorAlias from .types import LocatorProtocol -from .utils import crop_string_middle -from .utils import retry_stale_element from .xpath import normalize_space -EXTRACT_CLASSES_OF_ELEMENT = """ -return ( - function(arguments){ - var cl = arguments[0].classList; - if(typeof cl.value === "undefined") { - return cl; - } else { - var arr=[]; - for (i=0; i < cl.length; i++){ - arr.push(cl[i]); - }; - return arr; - } -})(arguments); -""" - -EXTRACT_ATTRIBUTES_OF_ELEMENT = """ -var items = {}; -for (index = 0; index < arguments[0].attributes.length; ++index) { - items[arguments[0].attributes[index].name] = arguments[0].attributes[index].value - }; -return items; -""" - if TYPE_CHECKING: from .widget.base import Widget @@ -83,14 +70,6 @@ class Location(NamedTuple): class DefaultPlugin: - ENSURE_PAGE_SAFE = """\ - return { - jquery: (typeof jQuery === "undefined") ? true : jQuery.active < 1, - prototype: (typeof Ajax === "undefined") ? true : Ajax.activeRequestCount < 1, - document: document.readyState == "complete" - } - """ - def __init__(self, browser: "Browser") -> None: self.browser = browser @@ -99,32 +78,28 @@ def logger(self): """Logger with prepended plugin name.""" return create_widget_logger(type(self).__name__, self.browser.logger) - def ensure_page_safe(self, timeout: str = "10s") -> None: - # THIS ONE SHOULD ALWAYS USE JAVASCRIPT ONLY, NO OTHER SELENIUM INTERACTION - - def _check(): - result = self.browser.execute_script(self.ENSURE_PAGE_SAFE, silent=True) - # TODO: Logging - try: - return all(result.values()) - except AttributeError: - return True + def ensure_page_safe(self, timeout: Union[int, None] = None) -> None: + """Waits for the page to be quiescent, replacing the old JS-based check. - wait_for(_check, timeout=timeout, delay=0.2, very_quiet=True) + Args: + timeout: Provide timeout in seconds. + """ + timeout_ms = 0 if timeout is None else timeout * 1000 + self.browser.page.wait_for_load_state("networkidle", timeout=timeout_ms) - def after_click(self, element: WebElement, locator: LocatorAlias) -> None: + def after_click(self, element: Locator, locator: LocatorAlias) -> None: """Invoked after clicking on an element.""" pass - def after_click_safe_timeout(self, element: WebElement, locator: LocatorAlias) -> None: - """Invoked after clicking on an element and :py:meth:`ensure_page_safe` failing to wait.""" + def after_click_safe_timeout(self, element: Locator, locator: LocatorAlias) -> None: + """Invoked after clicking on an element and `ensure_page_safe` failing to wait.""" pass - def before_click(self, element: WebElement, locator: LocatorAlias) -> None: + def before_click(self, element: Locator, locator: LocatorAlias) -> None: """Invoked before clicking on an element.""" pass - def after_keyboard_input(self, element: WebElement, keyboard_input: Optional[str]) -> None: + def after_keyboard_input(self, element: Locator, keyboard_input: Optional[str]) -> None: """Invoked after sending keys into an element. Args: @@ -132,7 +107,7 @@ def after_keyboard_input(self, element: WebElement, keyboard_input: Optional[str """ pass - def before_keyboard_input(self, element: WebElement, keyboard_input: Optional[str]) -> None: + def before_keyboard_input(self, element: Locator, keyboard_input: Optional[str]) -> None: """Invoked after sending keys into an element. Args: @@ -142,7 +117,7 @@ def before_keyboard_input(self, element: WebElement, keyboard_input: Optional[st def highlight_element( self, - element: WebElement, + element: Locator, style: str = "border: 2px solid red;", visible_for: float = 0.3, ) -> None: @@ -154,83 +129,97 @@ def highlight_element( Generally, visible_for should not be > 0.5 s. If the timeout is too high and we check an element multiple times in quick succession, the modified style will "stick". """ - self.browser.selenium.execute_script( - """ - element = arguments[0]; - original_style = element.getAttribute('style'); - element.setAttribute('style', arguments[1]); - setTimeout(function(){ - element.setAttribute('style', original_style); - }, arguments[2]); - """, - element, - style, - int(visible_for * 1000), - ) # convert visible_for to milliseconds + warnings.warn( + "Playwright's has build-in functionality for highlighting element." + "Please use browser.highlight(locator)", + category=DeprecationWarning, + ) + element.highlight() class Browser: - """Wrapper of the selenium "browser" - - This class contains methods that wrap the Standard Selenium functionality in a convenient way, - mitigating known issues and generally improving the developer experience. - - If you want to present more informations (like :py:meth:`product_version` for - :py:class:`widgetastic.utils.VersionPick`) to the widgets, subclass this class. - - Many of these "hacks" were developed in period between 2013-2016 in ManageIQ QE functional test - suite and are used to date. Those that were generic enough were pulled in here. - - This wrapper is opinionated in some aspects, tries to get out of your way. For example, if you - use :py:meth:`element`, and there are two elements of which first is invisible and the second - is visible, normal selenium would just return the first one, but this wrapper assumes you want - the visible one in case there is more than one element that resolves from given locator. - - Standard Selenium cannot read text that is located under some other element or invisible in some - cases. This wrapper assumes that if you cannot scroll the element or you get no text, it shall - try getting it via JavaScript, which works always. - - This wrapper also ensures the text that is returned is normalized. When working with this - wrapper and using XPath to match text, never use ``.="foo"`` or ``text()="foo"`` but rather use - something like this: ``normalize-space(.)="foo"``. - - Standard Selenium has a special method that clicks on an element. It might not work in some - cases - eg. when some composed "widgets" make the element that is resolved by the locator - somehow hidden behind another. We had these issues so we just replaced the click with a two - stage "move & click the mouse", the :py:meth:`click`. - - Moving to an element involves a workaround that tries to mitigate possible browser misbehaviour - when scrolling in. Sometimes some browsers complain that it is not possible to scroll to the - element but when you engage JavaScript, it works just fine, so this is what this wrapper does - too. Also when you accidentally try moving to ``