diff --git a/CHANGELOG.md b/CHANGELOG.md index 1650c234ae..7dfb54c487 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). +## [UNRELEASED] + +## Added + +- [#2417](https://github.com/plotly/dash/pull/2417) Add wait_timeout property to customize the behavior of the default wait timeout used for by wait_for_page, fix [#1595](https://github.com/plotly/dash/issues/1595) +- [#2417](https://github.com/plotly/dash/pull/2417) Add the element target text for wait_for_text* error message, fix [#945](https://github.com/plotly/dash/issues/945) + +## Fixed + +- [#2417](https://github.com/plotly/dash/pull/2417) Disable the pytest plugin if `dash[testing]` not installed, fix [#946](https://github.com/plotly/dash/issues/946). +- [#2417](https://github.com/plotly/dash/pull/2417) Do not swallow the original error to get the webdriver, easier to know what is wrong after updating the browser but the driver. ## [2.8.1] - 2023-01-30 diff --git a/dash/dash.py b/dash/dash.py index b8746d4d05..c9fce8d129 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -125,7 +125,8 @@ html.Div(id=_ID_DUMMY, disable_n_clicks=True), ] ) -except AttributeError: +# pylint: disable-next=bare-except +except: # noqa: E722 page_container = None diff --git a/dash/testing/browser.py b/dash/testing/browser.py index 6b3dc941fc..1ce5eea68c 100644 --- a/dash/testing/browser.py +++ b/dash/testing/browser.py @@ -262,19 +262,28 @@ def _get_element(self, elem_or_selector): return self.find_element(elem_or_selector) return elem_or_selector - def _wait_for(self, method, args, timeout, msg): + def _wait_for(self, method, timeout, msg): """Abstract generic pattern for explicit WebDriverWait.""" - _wait = ( - self._wd_wait if timeout is None else WebDriverWait(self.driver, timeout) - ) - logger.debug( - "method, timeout, poll => %s %s %s", - method, - _wait._timeout, # pylint: disable=protected-access - _wait._poll, # pylint: disable=protected-access - ) + try: + _wait = ( + self._wd_wait + if timeout is None + else WebDriverWait(self.driver, timeout) + ) + logger.debug( + "method, timeout, poll => %s %s %s", + method, + _wait._timeout, # pylint: disable=protected-access + _wait._poll, # pylint: disable=protected-access + ) - return _wait.until(method(*args), msg) + return _wait.until(method) + except Exception as err: + if callable(msg): + message = msg(self.driver) + else: + message = msg + raise TimeoutException(message) from err def wait_for_element(self, selector, timeout=None): """wait_for_element is shortcut to `wait_for_element_by_css_selector` @@ -286,8 +295,9 @@ def wait_for_element_by_css_selector(self, selector, timeout=None): equals to the fixture's `wait_timeout` shortcut to `WebDriverWait` with `EC.presence_of_element_located`.""" return self._wait_for( - EC.presence_of_element_located, - ((By.CSS_SELECTOR, selector),), + EC.presence_of_element_located( + (By.CSS_SELECTOR, selector), + ), timeout, f"timeout {timeout or self._wait_timeout}s => waiting for selector {selector}", ) @@ -310,8 +320,9 @@ def wait_for_element_by_id(self, element_id, timeout=None): equals to the fixture's `wait_timeout` shortcut to `WebDriverWait` with `EC.presence_of_element_located`.""" return self._wait_for( - EC.presence_of_element_located, - ((By.ID, element_id),), + EC.presence_of_element_located( + (By.ID, element_id), + ), timeout, f"timeout {timeout or self._wait_timeout}s => waiting for element id {element_id}", ) @@ -321,8 +332,7 @@ def wait_for_class_to_equal(self, selector, classname, timeout=None): if not set, equals to the fixture's `wait_timeout` shortcut to `WebDriverWait` with customized `class_to_equal` condition.""" return self._wait_for( - method=class_to_equal, - args=(selector, classname), + method=class_to_equal(selector, classname), timeout=timeout, msg=f"classname => {classname} not found within {timeout or self._wait_timeout}s", ) @@ -332,8 +342,7 @@ def wait_for_style_to_equal(self, selector, style, val, timeout=None): if not set, equals to the fixture's `wait_timeout` shortcut to `WebDriverWait` with customized `style_to_equal` condition.""" return self._wait_for( - method=style_to_equal, - args=(selector, style, val), + method=style_to_equal(selector, style, val), timeout=timeout, msg=f"style val => {style} {val} not found within {timeout or self._wait_timeout}s", ) @@ -345,11 +354,12 @@ def wait_for_text_to_equal(self, selector, text, timeout=None): shortcut to `WebDriverWait` with customized `text_to_equal` condition. """ + method = text_to_equal(selector, text, timeout or self.wait_timeout) + return self._wait_for( - method=text_to_equal, - args=(selector, text), + method=method, timeout=timeout, - msg=f"text -> {text} not found within {timeout or self._wait_timeout}s", + msg=method.message, ) def wait_for_contains_class(self, selector, classname, timeout=None): @@ -360,8 +370,7 @@ def wait_for_contains_class(self, selector, classname, timeout=None): condition. """ return self._wait_for( - method=contains_class, - args=(selector, classname), + method=contains_class(selector, classname), timeout=timeout, msg=f"classname -> {classname} not found inside element within {timeout or self._wait_timeout}s", ) @@ -373,11 +382,11 @@ def wait_for_contains_text(self, selector, text, timeout=None): shortcut to `WebDriverWait` with customized `contains_text` condition. """ + method = contains_text(selector, text, timeout or self.wait_timeout) return self._wait_for( - method=contains_text, - args=(selector, text), + method=method, timeout=timeout, - msg=f"text -> {text} not found inside element within {timeout or self._wait_timeout}s", + msg=method.message, ) def wait_for_page(self, url=None, timeout=10): @@ -449,11 +458,7 @@ def open_new_tab(self, url=None): ) def get_webdriver(self): - try: - return getattr(self, f"_get_{self._browser}")() - except WebDriverException: - logger.exception("<<>>") - return None + return getattr(self, f"_get_{self._browser}")() def _get_wd_options(self): options = ( @@ -640,3 +645,12 @@ def server_url(self, value): @property def download_path(self): return self._download_path + + @property + def wait_timeout(self): + return self._wait_timeout + + @wait_timeout.setter + def wait_timeout(self, value): + self._wait_timeout = value + self._wd_wait = WebDriverWait(self.driver, value) diff --git a/dash/testing/plugin.py b/dash/testing/plugin.py index 885ea18a06..5e741af765 100644 --- a/dash/testing/plugin.py +++ b/dash/testing/plugin.py @@ -22,6 +22,10 @@ def __init__(self, **kwargs): ) from dash.testing.browser import Browser from dash.testing.composite import DashComposite, DashRComposite, DashJuliaComposite + # pylint: disable=unused-import + import dash_testing_stub # noqa: F401 + + _installed = True except ImportError: # Running pytest without dash[testing] installed. ThreadedRunner = MissingDashTesting @@ -33,9 +37,13 @@ def __init__(self, **kwargs): DashComposite = MissingDashTesting DashRComposite = MissingDashTesting DashJuliaComposite = MissingDashTesting + _installed = False def pytest_addoption(parser): + if not _installed: + return + dash = parser.getgroup("Dash", "Dash Integration Tests") dash.addoption( @@ -82,6 +90,8 @@ def pytest_addoption(parser): @pytest.mark.tryfirst def pytest_addhooks(pluginmanager): + if not _installed: + return # https://github.com/pytest-dev/pytest-xdist/blob/974bd566c599dc6a9ea291838c6f226197208b46/xdist/plugin.py#L67 # avoid warnings with pytest-2.8 from dash.testing import newhooks # pylint: disable=import-outside-toplevel @@ -94,6 +104,8 @@ def pytest_addhooks(pluginmanager): @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): # pylint: disable=unused-argument + if not _installed: + return # execute all other hooks to obtain the report object outcome = yield rep = outcome.get_result() diff --git a/dash/testing/wait.py b/dash/testing/wait.py index c0d41baa70..202322e96b 100644 --- a/dash/testing/wait.py +++ b/dash/testing/wait.py @@ -53,9 +53,10 @@ def until_not( class contains_text: - def __init__(self, selector, text): + def __init__(self, selector, text, timeout): self.selector = selector self.text = text + self.timeout = timeout def __call__(self, driver): try: @@ -67,6 +68,17 @@ def __call__(self, driver): except WebDriverException: return False + def message(self, driver): + try: + element = self._get_element(driver) + text = "found: " + str(element.text) or str(element.get_attribute("value")) + except WebDriverException: + text = f"{self.selector} not found" + return f"text -> {self.text} not found inside element within {self.timeout}s, {text}" + + def _get_element(self, driver): + return driver.find_element(By.CSS_SELECTOR, self.selector) + class contains_class: def __init__(self, selector, classname): @@ -86,13 +98,14 @@ def __call__(self, driver): class text_to_equal: - def __init__(self, selector, text): + def __init__(self, selector, text, timeout): self.selector = selector self.text = text + self.timeout = timeout def __call__(self, driver): try: - elem = driver.find_element(By.CSS_SELECTOR, self.selector) + elem = self._get_element(driver) logger.debug("text to equal {%s} => expected %s", elem.text, self.text) return ( str(elem.text) == self.text @@ -101,6 +114,17 @@ def __call__(self, driver): except WebDriverException: return False + def message(self, driver): + try: + element = self._get_element(driver) + text = "found: " + str(element.text) or str(element.get_attribute("value")) + except WebDriverException: + text = f"{self.selector} not found" + return f"text -> {self.text} not found within {self.timeout}s, {text}" + + def _get_element(self, driver): + return driver.find_element(By.CSS_SELECTOR, self.selector) + class style_to_equal: def __init__(self, selector, style, val): diff --git a/requires-testing.txt b/requires-testing.txt index f26ae4058c..cf8b8b737c 100644 --- a/requires-testing.txt +++ b/requires-testing.txt @@ -9,3 +9,4 @@ selenium>=3.141.0,<=4.2.0 waitress>=1.4.4 multiprocess>=0.70.12 psutil>=5.8.0 +dash_testing_stub>=0.0.2 diff --git a/tests/integration/test_duo.py b/tests/integration/test_duo.py new file mode 100644 index 0000000000..9408c1c47c --- /dev/null +++ b/tests/integration/test_duo.py @@ -0,0 +1,36 @@ +import pytest +from selenium.common.exceptions import TimeoutException + +from dash import Dash, html + + +def test_duo001_wait_for_text_error(dash_duo): + app = Dash(__name__) + app.layout = html.Div([html.Div("Content", id="content")]) + dash_duo.start_server(app) + + with pytest.raises(TimeoutException) as err: + dash_duo.wait_for_text_to_equal("#content", "Invalid", timeout=1.0) + + assert err.value.args[0] == "text -> Invalid not found within 1.0s, found: Content" + + with pytest.raises(TimeoutException) as err: + dash_duo.wait_for_text_to_equal("#none", "None", timeout=1.0) + + assert err.value.args[0] == "text -> None not found within 1.0s, #none not found" + + with pytest.raises(TimeoutException) as err: + dash_duo.wait_for_contains_text("#content", "invalid", timeout=1.0) + + assert ( + err.value.args[0] + == "text -> invalid not found inside element within 1.0s, found: Content" + ) + + with pytest.raises(TimeoutException) as err: + dash_duo.wait_for_contains_text("#none", "none", timeout=1.0) + + assert ( + err.value.args[0] + == "text -> none not found inside element within 1.0s, #none not found" + )