diff --git a/.circleci/config.yml b/.circleci/config.yml
index 900b1c0855..930c4eeb92 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -49,7 +49,7 @@ jobs:
flake8 dash setup.py
flake8 --ignore=E123,E126,E501,E722,E731,F401,F841,W503,W504 --exclude=metadata_test.py tests
pylint dash setup.py --rcfile=$PYLINTRC
- pylint tests/unit -d all -e C0410,C0411,C0412,C0413,W0109
+ pylint tests/unit tests/integration/devtools tests/integration/renderer tests/integration/dash_assets -d all -e C0410,C0411,C0412,C0413,W0109
cd dash-renderer && npm install --ignore-scripts && npm run lint:test && npm run format:test
- run:
@@ -57,7 +57,7 @@ jobs:
command: |
. venv/bin/activate
mkdir test-reports
- pytest --junitxml=test-reports/junit.xml tests/unit
+ PYTHONPATH=~/dash/tests/assets pytest --junitxml=test-reports/junit.xml tests/unit
- store_test_results:
path: test-reports
- store_artifacts:
@@ -74,13 +74,13 @@ jobs:
- run:
name: 🚧 install dependencies from latest master commit
command: |
- git clone --depth 1 https://github.com/plotly/dash-core-components.git
- git clone --depth 1 https://github.com/plotly/dash-html-components.git
+ git clone https://github.com/plotly/dash-core-components.git
+ git clone https://github.com/plotly/dash-html-components.git
git clone --depth 1 https://github.com/plotly/dash-table.git
git clone --depth 1 https://github.com/plotly/dash-renderer-test-components
. venv/bin/activate
- cd dash-core-components && npm install --ignore-scripts && npm run build && pip install -e . && cd ..
- cd dash-html-components && npm install --ignore-scripts && npm run build && pip install -e . && cd ..
+ cd dash-core-components && git checkout 2932409 && npm install --ignore-scripts && npm run build && pip install -e . && cd ..
+ cd dash-html-components && git checkout 446b114 && npm install --ignore-scripts && npm run build && pip install -e . && cd ..
cd dash-table && npm install --ignore-scripts && npm run build && pip install -e . && cd ..
cd dash-renderer-test-components && npm install --ignore-scripts && npm run build:all && pip install -e . && cd ..
@@ -89,11 +89,12 @@ jobs:
command: |
. venv/bin/activate
pytest --junitxml=test-reports/junit_intg.xml tests/integration/
-
+ - store_artifacts:
+ path: test-reports
- store_test_results:
path: test-reports
- store_artifacts:
- path: test-reports
+ path: /tmp/dash_artifacts
"python-3.6":
diff --git a/.circleci/requirements/dev-requirements-py37.txt b/.circleci/requirements/dev-requirements-py37.txt
index 127f48d5ec..723f0cac14 100644
--- a/.circleci/requirements/dev-requirements-py37.txt
+++ b/.circleci/requirements/dev-requirements-py37.txt
@@ -14,4 +14,5 @@ requests
beautifulsoup4
pytest
pytest-sugar
-pytest-mock
\ No newline at end of file
+pytest-mock
+waitress
\ No newline at end of file
diff --git a/.circleci/requirements/dev-requirements.txt b/.circleci/requirements/dev-requirements.txt
index c151ccc7d3..6533e7f5bc 100644
--- a/.circleci/requirements/dev-requirements.txt
+++ b/.circleci/requirements/dev-requirements.txt
@@ -14,3 +14,4 @@ pytest-mock
lxml
requests
beautifulsoup4
+waitress
diff --git a/.pylintrc b/.pylintrc
index a9688e4e7a..766a5f6d1c 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -59,7 +59,9 @@ disable=fixme,
invalid-name,
too-many-lines,
old-style-class,
- superfluous-parens
+ superfluous-parens,
+ bad-continuation,
+
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
diff --git a/.pylintrc37 b/.pylintrc37
index 57c45836cd..533bbade91 100644
--- a/.pylintrc37
+++ b/.pylintrc37
@@ -147,7 +147,8 @@ disable=invalid-name,
useless-object-inheritance,
possibly-unused-variable,
too-many-lines,
- too-many-statements
+ too-many-statements,
+ bad-continuation
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
diff --git a/dash-renderer/version.py b/dash-renderer/version.py
index 08a9dbff61..f8ab8c2e1f 100644
--- a/dash-renderer/version.py
+++ b/dash-renderer/version.py
@@ -1 +1 @@
-__version__ = '0.23.0'
+__version__ = '0.24.0'
diff --git a/tests/__init__.py b/dash/testing/__init__.py
similarity index 100%
rename from tests/__init__.py
rename to dash/testing/__init__.py
diff --git a/dash/testing/application_runners.py b/dash/testing/application_runners.py
new file mode 100644
index 0000000000..5dc2d3fa8f
--- /dev/null
+++ b/dash/testing/application_runners.py
@@ -0,0 +1,216 @@
+from __future__ import print_function
+
+import sys
+import uuid
+import shlex
+import threading
+import subprocess
+import logging
+
+import runpy
+import six
+import flask
+import requests
+
+from dash.testing.errors import (
+ NoAppFoundError,
+ TestingTimeoutError,
+ ServerCloseError,
+)
+import dash.testing.wait as wait
+
+
+logger = logging.getLogger(__name__)
+
+
+def import_app(app_file, application_name="app"):
+ """
+ Import a dash application from a module.
+ The import path is in dot notation to the module.
+ The variable named app will be returned.
+
+ :Example:
+
+ >>> app = import_app('my_app.app')
+
+ Will import the application in module `app` of the package `my_app`.
+
+ :param app_file: Path to the app (dot-separated).
+ :type app_file: str
+ :param application_name: The name of the dash application instance.
+ :raise: dash_tests.errors.NoAppFoundError
+ :return: App from module.
+ :rtype: dash.Dash
+ """
+ try:
+ app_module = runpy.run_module(app_file)
+ app = app_module[application_name]
+ except KeyError:
+ logger.exception("the app name cannot be found")
+ raise NoAppFoundError(
+ "No dash `app` instance was found in {}".format(app_file)
+ )
+ return app
+
+
+class BaseDashRunner(object):
+ """Base context manager class for running applications."""
+
+ def __init__(self, keep_open, stop_timeout):
+ self.port = 8050
+ self.started = None
+ self.keep_open = keep_open
+ self.stop_timeout = stop_timeout
+
+ def start(self, *args, **kwargs):
+ raise NotImplementedError # pragma: no cover
+
+ def stop(self):
+ raise NotImplementedError # pragma: no cover
+
+ def __call__(self, *args, **kwargs):
+ return self.start(*args, **kwargs)
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_val, traceback):
+ if self.started and not self.keep_open:
+ try:
+ logger.info("killing the app runner")
+ self.stop()
+ except TestingTimeoutError:
+ raise ServerCloseError(
+ "Cannot stop server within {}s timeout".format(
+ self.stop_timeout
+ )
+ )
+
+ @property
+ def url(self):
+ """the default server url"""
+ return "http://localhost:{}".format(self.port)
+
+
+class ThreadedRunner(BaseDashRunner):
+ """Runs a dash application in a thread
+
+ this is the default flavor to use in dash integration tests
+ """
+
+ def __init__(self, keep_open=False, stop_timeout=3):
+ super(ThreadedRunner, self).__init__(
+ keep_open=keep_open, stop_timeout=stop_timeout
+ )
+ self.stop_route = "/_stop-{}".format(uuid.uuid4().hex)
+ self.thread = None
+
+ @staticmethod
+ def _stop_server():
+ # https://werkzeug.palletsprojects.com/en/0.15.x/serving/#shutting-down-the-server
+ stopper = flask.request.environ.get("werkzeug.server.shutdown")
+ if stopper is None:
+ raise RuntimeError("Not running with the Werkzeug Server")
+ stopper()
+ return "Flask server is shutting down"
+
+ # pylint: disable=arguments-differ,C0330
+ def start(self, app, **kwargs):
+ """Start the app server in threading flavor"""
+ app.server.add_url_rule(
+ self.stop_route, self.stop_route, self._stop_server
+ )
+
+ def _handle_error():
+ self._stop_server()
+
+ app.server.errorhandler(500)(_handle_error)
+
+ def run():
+ app.scripts.config.serve_locally = True
+ app.css.config.serve_locally = True
+ if "port" not in kwargs:
+ kwargs["port"] = self.port
+ else:
+ self.port = kwargs["port"]
+ app.run_server(threaded=True, **kwargs)
+
+ self.thread = threading.Thread(target=run)
+ self.thread.daemon = True
+ try:
+ self.thread.start()
+ except RuntimeError: # multiple call on same thread
+ logger.exception("threaded server failed to start")
+ self.started = False
+
+ self.started = self.thread.is_alive()
+
+ def accessible():
+ try:
+ requests.get(self.url)
+ except requests.exceptions.RequestException:
+ return False
+ return True
+
+ # wait until server is able to answer http request
+ wait.until(accessible, timeout=1)
+
+ def stop(self):
+ requests.get("{}{}".format(self.url, self.stop_route))
+ wait.until_not(self.thread.is_alive, self.stop_timeout)
+
+
+class ProcessRunner(BaseDashRunner):
+ """Runs a dash application in a waitress-serve subprocess
+
+ this flavor is closer to production environment but slower
+ """
+
+ def __init__(self, keep_open=False, stop_timeout=3):
+ super(ProcessRunner, self).__init__(
+ keep_open=keep_open, stop_timeout=stop_timeout
+ )
+ self.proc = None
+
+ # pylint: disable=arguments-differ
+ def start(self, app_module, application_name="app", port=8050):
+ """Start the server with waitress-serve in process flavor """
+ entrypoint = "{}:{}.server".format(app_module, application_name)
+ self.port = port
+
+ args = shlex.split(
+ "waitress-serve --listen=0.0.0.0:{} {}".format(port, entrypoint),
+ posix=sys.platform != "win32",
+ )
+ logger.debug("start dash process with %s", args)
+
+ try:
+ self.proc = subprocess.Popen(
+ args, stdout=subprocess.PIPE, stderr=subprocess.PIPE
+ )
+ except (OSError, ValueError):
+ logger.exception("process server has encountered an error")
+ self.started = False
+ return
+
+ self.started = True
+
+ def stop(self):
+ if self.proc:
+ try:
+ self.proc.terminate()
+ if six.PY3:
+ # pylint:disable=no-member
+ _except = subprocess.TimeoutExpired
+ # pylint: disable=unexpected-keyword-arg
+ self.proc.communicate(timeout=self.stop_timeout)
+ else:
+ _except = OSError
+ self.proc.communicate()
+ except _except:
+ logger.exception(
+ "subprocess terminate not success, trying to kill "
+ "the subprocess in a safe manner"
+ )
+ self.proc.kill()
+ self.proc.communicate()
diff --git a/dash/testing/browser.py b/dash/testing/browser.py
new file mode 100644
index 0000000000..99e552c509
--- /dev/null
+++ b/dash/testing/browser.py
@@ -0,0 +1,266 @@
+# pylint: disable=missing-docstring
+import os
+import sys
+import logging
+import warnings
+import percy
+
+from selenium import webdriver
+from selenium.webdriver.support import expected_conditions as EC
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.wait import WebDriverWait
+from selenium.webdriver.chrome.options import Options
+from selenium.webdriver.common.keys import Keys
+from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
+from selenium.webdriver.common.action_chains import ActionChains
+
+from selenium.common.exceptions import WebDriverException, TimeoutException
+
+from dash.testing.wait import text_to_equal, style_to_equal
+from dash.testing.dash_page import DashPageMixin
+from dash.testing.errors import DashAppLoadingError
+
+
+logger = logging.getLogger(__name__)
+
+
+class Browser(DashPageMixin):
+ def __init__(self, browser, remote=None, wait_timeout=10):
+ self._browser = browser.lower()
+ self._wait_timeout = wait_timeout
+
+ self._driver = self.get_webdriver(remote)
+ self._driver.implicitly_wait(2)
+
+ self._wd_wait = WebDriverWait(self.driver, wait_timeout)
+ self._last_ts = 0
+ self._url = None
+
+ self.percy_runner = percy.Runner(
+ loader=percy.ResourceLoader(
+ webdriver=self.driver,
+ base_url="/assets",
+ root_dir="tests/assets",
+ )
+ )
+ self.percy_runner.initialize_build()
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_val, traceback):
+ try:
+ self.driver.quit()
+ self.percy_runner.finalize_build()
+ except WebDriverException:
+ logger.exception("webdriver quit was not successfully")
+ except percy.errors.Error:
+ logger.exception("percy runner failed to finalize properly")
+
+ def percy_snapshot(self, name=""):
+ snapshot_name = "{} - py{}.{}".format(
+ name, sys.version_info.major, sys.version_info.minor
+ )
+ logger.info("taking snapshot name => %s", snapshot_name)
+ self.percy_runner.snapshot(name=snapshot_name)
+
+ def take_snapshot(self, name):
+ """method used by hook to take snapshot while selenium test fails"""
+ target = (
+ "/tmp/dash_artifacts"
+ if not self._is_windows()
+ else os.getenv("TEMP")
+ )
+ if not os.path.exists(target):
+ try:
+ os.mkdir(target)
+ except OSError:
+ logger.exception("cannot make artifacts")
+
+ self.driver.save_screenshot(
+ "{}/{}_{}.png".format(target, name, self.session_id)
+ )
+
+ def find_element(self, css_selector):
+ """wrapper for find_element_by_css_selector from driver"""
+ return self.driver.find_element_by_css_selector(css_selector)
+
+ def find_elements(self, css_selector):
+ """wrapper for find_elements_by_css_selector from driver"""
+ return self.driver.find_elements_by_css_selector(css_selector)
+
+ def _wait_for(self, method, args, timeout, msg):
+ """abstract generic pattern for explicit webdriver wait"""
+ _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)
+
+ def wait_for_element(self, css_selector, timeout=None):
+ return self.wait_for_element_by_css_selector(css_selector, timeout)
+
+ # keep these two wait_for API for easy migration
+ def wait_for_element_by_css_selector(self, selector, timeout=None):
+ return self._wait_for(
+ EC.presence_of_element_located,
+ ((By.CSS_SELECTOR, selector),),
+ timeout,
+ "timeout {} => waiting for selector {}".format(timeout, selector),
+ )
+
+ def wait_for_style_to_equal(self, selector, style, val, timeout=None):
+ return self._wait_for(
+ method=style_to_equal,
+ args=(selector, style, val),
+ timeout=timeout,
+ msg="style val => {} {} not found within {}s".format(
+ style, val, timeout
+ ),
+ )
+
+ def wait_for_text_to_equal(self, selector, text, timeout=None):
+ return self._wait_for(
+ method=text_to_equal,
+ args=(selector, text),
+ timeout=timeout,
+ msg="text -> {} not found within {}s".format(text, timeout),
+ )
+
+ def wait_for_page(self, url=None, timeout=10):
+
+ self.driver.get(self.server_url if url is None else url)
+ try:
+ self.wait_for_element_by_css_selector(
+ self.dash_entry_locator, timeout=timeout
+ )
+ except TimeoutException:
+ logger.exception(
+ "dash server is not loaded within %s seconds", timeout
+ )
+ logger.debug(self.get_logs())
+ raise DashAppLoadingError(
+ "the expected Dash react entry point cannot be loaded"
+ " in browser\n HTML => {}\n Console Logs => {}\n".format(
+ self.driver.find_element_by_tag_name("body").get_property(
+ "innerHTML"
+ ),
+ "\n".join((str(log) for log in self.get_logs())),
+ )
+ )
+
+ def get_webdriver(self, remote):
+ return (
+ getattr(self, "_get_{}".format(self._browser))()
+ if remote is None
+ else webdriver.Remote(
+ command_executor=remote,
+ desired_capabilities=getattr(
+ DesiredCapabilities, self._browser.upper()
+ ),
+ )
+ )
+
+ @staticmethod
+ def _get_chrome():
+ options = Options()
+ options.add_argument("--no-sandbox")
+
+ capabilities = DesiredCapabilities.CHROME
+ capabilities["loggingPrefs"] = {"browser": "SEVERE"}
+
+ if "DASH_TEST_CHROMEPATH" in os.environ:
+ options.binary_location = os.environ["DASH_TEST_CHROMEPATH"]
+
+ chrome = webdriver.Chrome(
+ options=options, desired_capabilities=capabilities
+ )
+ chrome.set_window_position(0, 0)
+ return chrome
+
+ @staticmethod
+ def _get_firefox():
+
+ capabilities = DesiredCapabilities.FIREFOX
+ capabilities["loggingPrefs"] = {"browser": "SEVERE"}
+ capabilities["marionette"] = True
+
+ # https://developer.mozilla.org/en-US/docs/Download_Manager_preferences
+ fp = webdriver.FirefoxProfile()
+
+ # this will be useful if we wanna test download csv or other data
+ # files with selenium
+ # TODO this could be replaced with a tmpfixture from pytest too
+ fp.set_preference("browser.download.dir", "/tmp")
+ fp.set_preference("browser.download.folderList", 2)
+ fp.set_preference("browser.download.manager.showWhenStarting", False)
+
+ return webdriver.Firefox(fp, capabilities=capabilities)
+
+ @staticmethod
+ def _is_windows():
+ return sys.platform == "win32"
+
+ def multiple_click(self, css_selector, clicks):
+ for _ in range(clicks):
+ self.find_element(css_selector).click()
+
+ def clear_input(self, elem):
+ (
+ ActionChains(self.driver)
+ .click(elem)
+ .send_keys(Keys.HOME)
+ .key_down(Keys.SHIFT)
+ .send_keys(Keys.END)
+ .key_up(Keys.SHIFT)
+ .send_keys(Keys.DELETE)
+ ).perform()
+
+ def get_logs(self):
+ """get_logs works only with chrome webdriver"""
+ if self.driver.name.lower() == "chrome":
+ return [
+ entry
+ for entry in self.driver.get_log("browser")
+ if entry["timestamp"] > self._last_ts
+ ]
+ warnings.warn(
+ "get_logs always return None with webdrivers other than Chrome"
+ )
+ return None
+
+ def reset_log_timestamp(self):
+ """reset_log_timestamp only work with chrome webdrier"""
+ if self.driver.name.lower() == "chrome":
+ entries = self.driver.get_log("browser")
+ if entries:
+ self._last_ts = entries[-1]["timestamp"]
+
+ @property
+ def driver(self):
+ return self._driver
+
+ @property
+ def session_id(self):
+ return self.driver.session_id
+
+ @property
+ def server_url(self):
+ return self._url
+
+ @server_url.setter
+ def server_url(self, value):
+ """property setter for server_url
+ Note: set server_url will implicitly check if the server is ready
+ for selenium testing
+ """
+ self._url = value
+ self.wait_for_page()
diff --git a/dash/testing/composite.py b/dash/testing/composite.py
new file mode 100644
index 0000000000..485faacf08
--- /dev/null
+++ b/dash/testing/composite.py
@@ -0,0 +1,17 @@
+from dash.testing.browser import Browser
+
+
+class DashComposite(Browser):
+
+ def __init__(self, server, browser, remote=None, wait_timeout=10):
+ super(DashComposite, self).__init__(browser, remote, wait_timeout)
+ self.server = server
+
+ def start_server(self, app, **kwargs):
+ '''start the local server with app'''
+
+ # start server with app and pass Dash arguments
+ self.server(app, **kwargs)
+
+ # set the default server_url, it implicitly call wait_for_page
+ self.server_url = self.server.url
diff --git a/dash/testing/dash_page.py b/dash/testing/dash_page.py
new file mode 100644
index 0000000000..3ba62af000
--- /dev/null
+++ b/dash/testing/dash_page.py
@@ -0,0 +1,37 @@
+from bs4 import BeautifulSoup
+
+
+class DashPageMixin(object):
+ def _get_dash_dom_by_attribute(self, attr):
+ return BeautifulSoup(
+ self.find_element(self.dash_entry_locator).get_attribute(attr),
+ "lxml",
+ )
+
+ @property
+ def devtools_error_count_locator(self):
+ return ".test-devtools-error-count"
+
+ @property
+ def dash_entry_locator(self):
+ return "#react-entry-point"
+
+ @property
+ def dash_outerhtml_dom(self):
+ return self._get_dash_dom_by_attribute('outerHTML')
+
+ @property
+ def dash_innerhtml_dom(self):
+ return self._get_dash_dom_by_attribute('innerHTML')
+
+ @property
+ def redux_state_paths(self):
+ return self.driver.execute_script(
+ "return window.store.getState().paths"
+ )
+
+ @property
+ def redux_state_rqs(self):
+ return self.driver.execute_script(
+ "return window.store.getState().requestQueue"
+ )
diff --git a/dash/testing/errors.py b/dash/testing/errors.py
new file mode 100644
index 0000000000..9de48e30eb
--- /dev/null
+++ b/dash/testing/errors.py
@@ -0,0 +1,22 @@
+class DashTestingError(Exception):
+ """Base error for pytest-dash."""
+
+
+class InvalidDriverError(DashTestingError):
+ """An invalid selenium driver was specified."""
+
+
+class NoAppFoundError(DashTestingError):
+ """No `app` was found in the file."""
+
+
+class DashAppLoadingError(DashTestingError):
+ """The dash app failed to load"""
+
+
+class ServerCloseError(DashTestingError):
+ """The server cannot be closed"""
+
+
+class TestingTimeoutError(DashTestingError):
+ """"all timeout error about dash testing"""
diff --git a/dash/testing/plugin.py b/dash/testing/plugin.py
new file mode 100644
index 0000000000..01b2d074d1
--- /dev/null
+++ b/dash/testing/plugin.py
@@ -0,0 +1,76 @@
+# pylint: disable=missing-docstring,redefined-outer-name
+import pytest
+
+from selenium import webdriver
+
+from dash.testing.application_runners import ThreadedRunner, ProcessRunner
+from dash.testing.browser import Browser
+from dash.testing.composite import DashComposite
+
+WEBDRIVERS = {
+ "Chrome": webdriver.Chrome,
+ "Firefox": webdriver.Firefox,
+ "Remote": webdriver.Remote,
+}
+
+
+def pytest_addoption(parser):
+ # Add options to the pytest parser, either on the commandline or ini
+ # TODO add more options for the selenium driver.
+ dash = parser.getgroup("Dash", "Dash Integration Tests")
+
+ dash.addoption(
+ "--webdriver",
+ choices=tuple(WEBDRIVERS.keys()),
+ default="Chrome",
+ help="Name of the selenium driver to use",
+ )
+
+
+@pytest.hookimpl(tryfirst=True, hookwrapper=True)
+def pytest_runtest_makereport(item, call): # pylint: disable=unused-argument
+ # execute all other hooks to obtain the report object
+ outcome = yield
+ rep = outcome.get_result()
+
+ # we only look at actual failing test calls, not setup/teardown
+ if rep.when == "call" and rep.failed:
+ for name, fixture in item.funcargs.items():
+ try:
+ if name in {"dash_duo", "dash_br"}:
+ fixture.take_snapshot(item.name)
+ except Exception as e: # pylint: disable=broad-except
+ print(e)
+
+
+###############################################################################
+# Fixtures
+###############################################################################
+
+
+@pytest.fixture
+def dash_thread_server():
+ """Start a local dash server in a new thread"""
+ with ThreadedRunner() as starter:
+ yield starter
+
+
+@pytest.fixture
+def dash_process_server():
+ """Start a Dash server with subprocess.Popen and waitress-serve"""
+ with ProcessRunner() as starter:
+ yield starter
+
+
+@pytest.fixture
+def dash_br(request):
+ with Browser(request.config.getoption("webdriver")) as browser:
+ yield browser
+
+
+@pytest.fixture
+def dash_duo(request, dash_thread_server):
+ with DashComposite(
+ dash_thread_server, request.config.getoption("webdriver")
+ ) as dc:
+ yield dc
diff --git a/dash/testing/wait.py b/dash/testing/wait.py
new file mode 100644
index 0000000000..5d62d93c99
--- /dev/null
+++ b/dash/testing/wait.py
@@ -0,0 +1,91 @@
+# pylint: disable=too-few-public-methods
+"""Utils methods for pytest-dash such wait_for wrappers"""
+import time
+import logging
+from selenium.common.exceptions import WebDriverException
+from dash.testing.errors import TestingTimeoutError
+
+
+logger = logging.getLogger(__name__)
+
+
+def until(
+ wait_cond,
+ timeout,
+ poll=0.1,
+ msg="expected condition not met within timeout",
+): # noqa: C0330
+ res = None
+ logger.debug(
+ "start wait.until with method, timeout, poll => %s %s %s",
+ wait_cond,
+ timeout,
+ poll,
+ )
+ end_time = time.time() + timeout
+ while not res:
+ if time.time() > end_time:
+ raise TestingTimeoutError(msg)
+ time.sleep(poll)
+ res = wait_cond()
+ logger.debug("poll => %s", time.time())
+
+ return res
+
+
+def until_not(
+ wait_cond, timeout, poll=0.1, msg="expected condition met within timeout"
+): # noqa: C0330
+ res = True
+ logger.debug(
+ "start wait.until_not method, timeout, poll => %s %s %s",
+ wait_cond,
+ timeout,
+ poll,
+ )
+ end_time = time.time() + timeout
+ while res:
+ if time.time() > end_time:
+ raise TestingTimeoutError(msg)
+ time.sleep(poll)
+ res = wait_cond()
+ logger.debug("poll => %s", time.time())
+
+ return res
+
+
+class text_to_equal(object):
+ def __init__(self, selector, text):
+ self.selector = selector
+ self.text = text
+
+ def __call__(self, driver):
+ try:
+ elem = driver.find_element_by_css_selector(self.selector)
+ logger.debug(
+ "text to equal {%s} => expected %s", elem.text, self.text
+ )
+ return (
+ str(elem.text) == self.text
+ or str(elem.get_attribute("value")) == self.text
+ )
+ except WebDriverException:
+ logger.exception("text_to_equal encountered an exception")
+ return False
+
+
+class style_to_equal(object):
+ def __init__(self, selector, style, val):
+ self.selector = selector
+ self.style = style
+ self.val = val
+
+ def __call__(self, driver):
+ try:
+ elem = driver.find_element_by_css_selector(self.selector)
+ val = elem.value_of_css_property(self.style)
+ logger.debug("style to equal {%s} => expected %s", val, self.val)
+ return val == self.val
+ except WebDriverException:
+ logger.exception("style_to_equal encountered an exception")
+ return False
diff --git a/pytest.ini b/pytest.ini
index 52c3d7b0e6..d2dc22fc6a 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,5 +1,5 @@
[pytest]
-addopts = -rsxX -vv
-
-
-
+testpaths = tests/
+addopts = -rsxX
+log_format = %(asctime)s | %(levelname)s | %(name)s:%(lineno)d | %(message)s
+log_cli_level = ERROR
diff --git a/setup.py b/setup.py
index 64cda7e1ea..3d8070c86f 100644
--- a/setup.py
+++ b/setup.py
@@ -12,8 +12,10 @@
packages=find_packages(exclude=['tests*']),
include_package_data=True,
license='MIT',
- description=('A Python framework for building reactive web-apps. '
- 'Developed by Plotly.'),
+ description=(
+ 'A Python framework for building reactive web-apps. '
+ 'Developed by Plotly.'
+ ),
long_description=io.open('README.md', encoding='utf-8').read(),
long_description_content_type='text/markdown',
install_requires=[
@@ -29,7 +31,10 @@
'console_scripts': [
'dash-generate-components ='
' dash.development.component_generator:cli'
- ]
+ ],
+ 'pytest11': [
+ 'dash = dash.testing.plugin'
+ ],
},
url='https://plot.ly/dash',
classifiers=[
diff --git a/tests/assets/simple_app.py b/tests/assets/simple_app.py
new file mode 100644
index 0000000000..3e485c0890
--- /dev/null
+++ b/tests/assets/simple_app.py
@@ -0,0 +1,38 @@
+# pylint: disable=missing-docstring
+import dash_core_components as dcc
+import dash_html_components as html
+import dash
+from dash.dependencies import Output, Input
+from dash.exceptions import PreventUpdate
+
+
+app = dash.Dash(__name__)
+
+app.layout = html.Div(
+ [
+ dcc.Input(id="value", placeholder="my-value"),
+ html.Div(["You entered: ", html.Span(id="out")]),
+ html.Button("style-btn", id="style-btn"),
+ html.Div("style-container", id="style-output"),
+ ]
+)
+
+
+@app.callback(Output("out", "children"), [Input("value", "value")])
+def on_value(value):
+ if value is None:
+ raise PreventUpdate
+
+ return value
+
+
+@app.callback(Output("style-output", "style"), [Input("style-btn", "n_clicks")])
+def on_style(value):
+ if value is None:
+ raise PreventUpdate
+
+ return {"padding": "10px"}
+
+
+if __name__ == "__main__":
+ app.run_server(debug=True, port=10850)
diff --git a/tests/integration/IntegrationTests.py b/tests/integration/IntegrationTests.py
index a92bc927cd..0db03a6b81 100644
--- a/tests/integration/IntegrationTests.py
+++ b/tests/integration/IntegrationTests.py
@@ -37,7 +37,7 @@ def setUpClass(cls):
options.binary_location = os.environ['DASH_TEST_CHROMEPATH']
cls.driver = webdriver.Chrome(
- chrome_options=options, desired_capabilities=capabilities,
+ options=options, desired_capabilities=capabilities,
service_args=["--verbose", "--log-path=chrome.log"]
)
diff --git a/tests/integration/callbacks/test_basic_callback.py b/tests/integration/callbacks/test_basic_callback.py
new file mode 100644
index 0000000000..f12c589b0b
--- /dev/null
+++ b/tests/integration/callbacks/test_basic_callback.py
@@ -0,0 +1,142 @@
+from multiprocessing import Value
+
+from bs4 import BeautifulSoup
+
+import dash_core_components as dcc
+import dash_html_components as html
+import dash
+from dash.dependencies import Input, Output
+
+
+def test_cbsc001_simple_callback(dash_duo):
+ app = dash.Dash(__name__)
+ app.layout = html.Div(
+ [
+ dcc.Input(id="input", value="initial value"),
+ html.Div(html.Div([1.5, None, "string", html.Div(id="output-1")])),
+ ]
+ )
+ call_count = Value("i", 0)
+
+ @app.callback(Output("output-1", "children"), [Input("input", "value")])
+ def update_output(value):
+ call_count.value = call_count.value + 1
+ return value
+
+ dash_duo.start_server(app)
+
+ assert dash_duo.find_element("#output-1").text == "initial value"
+ dash_duo.percy_snapshot(name="simple-callback-initial")
+
+ input_ = dash_duo.find_element("#input")
+ dash_duo.clear_input(input_)
+
+ input_.send_keys("hello world")
+
+ assert dash_duo.find_element("#output-1").text == "hello world"
+ dash_duo.percy_snapshot(name="simple-callback-hello-world")
+
+ assert call_count.value == 2 + len(
+ "hello world"
+ ), "initial count + each key stroke"
+
+ rqs = dash_duo.redux_state_rqs
+ assert len(rqs) == 1
+
+ assert dash_duo.get_logs() == []
+
+
+def test_cbsc002_callbacks_generating_children(dash_duo):
+ """ Modify the DOM tree by adding new components in the callbacks"""
+
+ app = dash.Dash(__name__)
+ app.layout = html.Div(
+ [dcc.Input(id="input", value="initial value"), html.Div(id="output")]
+ )
+
+ @app.callback(Output("output", "children"), [Input("input", "value")])
+ def pad_output(input):
+ return html.Div(
+ [
+ dcc.Input(id="sub-input-1", value="sub input initial value"),
+ html.Div(id="sub-output-1"),
+ ]
+ )
+
+ call_count = Value("i", 0)
+
+ # these components don't exist in the initial render
+ app.config.supress_callback_exceptions = True
+
+ @app.callback(
+ Output("sub-output-1", "children"), [Input("sub-input-1", "value")]
+ )
+ def update_input(value):
+ call_count.value = call_count.value + 1
+ return value
+
+ dash_duo.start_server(app)
+
+ assert call_count.value == 1, "called once at initial stage"
+
+ pad_input, pad_div = dash_duo.dash_innerhtml_dom.select_one(
+ "#output > div"
+ ).contents
+
+ assert (
+ pad_input.attrs["value"] == "sub input initial value"
+ and pad_input.attrs["id"] == "sub-input-1"
+ )
+ assert pad_input.name == "input"
+
+ assert (
+ pad_div.text == pad_input.attrs["value"]
+ and pad_div.get("id") == "sub-output-1"
+ ), "the sub-output-1 content reflects to sub-input-1 value"
+
+ dash_duo.percy_snapshot(name="callback-generating-function-1")
+
+ assert dash_duo.redux_state_paths == {
+ "input": ["props", "children", 0],
+ "output": ["props", "children", 1],
+ "sub-input-1": [
+ "props",
+ "children",
+ 1,
+ "props",
+ "children",
+ "props",
+ "children",
+ 0,
+ ],
+ "sub-output-1": [
+ "props",
+ "children",
+ 1,
+ "props",
+ "children",
+ "props",
+ "children",
+ 1,
+ ],
+ }, "the paths should include these new output IDs"
+
+ # editing the input should modify the sub output
+ dash_duo.find_element("#sub-input-1").send_keys("deadbeef")
+
+ assert (
+ dash_duo.find_element("#sub-output-1").text
+ == pad_input.attrs["value"] + "deadbeef"
+ ), "deadbeef is added"
+
+ # the total updates is initial one + the text input changes
+ dash_duo.wait_for_text_to_equal(
+ "#sub-output-1", pad_input.attrs["value"] + "deadbeef"
+ )
+
+ rqs = dash_duo.redux_state_rqs
+ assert rqs, "request queue is not empty"
+ assert all((rq["status"] == 200 and not rq["rejected"] for rq in rqs))
+
+ dash_duo.percy_snapshot(name="callback-generating-function-2")
+ assert dash_duo.get_logs() == [], "console is clean"
diff --git a/tests/integration/callbacks/test_multiple_callbacks.py b/tests/integration/callbacks/test_multiple_callbacks.py
new file mode 100644
index 0000000000..7be877dd1f
--- /dev/null
+++ b/tests/integration/callbacks/test_multiple_callbacks.py
@@ -0,0 +1,39 @@
+import time
+from multiprocessing import Value
+
+import dash_html_components as html
+import dash
+from dash.dependencies import Input, Output
+
+
+def test_cbmt001_called_multiple_times_and_out_of_order(dash_duo):
+ app = dash.Dash(__name__)
+ app.layout = html.Div(
+ [html.Button(id="input", n_clicks=0), html.Div(id="output")]
+ )
+
+ call_count = Value("i", 0)
+
+ @app.callback(Output("output", "children"), [Input("input", "n_clicks")])
+ def update_output(n_clicks):
+ call_count.value = call_count.value + 1
+ if n_clicks == 1:
+ time.sleep(1)
+ return n_clicks
+
+ dash_duo.start_server(app)
+ dash_duo.multiple_click("#input", clicks=3)
+
+ time.sleep(3)
+
+ assert call_count.value == 4, "get called 4 times"
+ assert (
+ dash_duo.find_element("#output").text == "3"
+ ), "clicked button 3 times"
+
+ rqs = dash_duo.redux_state_rqs
+ assert len(rqs) == 1 and not rqs[0]["rejected"]
+
+ dash_duo.percy_snapshot(
+ name="test_callbacks_called_multiple_times_and_out_of_order"
+ )
diff --git a/tests/integration/dash_assets/test_assets.py b/tests/integration/dash_assets/test_assets.py
deleted file mode 100644
index 894c7d1395..0000000000
--- a/tests/integration/dash_assets/test_assets.py
+++ /dev/null
@@ -1,150 +0,0 @@
-import json
-import time
-import itertools
-
-import dash_html_components as html
-import dash_core_components as dcc
-
-from dash import Dash
-from tests.integration.IntegrationTests import IntegrationTests
-from tests.integration.utils import wait_for, invincible
-
-
-class TestAssets(IntegrationTests):
-
- def setUp(self):
- def wait_for_element_by_id(id_):
- wait_for(lambda: None is not invincible(
- lambda: self.driver.find_element_by_id(id_)
- ))
- return self.driver.find_element_by_id(id_)
- self.wait_for_element_by_id = wait_for_element_by_id
-
- def test_assets(self):
- app = Dash(__name__, assets_ignore='.*ignored.*')
- app.index_string = '''
-
-
-
- {%metas%}
- {%title%}
- {%css%}
-
-
-
- {%app_entry%}
-
-
-
- '''
-
- app.layout = html.Div([
- html.Div('Content', id='content'),
- dcc.Input(id='test')
- ], id='layout')
-
- self.startServer(app)
-
- # time.sleep(3600)
-
- body = self.driver.find_element_by_tag_name('body')
-
- body_margin = body.value_of_css_property('margin')
- self.assertEqual('0px', body_margin)
-
- content = self.wait_for_element_by_id('content')
- content_padding = content.value_of_css_property('padding')
- self.assertEqual('8px', content_padding)
-
- tested = self.wait_for_element_by_id('tested')
- tested = json.loads(tested.text)
-
- order = (
- 'load_first', 'load_after', 'load_after1', 'load_after10',
- 'load_after11', 'load_after2', 'load_after3', 'load_after4',
- )
-
- self.assertEqual(len(order), len(tested))
-
- for idx, _ in enumerate(tested):
- self.assertEqual(order[idx], tested[idx])
-
- self.percy_snapshot('test assets includes')
-
- def test_external_files_init(self):
- js_files = [
- 'https://www.google-analytics.com/analytics.js',
- {'src': 'https://cdn.polyfill.io/v2/polyfill.min.js'},
- {
- 'src': 'https://cdnjs.cloudflare.com/ajax/libs/ramda/0.26.1/ramda.min.js',
- 'integrity': 'sha256-43x9r7YRdZpZqTjDT5E0Vfrxn1ajIZLyYWtfAXsargA=',
- 'crossorigin': 'anonymous'
- },
- {
- 'src': 'https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.11/lodash.min.js',
- 'integrity': 'sha256-7/yoZS3548fXSRXqc/xYzjsmuW3sFKzuvOCHd06Pmps=',
- 'crossorigin': 'anonymous'
- }
- ]
-
- css_files = [
- 'https://codepen.io/chriddyp/pen/bWLwgP.css',
- {
- 'href': 'https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css',
- 'rel': 'stylesheet',
- 'integrity': 'sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO',
- 'crossorigin': 'anonymous'
- }
- ]
-
- app = Dash(
- __name__, external_scripts=js_files, external_stylesheets=css_files)
-
- app.index_string = '''
-
-
-
- {%metas%}
- {%title%}
- {%css%}
-
-
-
-
-
- {%app_entry%}
-
-
-
- '''
-
- app.layout = html.Div()
-
- self.startServer(app)
- time.sleep(0.5)
-
- js_urls = [x['src'] if isinstance(x, dict) else x for x in js_files]
- css_urls = [x['href'] if isinstance(x, dict) else x for x in css_files]
-
- for fmt, url in itertools.chain(
- (("//script[@src='{}']", x) for x in js_urls),
- (("//link[@href='{}']", x) for x in css_urls)):
- self.driver.find_element_by_xpath(fmt.format(url))
-
- # Ensure the button style was overloaded by reset (set to 38px in codepen)
- btn = self.driver.find_element_by_id('btn')
- btn_height = btn.value_of_css_property('height')
-
- self.assertEqual('18px', btn_height)
-
- # ensure ramda was loaded before the assets so they can use it.
- lo_test = self.driver.find_element_by_id('ramda-test')
- self.assertEqual('Hello World', lo_test.text)
diff --git a/tests/integration/dash_assets/test_dash_assets.py b/tests/integration/dash_assets/test_dash_assets.py
new file mode 100644
index 0000000000..578d726fa9
--- /dev/null
+++ b/tests/integration/dash_assets/test_dash_assets.py
@@ -0,0 +1,135 @@
+import json
+import time
+import itertools
+
+import dash_html_components as html
+import dash_core_components as dcc
+
+from dash import Dash
+
+
+def test_dada001_assets(dash_duo):
+ app = Dash(__name__, assets_ignore=".*ignored.*")
+ app.index_string = """
+
+
+
+ {%metas%}
+ {%title%}
+ {%css%}
+
+
+
+ {%app_entry%}
+
+
+
+ """
+
+ app.layout = html.Div(
+ [html.Div("Content", id="content"), dcc.Input(id="test")], id="layout"
+ )
+
+ dash_duo.start_server(app)
+
+ assert (
+ dash_duo.find_element("body").value_of_css_property("margin") == "0px"
+ ), "margin is overloaded by assets css resource"
+
+ assert (
+ dash_duo.find_element("#content").value_of_css_property("padding")
+ == "8px"
+ ), "padding is overloaded by assets"
+
+ tested = json.loads(dash_duo.wait_for_element("#tested").text)
+
+ order = [
+ u"load_first",
+ u"load_after",
+ u"load_after1",
+ u"load_after10",
+ u"load_after11",
+ u"load_after2",
+ u"load_after3",
+ u"load_after4",
+ ]
+
+ assert order == tested, "the content and order is expected"
+ dash_duo.percy_snapshot("test assets includes")
+
+
+def test_dada002_external_files_init(dash_duo):
+ js_files = [
+ "https://www.google-analytics.com/analytics.js",
+ {"src": "https://cdn.polyfill.io/v2/polyfill.min.js"},
+ {
+ "src": "https://cdnjs.cloudflare.com/ajax/libs/ramda/0.26.1/ramda.min.js",
+ "integrity": "sha256-43x9r7YRdZpZqTjDT5E0Vfrxn1ajIZLyYWtfAXsargA=",
+ "crossorigin": "anonymous",
+ },
+ {
+ "src": "https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.11/lodash.min.js",
+ "integrity": "sha256-7/yoZS3548fXSRXqc/xYzjsmuW3sFKzuvOCHd06Pmps=",
+ "crossorigin": "anonymous",
+ },
+ ]
+
+ css_files = [
+ "https://codepen.io/chriddyp/pen/bWLwgP.css",
+ {
+ "href": "https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css",
+ "rel": "stylesheet",
+ "integrity": "sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO",
+ "crossorigin": "anonymous",
+ },
+ ]
+
+ app = Dash(
+ __name__, external_scripts=js_files, external_stylesheets=css_files
+ )
+
+ app.index_string = """
+
+
+
+ {%metas%}
+ {%title%}
+ {%css%}
+
+
+
+
+
+ {%app_entry%}
+
+
+
+ """
+
+ app.layout = html.Div()
+
+ dash_duo.start_server(app)
+
+ js_urls = [x["src"] if isinstance(x, dict) else x for x in js_files]
+ css_urls = [x["href"] if isinstance(x, dict) else x for x in css_files]
+
+ for fmt, url in itertools.chain(
+ (("//script[@src='{}']", x) for x in js_urls),
+ (("//link[@href='{}']", x) for x in css_urls),
+ ):
+ dash_duo.driver.find_element_by_xpath(fmt.format(url))
+
+ assert (
+ dash_duo.find_element("#btn").value_of_css_property("height") == "18px"
+ ), "Ensure the button style was overloaded by reset (set to 38px in codepen)"
+
+ # ensure ramda was loaded before the assets so they can use it.
+ assert dash_duo.find_element("#ramda-test").text == "Hello World"
diff --git a/tests/integration/test_assets/hot_reload.css b/tests/integration/devtools/hr_assets/hot_reload.css
similarity index 100%
rename from tests/integration/test_assets/hot_reload.css
rename to tests/integration/devtools/hr_assets/hot_reload.css
diff --git a/tests/integration/devtools/test_devtools_error_handling.py b/tests/integration/devtools/test_devtools_error_handling.py
new file mode 100644
index 0000000000..398578cc76
--- /dev/null
+++ b/tests/integration/devtools/test_devtools_error_handling.py
@@ -0,0 +1,202 @@
+# -*- coding: UTF-8 -*-
+import dash_html_components as html
+import dash_core_components as dcc
+import dash
+from dash.dependencies import Input, Output
+from dash.exceptions import PreventUpdate
+
+
+def test_dveh001_python_errors(dash_duo):
+ app = dash.Dash(__name__)
+
+ app.layout = html.Div(
+ [
+ html.Button(id="python", children="Python exception", n_clicks=0),
+ html.Div(id="output"),
+ ]
+ )
+
+ @app.callback(Output("output", "children"), [Input("python", "n_clicks")])
+ def update_output(n_clicks):
+ if n_clicks == 1:
+ 1 / 0
+ elif n_clicks == 2:
+ raise Exception("Special 2 clicks exception")
+
+ dash_duo.start_server(
+ app,
+ debug=True,
+ use_reloader=False,
+ use_debugger=True,
+ dev_tools_hot_reload=False,
+ )
+
+ dash_duo.percy_snapshot("devtools - python exception - start")
+
+ dash_duo.find_element("#python").click()
+ dash_duo.wait_for_text_to_equal(dash_duo.devtools_error_count_locator, "1")
+ dash_duo.percy_snapshot("devtools - python exception - closed")
+
+ dash_duo.find_element(".test-devtools-error-toggle").click()
+ dash_duo.percy_snapshot("devtools - python exception - open")
+
+ dash_duo.find_element(".test-devtools-error-toggle").click()
+ dash_duo.find_element("#python").click()
+
+ dash_duo.wait_for_text_to_equal(dash_duo.devtools_error_count_locator, "2")
+ dash_duo.percy_snapshot("devtools - python exception - 2 errors")
+
+ dash_duo.find_element(".test-devtools-error-toggle").click()
+ dash_duo.percy_snapshot("devtools - python exception - 2 errors open")
+
+
+def test_dveh002_prevent_update_not_in_error_msg(dash_duo):
+ # raising PreventUpdate shouldn't display the error message
+ app = dash.Dash(__name__)
+
+ app.layout = html.Div(
+ [
+ html.Button(id="python", children="Prevent update", n_clicks=0),
+ html.Div(id="output"),
+ ]
+ )
+
+ @app.callback(Output("output", "children"), [Input("python", "n_clicks")])
+ def update_output(n_clicks):
+ if n_clicks == 1:
+ raise PreventUpdate
+ if n_clicks == 2:
+ raise Exception("An actual python exception")
+
+ return "button clicks: {}".format(n_clicks)
+
+ dash_duo.start_server(
+ app,
+ debug=True,
+ use_reloader=False,
+ use_debugger=True,
+ dev_tools_hot_reload=False,
+ )
+
+ for _ in range(3):
+ dash_duo.find_element("#python").click()
+
+ assert (
+ dash_duo.find_element("#output").text == "button clicks: 3"
+ ), "the click counts correctly in output"
+
+ # two exceptions fired, but only a single exception appeared in the UI:
+ # the prevent default was not displayed
+ dash_duo.wait_for_text_to_equal(dash_duo.devtools_error_count_locator, "1")
+ dash_duo.percy_snapshot(
+ "devtools - prevent update - only a single exception"
+ )
+
+
+def test_dveh003_validation_errors_in_place(dash_duo):
+ app = dash.Dash(__name__)
+
+ app.layout = html.Div(
+ [
+ html.Button(id="button", children="update-graph", n_clicks=0),
+ dcc.Graph(id="output", figure={"data": [{"y": [3, 1, 2]}]}),
+ ]
+ )
+
+ # animate is a bool property
+ @app.callback(Output("output", "animate"), [Input("button", "n_clicks")])
+ def update_output(n_clicks):
+ if n_clicks == 1:
+ return n_clicks
+
+ dash_duo.start_server(
+ app,
+ debug=True,
+ use_reloader=False,
+ use_debugger=True,
+ dev_tools_hot_reload=False,
+ )
+
+ dash_duo.find_element("#button").click()
+ dash_duo.wait_for_text_to_equal(dash_duo.devtools_error_count_locator, "1")
+ dash_duo.percy_snapshot("devtools - validation exception - closed")
+
+ dash_duo.find_element(".test-devtools-error-toggle").click()
+ dash_duo.percy_snapshot("devtools - validation exception - open")
+
+
+def test_dveh004_validation_errors_creation(dash_duo):
+ app = dash.Dash(__name__)
+
+ app.layout = html.Div(
+ [
+ html.Button(id="button", children="update-graph", n_clicks=0),
+ html.Div(id="output"),
+ ]
+ )
+
+ # animate is a bool property
+ @app.callback(Output("output", "children"), [Input("button", "n_clicks")])
+ def update_output(n_clicks):
+ if n_clicks == 1:
+ return dcc.Graph(
+ id="output", animate=0, figure={"data": [{"y": [3, 1, 2]}]}
+ )
+
+ dash_duo.start_server(
+ app,
+ debug=True,
+ use_reloader=False,
+ use_debugger=True,
+ dev_tools_hot_reload=False,
+ )
+
+ dash_duo.wait_for_element("#button").click()
+ dash_duo.wait_for_text_to_equal(dash_duo.devtools_error_count_locator, "1")
+ dash_duo.percy_snapshot("devtools - validation creation exception - closed")
+
+ dash_duo.find_element(".test-devtools-error-toggle").click()
+ dash_duo.percy_snapshot("devtools - validation creation exception - open")
+
+
+def test_dveh005_multiple_outputs(dash_duo):
+ app = dash.Dash(__name__)
+ app.layout = html.Div(
+ [
+ html.Button(
+ id="multi-output",
+ children="trigger multi output update",
+ n_clicks=0,
+ ),
+ html.Div(id="multi-1"),
+ html.Div(id="multi-2"),
+ ]
+ )
+
+ @app.callback(
+ [Output("multi-1", "children"), Output("multi-2", "children")],
+ [Input("multi-output", "n_clicks")],
+ )
+ def update_outputs(n_clicks):
+ if n_clicks == 0:
+ return [
+ "Output 1 - {} Clicks".format(n_clicks),
+ "Output 2 - {} Clicks".format(n_clicks),
+ ]
+ else:
+ n_clicks / 0
+
+ dash_duo.start_server(
+ app,
+ debug=True,
+ use_reloader=False,
+ use_debugger=True,
+ dev_tools_hot_reload=False,
+ )
+
+ dash_duo.find_element("#multi-output").click()
+ dash_duo.wait_for_text_to_equal(dash_duo.devtools_error_count_locator, "1")
+ dash_duo.percy_snapshot("devtools - multi output python exception - closed")
+
+ dash_duo.find_element(".test-devtools-error-toggle").click()
+ dash_duo.percy_snapshot("devtools - multi output python exception - open")
diff --git a/tests/integration/devtools/test_devtools_ui.py b/tests/integration/devtools/test_devtools_ui.py
new file mode 100644
index 0000000000..d0f958481c
--- /dev/null
+++ b/tests/integration/devtools/test_devtools_ui.py
@@ -0,0 +1,66 @@
+import dash_core_components as dcc
+import dash_html_components as html
+import dash
+import dash.testing.wait as wait
+
+
+def test_dvui001_disable_props_check_config(dash_duo):
+ app = dash.Dash(__name__)
+ app.layout = html.Div(
+ [
+ html.P(id="tcid", children="Hello Props Check"),
+ dcc.Graph(id="broken", animate=3), # error ignored by disable
+ ]
+ )
+
+ dash_duo.start_server(
+ app,
+ debug=True,
+ use_reloader=False,
+ use_debugger=True,
+ dev_tools_hot_reload=False,
+ dev_tools_props_check=False,
+ )
+
+ dash_duo.wait_for_text_to_equal("#tcid", "Hello Props Check")
+ assert dash_duo.find_elements(
+ "#broken svg.main-svg"
+ ), "graph should be rendered"
+
+ assert dash_duo.find_elements(
+ ".dash-debug-menu"
+ ), "the debug menu icon should show up"
+
+ dash_duo.percy_snapshot(
+ "devtools - disable props check - Graph should render"
+ )
+
+
+def test_dvui002_disable_ui_config(dash_duo):
+ app = dash.Dash(__name__)
+ app.layout = html.Div(
+ [
+ html.P(id="tcid", children="Hello Disable UI"),
+ dcc.Graph(id="broken", animate=3), # error ignored by disable
+ ]
+ )
+
+ dash_duo.start_server(
+ app,
+ debug=True,
+ use_reloader=False,
+ use_debugger=True,
+ dev_tools_hot_reload=False,
+ dev_tools_ui=False,
+ )
+
+ dash_duo.wait_for_text_to_equal("#tcid", "Hello Disable UI")
+ logs = str(wait.until(dash_duo.get_logs, timeout=1))
+ assert (
+ "Invalid argument `animate` passed into Graph" in logs
+ ), "the error should present in the console without DEV tools UI"
+
+ assert not dash_duo.find_elements(
+ ".dash-debug-menu"
+ ), "the debug menu icon should NOT show up"
+ dash_duo.percy_snapshot("devtools - disable dev tools UI - no debug menu")
diff --git a/tests/integration/devtools/test_hot_reload.py b/tests/integration/devtools/test_hot_reload.py
new file mode 100644
index 0000000000..5c5845efde
--- /dev/null
+++ b/tests/integration/devtools/test_hot_reload.py
@@ -0,0 +1,47 @@
+import os
+import textwrap
+import dash_html_components as html
+import dash
+
+
+def test_dvhr001_hot_reload(dash_duo):
+ app = dash.Dash(__name__, assets_folder="hr_assets")
+ app.layout = html.Div([html.H3("Hot reload")], id="hot-reload-content")
+
+ dash_duo.start_server(
+ app,
+ dev_tools_hot_reload=True,
+ dev_tools_hot_reload_interval=100,
+ dev_tools_hot_reload_max_retry=30,
+ )
+
+ # default overload color is blue
+ dash_duo.wait_for_style_to_equal(
+ "#hot-reload-content", "background-color", "rgba(0, 0, 255, 1)"
+ )
+
+ hot_reload_file = os.path.join(
+ os.path.dirname(__file__), "hr_assets", "hot_reload.css"
+ )
+ with open(hot_reload_file, "r+") as fp:
+ old_content = fp.read()
+ fp.truncate(0)
+ fp.seek(0)
+ fp.write(
+ textwrap.dedent(
+ """
+ #hot-reload-content {
+ background-color: red;
+ }
+ """
+ )
+ )
+
+ try:
+ # red is live changed during the test execution
+ dash_duo.wait_for_style_to_equal(
+ "#hot-reload-content", "background-color", "rgba(255, 0, 0, 1)"
+ )
+ finally:
+ with open(hot_reload_file, "w") as f:
+ f.write(old_content)
diff --git a/tests/integration/devtools/test_props_check.py b/tests/integration/devtools/test_props_check.py
new file mode 100644
index 0000000000..0f2fe200c8
--- /dev/null
+++ b/tests/integration/devtools/test_props_check.py
@@ -0,0 +1,227 @@
+import dash_core_components as dcc
+import dash_html_components as html
+import dash
+from dash.dependencies import Input, Output
+
+
+test_cases = {
+ "not-boolean": {
+ "fail": True,
+ "name": 'simple "not a boolean" check',
+ "component": dcc.Graph,
+ "props": {"animate": 0},
+ },
+ "missing-required-nested-prop": {
+ "fail": True,
+ "name": 'missing required "value" inside options',
+ "component": dcc.Checklist,
+ "props": {"options": [{"label": "hello"}], "values": ["test"]},
+ },
+ "invalid-nested-prop": {
+ "fail": True,
+ "name": "invalid nested prop",
+ "component": dcc.Checklist,
+ "props": {
+ "options": [{"label": "hello", "value": True}],
+ "values": ["test"],
+ },
+ },
+ "invalid-arrayOf": {
+ "fail": True,
+ "name": "invalid arrayOf",
+ "component": dcc.Checklist,
+ "props": {"options": "test", "values": []},
+ },
+ "invalid-oneOf": {
+ "fail": True,
+ "name": "invalid oneOf",
+ "component": dcc.Input,
+ "props": {"type": "test"},
+ },
+ "invalid-oneOfType": {
+ "fail": True,
+ "name": "invalid oneOfType",
+ "component": dcc.Input,
+ "props": {"max": True},
+ },
+ "invalid-shape-1": {
+ "fail": True,
+ "name": "invalid key within nested object",
+ "component": dcc.Graph,
+ "props": {"config": {"asdf": "that"}},
+ },
+ "invalid-shape-2": {
+ "fail": True,
+ "name": "nested object with bad value",
+ "component": dcc.Graph,
+ "props": {"config": {"edits": {"legendPosition": "asdf"}}},
+ },
+ "invalid-shape-3": {
+ "fail": True,
+ "name": "invalid oneOf within nested object",
+ "component": dcc.Graph,
+ "props": {"config": {"toImageButtonOptions": {"format": "asdf"}}},
+ },
+ "invalid-shape-4": {
+ "fail": True,
+ "name": "invalid key within deeply nested object",
+ "component": dcc.Graph,
+ "props": {"config": {"toImageButtonOptions": {"asdf": "test"}}},
+ },
+ "invalid-shape-5": {
+ "fail": True,
+ "name": "invalid not required key",
+ "component": dcc.Dropdown,
+ "props": {
+ "options": [{"label": "new york", "value": "ny", "typo": "asdf"}]
+ },
+ },
+ "string-not-list": {
+ "fail": True,
+ "name": "string-not-a-list",
+ "component": dcc.Checklist,
+ "props": {
+ "options": [{"label": "hello", "value": "test"}],
+ "values": "test",
+ },
+ },
+ "no-properties": {
+ "fail": False,
+ "name": "no properties",
+ "component": dcc.Graph,
+ "props": {},
+ },
+ "nested-children": {
+ "fail": True,
+ "name": "nested children",
+ "component": html.Div,
+ "props": {"children": [[1]]},
+ },
+ "deeply-nested-children": {
+ "fail": True,
+ "name": "deeply nested children",
+ "component": html.Div,
+ "props": {"children": html.Div([html.Div([3, html.Div([[10]])])])},
+ },
+ "dict": {
+ "fail": True,
+ "name": "returning a dictionary",
+ "component": html.Div,
+ "props": {"children": {"hello": "world"}},
+ },
+ "nested-prop-failure": {
+ "fail": True,
+ "name": "nested string instead of number/null",
+ "component": dcc.Graph,
+ "props": {
+ "figure": {"data": [{}]},
+ "config": {
+ "toImageButtonOptions": {"width": None, "height": "test"}
+ },
+ },
+ },
+ "allow-null": {
+ "fail": False,
+ "name": "nested null",
+ "component": dcc.Graph,
+ "props": {
+ "figure": {"data": [{}]},
+ "config": {"toImageButtonOptions": {"width": None, "height": None}},
+ },
+ },
+ "allow-null-2": {
+ "fail": False,
+ "name": "allow null as value",
+ "component": dcc.Dropdown,
+ "props": {"value": None},
+ },
+ "allow-null-3": {
+ "fail": False,
+ "name": "allow null in properties",
+ "component": dcc.Input,
+ "props": {"value": None},
+ },
+ "allow-null-4": {
+ "fail": False,
+ "name": "allow null in oneOfType",
+ "component": dcc.Store,
+ "props": {"id": "store", "data": None},
+ },
+ "long-property-string": {
+ "fail": True,
+ "name": "long property string with id",
+ "component": html.Div,
+ "props": {"id": "pink div", "style": "color: hotpink; " * 1000},
+ },
+ "multiple-wrong-values": {
+ "fail": True,
+ "name": "multiple wrong props",
+ "component": dcc.Dropdown,
+ "props": {"id": "dropdown", "value": 10, "options": "asdf"},
+ },
+ "boolean-html-properties": {
+ "fail": True,
+ "name": "dont allow booleans for dom props",
+ "component": html.Div,
+ "props": {"contentEditable": True},
+ },
+ "allow-exact-with-optional-and-required-1": {
+ "fail": False,
+ "name": "allow exact with optional and required keys",
+ "component": dcc.Dropdown,
+ "props": {
+ "options": [{"label": "new york", "value": "ny", "disabled": False}]
+ },
+ },
+ "allow-exact-with-optional-and-required-2": {
+ "fail": False,
+ "name": "allow exact with optional and required keys 2",
+ "component": dcc.Dropdown,
+ "props": {"options": [{"label": "new york", "value": "ny"}]},
+ },
+}
+
+
+def test_dvpc001_prop_check_errors_with_path(dash_duo):
+ app = dash.Dash(__name__)
+
+ app.layout = html.Div([html.Div(id="content"), dcc.Location(id="location")])
+
+ @app.callback(
+ Output("content", "children"), [Input("location", "pathname")]
+ )
+ def display_content(pathname):
+ if pathname is None or pathname == "/":
+ return "Initial state"
+ test_case = test_cases[pathname.strip("/")]
+ return html.Div(
+ id="new-component",
+ children=test_case["component"](**test_case["props"]),
+ )
+
+ dash_duo.start_server(
+ app,
+ debug=True,
+ use_reloader=False,
+ use_debugger=True,
+ dev_tools_hot_reload=False,
+ )
+
+ for tc in test_cases:
+ route_url = "{}/{}".format(dash_duo.server_url, tc)
+ dash_duo.wait_for_page(url=route_url)
+
+ if test_cases[tc]["fail"]:
+ dash_duo.wait_for_element(".test-devtools-error-toggle").click()
+ dash_duo.percy_snapshot(
+ "devtools validation exception: {}".format(
+ test_cases[tc]["name"]
+ )
+ )
+ else:
+ dash_duo.wait_for_element("#new-component")
+ dash_duo.percy_snapshot(
+ "devtools validation no exception: {}".format(
+ test_cases[tc]["name"]
+ )
+ )
diff --git a/tests/integration/test_assets/initial_state_dash_app_content.html b/tests/integration/renderer/initial_state_dash_app_content.html
similarity index 100%
rename from tests/integration/test_assets/initial_state_dash_app_content.html
rename to tests/integration/renderer/initial_state_dash_app_content.html
diff --git a/tests/integration/renderer/test_dependencies.py b/tests/integration/renderer/test_dependencies.py
new file mode 100644
index 0000000000..d1160d9c45
--- /dev/null
+++ b/tests/integration/renderer/test_dependencies.py
@@ -0,0 +1,47 @@
+from multiprocessing import Value
+
+import dash_core_components as dcc
+import dash_html_components as html
+import dash
+from dash.dependencies import Input, Output
+
+
+def test_rddp001_dependencies_on_components_that_dont_exist(dash_duo):
+ app = dash.Dash(__name__)
+ app.layout = html.Div(
+ [dcc.Input(id="input", value="initial value"), html.Div(id="output-1")]
+ )
+
+ output_1_call_count = Value("i", 0)
+
+ @app.callback(Output("output-1", "children"), [Input("input", "value")])
+ def update_output(value):
+ output_1_call_count.value += 1
+ return value
+
+ # callback for component that doesn't yet exist in the dom
+ # in practice, it might get added by some other callback
+ app.config.supress_callback_exceptions = True
+ output_2_call_count = Value("i", 0)
+
+ @app.callback(Output("output-2", "children"), [Input("input", "value")])
+ def update_output_2(value):
+ output_2_call_count.value += 1
+ return value
+
+ dash_duo.start_server(app)
+
+ assert dash_duo.find_element("#output-1").text == "initial value"
+ assert output_1_call_count.value == 1 and output_2_call_count.value == 0
+ dash_duo.percy_snapshot(name="dependencies")
+
+ dash_duo.find_element("#input").send_keys("a")
+ assert dash_duo.find_element("#output-1").text == "initial valuea"
+
+ assert output_1_call_count.value == 2 and output_2_call_count.value == 0
+
+ rqs = dash_duo.redux_state_rqs
+ assert len(rqs) == 1
+ assert rqs[0]["controllerId"] == "output-1.children" and not rqs[0]['rejected']
+
+ assert dash_duo.get_logs() == []
diff --git a/tests/integration/renderer/test_due_diligence.py b/tests/integration/renderer/test_due_diligence.py
new file mode 100644
index 0000000000..88ef5fbf29
--- /dev/null
+++ b/tests/integration/renderer/test_due_diligence.py
@@ -0,0 +1,109 @@
+import json
+import os
+import string
+
+from bs4 import BeautifulSoup
+import requests
+
+import plotly
+import dash_html_components as html
+import dash
+
+
+def test_rddd001_initial_state(dash_duo):
+ app = dash.Dash(__name__)
+ my_class_attrs = {
+ "id": "p.c.4",
+ "className": "my-class",
+ "title": "tooltip",
+ "style": {"color": "red", "fontSize": 30},
+ }
+ # fmt:off
+ app.layout = html.Div([
+ 'Basic string',
+ 3.14,
+ True,
+ None,
+ html.Div('Child div with basic string', **my_class_attrs),
+ html.Div(id='p.c.5'),
+ html.Div([
+ html.Div('Grandchild div', id='p.c.6.p.c.0'),
+ html.Div([
+ html.Div('Great grandchild', id='p.c.6.p.c.1.p.c.0'),
+ 3.14159,
+ 'another basic string'
+ ], id='p.c.6.p.c.1'),
+ html.Div([
+ html.Div(
+ html.Div([
+ html.Div([
+ html.Div(
+ id='p.c.6.p.c.2.p.c.0.p.c.p.c.0.p.c.0'
+ ),
+ '',
+ html.Div(
+ id='p.c.6.p.c.2.p.c.0.p.c.p.c.0.p.c.2'
+ )
+ ], id='p.c.6.p.c.2.p.c.0.p.c.p.c.0')
+ ], id='p.c.6.p.c.2.p.c.0.p.c'),
+ id='p.c.6.p.c.2.p.c.0'
+ )
+ ], id='p.c.6.p.c.2')
+ ], id='p.c.6')
+ ])
+ # fmt:on
+
+ dash_duo.start_server(app)
+
+ # Note: this .html file shows there's no undo/redo button by default
+ with open(
+ os.path.join(
+ os.path.dirname(__file__), "initial_state_dash_app_content.html"
+ )
+ ) as fp:
+ expected_dom = BeautifulSoup(fp.read().strip(), "lxml")
+
+ fetched_dom = dash_duo.dash_outerhtml_dom
+
+ assert (
+ fetched_dom.decode() == expected_dom.decode()
+ ), "the fetching rendered dom is expected"
+
+ assert (
+ dash_duo.get_logs() == []
+ ), "Check that no errors or warnings were displayed"
+
+ assert dash_duo.driver.execute_script(
+ "return JSON.parse(JSON.stringify("
+ "window.store.getState().layout"
+ "))"
+ ) == json.loads(
+ json.dumps(app.layout, cls=plotly.utils.PlotlyJSONEncoder)
+ ), "the state layout is identical to app.layout"
+
+ r = requests.get("{}/_dash-dependencies".format(dash_duo.server_url))
+ assert r.status_code == 200
+ assert (
+ r.json() == []
+ ), "no dependencies present in app as no callbacks are defined"
+
+ assert dash_duo.redux_state_paths == {
+ abbr: [
+ int(token)
+ if token in string.digits
+ else token.replace("p", "props").replace("c", "children")
+ for token in abbr.split(".")
+ ]
+ for abbr in (
+ child.get("id")
+ for child in fetched_dom.find(id="react-entry-point").findChildren(
+ id=True
+ )
+ )
+ }, "paths should reflect to the component hierarchy"
+
+ rqs = dash_duo.redux_state_rqs
+ assert not rqs, "no callback => no requestQueue"
+
+ dash_duo.percy_snapshot(name="layout")
+ assert dash_duo.get_logs() == [], "console has no errors"
diff --git a/tests/integration/renderer/test_state_and_input.py b/tests/integration/renderer/test_state_and_input.py
new file mode 100644
index 0000000000..7ffe1ddbbe
--- /dev/null
+++ b/tests/integration/renderer/test_state_and_input.py
@@ -0,0 +1,113 @@
+from multiprocessing import Value
+import time
+import dash_html_components as html
+import dash_core_components as dcc
+import dash
+from dash.dependencies import Input, Output, State
+import dash.testing.wait as wait
+
+
+def test_rdsi001_state_and_inputs(dash_duo):
+ app = dash.Dash(__name__)
+ app.layout = html.Div(
+ [
+ dcc.Input(value="Initial Input", id="input"),
+ dcc.Input(value="Initial State", id="state"),
+ html.Div(id="output"),
+ ]
+ )
+
+ call_count = Value("i", 0)
+
+ @app.callback(
+ Output("output", "children"),
+ [Input("input", "value")],
+ [State("state", "value")],
+ )
+ def update_output(input, state):
+ call_count.value += 1
+ return 'input="{}", state="{}"'.format(input, state)
+
+ dash_duo.start_server(app)
+
+ input_ = lambda: dash_duo.find_element("#input")
+ output_ = lambda: dash_duo.find_element("#output")
+
+ assert (
+ output_().text == 'input="Initial Input", state="Initial State"'
+ ), "callback gets called with initial input"
+
+ input_().send_keys("x")
+ wait.until(lambda: call_count.value == 2, timeout=1)
+ assert (
+ output_().text == 'input="Initial Inputx", state="Initial State"'
+ ), "output get updated with key `x`"
+
+ dash_duo.find_element("#state").send_keys("z")
+ time.sleep(0.5)
+ assert call_count.value == 2, "state not trigger callback with 0.5 wait"
+ assert (
+ output_().text == 'input="Initial Inputx", state="Initial State"'
+ ), "output remains the same as last step"
+
+ input_().send_keys("y")
+ wait.until(lambda: call_count.value == 3, timeout=1)
+ assert (
+ output_().text == 'input="Initial Inputxy", state="Initial Statez"'
+ ), "both input and state value get updated by input callback"
+
+
+def test_rdsi002_event_properties_state_and_inputs(dash_duo):
+ app = dash.Dash(__name__)
+ app.layout = html.Div(
+ [
+ html.Button("Click Me", id="button"),
+ dcc.Input(value="Initial Input", id="input"),
+ dcc.Input(value="Initial State", id="state"),
+ html.Div(id="output"),
+ ]
+ )
+
+ call_count = Value("i", 0)
+
+ @app.callback(
+ Output("output", "children"),
+ [Input("input", "value"), Input("button", "n_clicks")],
+ [State("state", "value")],
+ )
+ def update_output(input, n_clicks, state):
+ call_count.value += 1
+ return 'input="{}", state="{}"'.format(input, state)
+
+ dash_duo.start_server(app)
+
+ btn = lambda: dash_duo.find_element("#button")
+ output = lambda: dash_duo.find_element("#output")
+
+ assert (
+ output().text == 'input="Initial Input", state="Initial State"'
+ ), "callback gets called with initial input"
+
+ btn().click()
+ wait.until(lambda: call_count.value == 2, timeout=1)
+ assert (
+ output().text == 'input="Initial Input", state="Initial State"'
+ ), "button click doesn't count on output"
+
+ dash_duo.find_element("#input").send_keys("x")
+ wait.until(lambda: call_count.value == 3, timeout=1)
+
+ assert (
+ output().text == 'input="Initial Inputx", state="Initial State"'
+ ), "output get updated with key `x`"
+
+ dash_duo.find_element("#state").send_keys("z")
+ time.sleep(0.5)
+ assert call_count.value == 3, "state not trigger callback with 0.5 wait"
+ assert (
+ output().text == 'input="Initial Inputx", state="Initial State"'
+ ), "output remains the same as last step"
+
+ btn().click()
+ wait.until(lambda: call_count.value == 4, timeout=1)
+ assert output().text == 'input="Initial Inputx", state="Initial Statez"'
diff --git a/tests/integration/test_devtools.py b/tests/integration/test_devtools.py
deleted file mode 100644
index bc49127d66..0000000000
--- a/tests/integration/test_devtools.py
+++ /dev/null
@@ -1,689 +0,0 @@
-# -*- coding: UTF-8 -*-
-import os
-import textwrap
-
-import dash
-from dash import Dash
-from dash.dependencies import Input, Output, State, ClientsideFunction
-from dash.exceptions import PreventUpdate
-from dash.development.base_component import Component
-import dash_html_components as html
-import dash_core_components as dcc
-import dash_renderer_test_components
-
-from bs4 import BeautifulSoup
-from selenium.webdriver.common.action_chains import ActionChains
-from selenium.webdriver.common.keys import Keys
-from selenium.webdriver.common.by import By
-from selenium.webdriver.support.ui import WebDriverWait
-from selenium.webdriver.support import expected_conditions as EC
-
-from .IntegrationTests import IntegrationTests
-from .utils import wait_for
-from multiprocessing import Value
-import time
-import re
-import itertools
-import json
-import string
-import plotly
-import requests
-import pytest
-
-
-TIMEOUT = 20
-
-
-@pytest.mark.skip(
- reason="flakey with circleci, will readdressing after pytest fixture")
-class Tests(IntegrationTests):
- def setUp(self):
- pass
-
- def wait_for_style_to_equal(self, selector, style, assertion_style, timeout=TIMEOUT):
- start = time.time()
- exception = Exception('Time ran out, {} on {} not found'.format(
- assertion_style, selector))
- while time.time() < start + timeout:
- element = self.wait_for_element_by_css_selector(selector)
- try:
- self.assertEqual(
- assertion_style, element.value_of_css_property(style))
- except Exception as e:
- exception = e
- else:
- return
- time.sleep(0.1)
-
- raise exception
-
- def wait_for_element_by_css_selector(self, selector, timeout=TIMEOUT):
- return WebDriverWait(self.driver, timeout).until(
- EC.presence_of_element_located((By.CSS_SELECTOR, selector)),
- 'Could not find element with selector "{}"'.format(selector)
- )
-
- def wait_for_text_to_equal(self, selector, assertion_text, timeout=TIMEOUT):
- self.wait_for_element_by_css_selector(selector)
- WebDriverWait(self.driver, timeout).until(
- lambda *args: (
- (str(self.wait_for_element_by_css_selector(selector).text)
- == assertion_text) or
- (str(self.wait_for_element_by_css_selector(
- selector).get_attribute('value')) == assertion_text)
- ),
- "Element '{}' text expects to equal '{}' but it didn't".format(
- selector,
- assertion_text
- )
- )
-
- def clear_input(self, input_element):
- (
- ActionChains(self.driver)
- .click(input_element)
- .send_keys(Keys.HOME)
- .key_down(Keys.SHIFT)
- .send_keys(Keys.END)
- .key_up(Keys.SHIFT)
- .send_keys(Keys.DELETE)
- ).perform()
-
- def request_queue_assertions(
- self, check_rejected=True, expected_length=None):
- request_queue = self.driver.execute_script(
- 'return window.store.getState().requestQueue'
- )
- self.assertTrue(
- all([
- (r['status'] == 200)
- for r in request_queue
- ])
- )
-
- if check_rejected:
- self.assertTrue(
- all([
- (r['rejected'] is False)
- for r in request_queue
- ])
- )
-
- if expected_length is not None:
- self.assertEqual(len(request_queue), expected_length)
-
- def test_devtools_python_errors(self):
- app = dash.Dash(__name__)
-
- app.layout = html.Div([
- html.Button(id='python', children='Python exception', n_clicks=0),
- html.Div(id='output')
- ])
-
- @app.callback(
- Output('output', 'children'),
- [Input('python', 'n_clicks')])
- def update_output(n_clicks):
- if n_clicks == 1:
- 1 / 0
- elif n_clicks == 2:
- raise Exception('Special 2 clicks exception')
-
- self.startServer(
- app,
- debug=True,
- use_reloader=False,
- use_debugger=True,
- dev_tools_hot_reload=False,
- )
-
- self.percy_snapshot('devtools - python exception - start')
-
- self.wait_for_element_by_css_selector('#python').click()
- self.wait_for_text_to_equal('.test-devtools-error-count', '1')
- self.percy_snapshot('devtools - python exception - closed')
- self.wait_for_element_by_css_selector('.test-devtools-error-toggle').click()
- self.percy_snapshot('devtools - python exception - open')
- self.wait_for_element_by_css_selector('.test-devtools-error-toggle').click()
-
- self.wait_for_element_by_css_selector('#python').click()
- self.wait_for_text_to_equal('.test-devtools-error-count', '2')
- self.percy_snapshot('devtools - python exception - 2 errors')
- self.wait_for_element_by_css_selector('.test-devtools-error-toggle').click()
- self.percy_snapshot('devtools - python exception - 2 errors open')
-
- def test_devtools_prevent_update(self):
- # raising PreventUpdate shouldn't display the error message
- app = dash.Dash(__name__)
-
- app.layout = html.Div([
- html.Button(id='python', children='Prevent update', n_clicks=0),
- html.Div(id='output')
- ])
-
- @app.callback(
- Output('output', 'children'),
- [Input('python', 'n_clicks')])
- def update_output(n_clicks):
- if n_clicks == 1:
- raise PreventUpdate
- if n_clicks == 2:
- raise Exception('An actual python exception')
-
- return 'button clicks: {}'.format(n_clicks)
-
- self.startServer(
- app,
- debug=True,
- use_reloader=False,
- use_debugger=True,
- dev_tools_hot_reload=False,
- )
-
- self.wait_for_element_by_css_selector('#python').click()
- self.wait_for_element_by_css_selector('#python').click()
- self.wait_for_element_by_css_selector('#python').click()
- self.wait_for_text_to_equal('#output', 'button clicks: 3')
-
- # two exceptions fired, but only a single exception appeared in the UI:
- # the prevent default was not displayed
- self.wait_for_text_to_equal('.test-devtools-error-count', '1')
- self.percy_snapshot('devtools - prevent update - only a single exception')
-
- def test_devtools_validation_errors_in_place(self):
- app = dash.Dash(__name__)
-
- app.layout = html.Div([
- html.Button(id='button', children='update-graph', n_clicks=0),
- dcc.Graph(id='output', figure={'data': [{'y': [3, 1, 2]}]})
- ])
-
- # animate is a bool property
- @app.callback(
- Output('output', 'animate'),
- [Input('button', 'n_clicks')])
- def update_output(n_clicks):
- if n_clicks == 1:
- return n_clicks
-
- self.startServer(
- app,
- debug=True,
- use_reloader=False,
- use_debugger=True,
- dev_tools_hot_reload=False,
- )
-
- self.wait_for_element_by_css_selector('#button').click()
- self.wait_for_text_to_equal('.test-devtools-error-count', '1')
- self.percy_snapshot('devtools - validation exception - closed')
- self.wait_for_element_by_css_selector('.test-devtools-error-toggle').click()
- self.percy_snapshot('devtools - validation exception - open')
-
- def test_dev_tools_disable_props_check_config(self):
- app = dash.Dash(__name__)
- app.layout = html.Div([
- html.P(id='tcid', children='Hello Props Check'),
- dcc.Graph(id='broken', animate=3), # error ignored by disable
- ])
-
- self.startServer(
- app,
- debug=True,
- use_reloader=False,
- use_debugger=True,
- dev_tools_hot_reload=False,
- dev_tools_props_check=False
- )
-
- self.wait_for_text_to_equal('#tcid', "Hello Props Check")
- self.assertTrue(
- self.driver.find_elements_by_css_selector('#broken svg.main-svg'),
- "graph should be rendered")
- self.assertTrue(
- self.driver.find_elements_by_css_selector('.dash-debug-menu'),
- "the debug menu icon should show up")
-
- self.percy_snapshot('devtools - disable props check - Graph should render')
-
- def test_dev_tools_disable_ui_config(self):
- app = dash.Dash(__name__)
- app.layout = html.Div([
- html.P(id='tcid', children='Hello Disable UI'),
- dcc.Graph(id='broken', animate=3), # error ignored by disable
- ])
-
- self.startServer(
- app,
- debug=True,
- use_reloader=False,
- use_debugger=True,
- dev_tools_hot_reload=False,
- dev_tools_ui=False
- )
-
- self.wait_for_text_to_equal('#tcid', "Hello Disable UI")
- logs = self.wait_until_get_log()
- self.assertIn(
- 'Invalid argument `animate` passed into Graph', str(logs),
- "the error should present in the console without DEV tools UI")
-
- self.assertFalse(
- self.driver.find_elements_by_css_selector('.dash-debug-menu'),
- "the debug menu icon should NOT show up")
-
- self.percy_snapshot('devtools - disable dev tools UI - no debug menu')
-
- def test_devtools_validation_errors_creation(self):
- app = dash.Dash(__name__)
-
- app.layout = html.Div([
- html.Button(id='button', children='update-graph', n_clicks=0),
- html.Div(id='output')
- ])
-
- # animate is a bool property
- @app.callback(
- Output('output', 'children'),
- [Input('button', 'n_clicks')])
- def update_output(n_clicks):
- if n_clicks == 1:
- return dcc.Graph(
- id='output',
- animate=0,
- figure={'data': [{'y': [3, 1, 2]}]}
- )
-
- self.startServer(
- app,
- debug=True,
- use_reloader=False,
- use_debugger=True,
- dev_tools_hot_reload=False,
- )
-
- self.wait_for_element_by_css_selector('#button').click()
- self.wait_for_text_to_equal('.test-devtools-error-count', '1')
- self.percy_snapshot('devtools - validation creation exception - closed')
- self.wait_for_element_by_css_selector('.test-devtools-error-toggle').click()
- self.percy_snapshot('devtools - validation creation exception - open')
-
- def test_devtools_multiple_outputs(self):
- app = dash.Dash(__name__)
- app.layout = html.Div([
- html.Button(
- id='multi-output',
- children='trigger multi output update',
- n_clicks=0
- ),
- html.Div(id='multi-1'),
- html.Div(id='multi-2'),
- ])
-
- @app.callback(
- [Output('multi-1', 'children'), Output('multi-2', 'children')],
- [Input('multi-output', 'n_clicks')])
- def update_outputs(n_clicks):
- if n_clicks == 0:
- return [
- 'Output 1 - {} Clicks'.format(n_clicks),
- 'Output 2 - {} Clicks'.format(n_clicks),
- ]
- else:
- n_clicks / 0
-
- self.startServer(
- app,
- debug=True,
- use_reloader=False,
- use_debugger=True,
- dev_tools_hot_reload=False,
- )
-
- self.wait_for_element_by_css_selector('#multi-output').click()
- self.wait_for_text_to_equal('.test-devtools-error-count', '1')
- self.percy_snapshot('devtools - multi output python exception - closed')
- self.wait_for_element_by_css_selector('.test-devtools-error-toggle').click()
- self.percy_snapshot('devtools - multi output python exception - open')
-
- def test_devtools_validation_errors(self):
- app = dash.Dash(__name__)
-
- test_cases = {
- 'not-boolean': {
- 'fail': True,
- 'name': 'simple "not a boolean" check',
- 'component': dcc.Graph,
- 'props': {
- 'animate': 0
- }
- },
-
- 'missing-required-nested-prop': {
- 'fail': True,
- 'name': 'missing required "value" inside options',
- 'component': dcc.Checklist,
- 'props': {
- 'options': [{
- 'label': 'hello'
- }],
- 'values': ['test']
- }
- },
-
- 'invalid-nested-prop': {
- 'fail': True,
- 'name': 'invalid nested prop',
- 'component': dcc.Checklist,
- 'props': {
- 'options': [{
- 'label': 'hello',
- 'value': True
- }],
- 'values': ['test']
- }
- },
-
- 'invalid-arrayOf': {
- 'fail': True,
- 'name': 'invalid arrayOf',
- 'component': dcc.Checklist,
- 'props': {
- 'options': 'test',
- 'values': []
- }
- },
-
- 'invalid-oneOf': {
- 'fail': True,
- 'name': 'invalid oneOf',
- 'component': dcc.Input,
- 'props': {
- 'type': 'test',
- }
- },
-
- 'invalid-oneOfType': {
- 'fail': True,
- 'name': 'invalid oneOfType',
- 'component': dcc.Input,
- 'props': {
- 'max': True,
- }
- },
-
- 'invalid-shape-1': {
- 'fail': True,
- 'name': 'invalid key within nested object',
- 'component': dcc.Graph,
- 'props': {
- 'config': {
- 'asdf': 'that'
- }
- }
- },
-
- 'invalid-shape-2': {
- 'fail': True,
- 'name': 'nested object with bad value',
- 'component': dcc.Graph,
- 'props': {
- 'config': {
- 'edits': {
- 'legendPosition': 'asdf'
- }
- }
- }
- },
-
- 'invalid-shape-3': {
- 'fail': True,
- 'name': 'invalid oneOf within nested object',
- 'component': dcc.Graph,
- 'props': {
- 'config': {
- 'toImageButtonOptions': {
- 'format': 'asdf'
- }
- }
- }
- },
-
- 'invalid-shape-4': {
- 'fail': True,
- 'name': 'invalid key within deeply nested object',
- 'component': dcc.Graph,
- 'props': {
- 'config': {
- 'toImageButtonOptions': {
- 'asdf': 'test'
- }
- }
- }
- },
-
- 'invalid-shape-5': {
- 'fail': True,
- 'name': 'invalid not required key',
- 'component': dcc.Dropdown,
- 'props': {
- 'options': [
- {
- 'label': 'new york',
- 'value': 'ny',
- 'typo': 'asdf'
- }
- ]
- }
- },
-
- 'string-not-list': {
- 'fail': True,
- 'name': 'string-not-a-list',
- 'component': dcc.Checklist,
- 'props': {
- 'options': [{
- 'label': 'hello',
- 'value': 'test'
- }],
- 'values': 'test'
- }
- },
-
- 'no-properties': {
- 'fail': False,
- 'name': 'no properties',
- 'component': dcc.Graph,
- 'props': {}
- },
-
- 'nested-children': {
- 'fail': True,
- 'name': 'nested children',
- 'component': html.Div,
- 'props': {'children': [[1]]}
- },
-
- 'deeply-nested-children': {
- 'fail': True,
- 'name': 'deeply nested children',
- 'component': html.Div,
- 'props': {'children': html.Div([
- html.Div([
- 3,
- html.Div([[10]])
- ])
- ])}
- },
-
- 'dict': {
- 'fail': True,
- 'name': 'returning a dictionary',
- 'component': html.Div,
- 'props': {
- 'children': {'hello': 'world'}
- }
- },
-
- 'nested-prop-failure': {
- 'fail': True,
- 'name': 'nested string instead of number/null',
- 'component': dcc.Graph,
- 'props': {
- 'figure': {'data': [{}]},
- 'config': {
- 'toImageButtonOptions': {
- 'width': None,
- 'height': 'test'
- }
- }
- }
- },
-
- 'allow-null': {
- 'fail': False,
- 'name': 'nested null',
- 'component': dcc.Graph,
- 'props': {
- 'figure': {'data': [{}]},
- 'config': {
- 'toImageButtonOptions': {
- 'width': None,
- 'height': None
- }
- }
- }
- },
-
- 'allow-null-2': {
- 'fail': False,
- 'name': 'allow null as value',
- 'component': dcc.Dropdown,
- 'props': {
- 'value': None
- }
- },
-
- 'allow-null-3': {
- 'fail': False,
- 'name': 'allow null in properties',
- 'component': dcc.Input,
- 'props': {
- 'value': None
- }
- },
-
- 'allow-null-4': {
- 'fail': False,
- 'name': 'allow null in oneOfType',
- 'component': dcc.Store,
- 'props': {
- 'id': 'store',
- 'data': None
- }
- },
-
- 'long-property-string': {
- 'fail': True,
- 'name': 'long property string with id',
- 'component': html.Div,
- 'props': {
- 'id': 'pink div',
- 'style': 'color: hotpink; ' * 1000
- }
- },
-
- 'multiple-wrong-values': {
- 'fail': True,
- 'name': 'multiple wrong props',
- 'component': dcc.Dropdown,
- 'props': {
- 'id': 'dropdown',
- 'value': 10,
- 'options': 'asdf',
- }
- },
-
- 'boolean-html-properties': {
- 'fail': True,
- 'name': 'dont allow booleans for dom props',
- 'component': html.Div,
- 'props': {
- 'contentEditable': True
- }
- },
-
- 'allow-exact-with-optional-and-required-1': {
- 'fail': False,
- 'name': 'allow exact with optional and required keys',
- 'component': dcc.Dropdown,
- 'props': {
- 'options': [{
- 'label': 'new york',
- 'value': 'ny',
- 'disabled': False
- }]
- }
- },
-
- 'allow-exact-with-optional-and-required-2': {
- 'fail': False,
- 'name': 'allow exact with optional and required keys 2',
- 'component': dcc.Dropdown,
- 'props': {
- 'options': [{
- 'label': 'new york',
- 'value': 'ny'
- }]
- }
- }
-
- }
-
- app.layout = html.Div([
- html.Div(id='content'),
- dcc.Location(id='location'),
- ])
-
- @app.callback(
- Output('content', 'children'),
- [Input('location', 'pathname')])
- def display_content(pathname):
- if pathname is None or pathname == '/':
- return 'Initial state'
- test_case = test_cases[pathname.strip('/')]
- return html.Div(
- id='new-component',
- children=test_case['component'](**test_case['props'])
- )
-
- self.startServer(
- app,
- debug=True,
- use_reloader=False,
- use_debugger=True,
- dev_tools_hot_reload=False,
- )
-
- for test_case_id in test_cases:
- self.driver.get('http://localhost:8050/{}'.format(test_case_id))
- if test_cases[test_case_id]['fail']:
- try:
- self.wait_for_element_by_css_selector('.test-devtools-error-toggle').click()
- except Exception as e:
- raise Exception('Error popup not shown for {}'.format(test_case_id))
- self.percy_snapshot(
- 'devtools validation exception: {}'.format(
- test_cases[test_case_id]['name']
- )
- )
- else:
- try:
- self.wait_for_element_by_css_selector('#new-component')
- except Exception as e:
- raise Exception('Component not rendered in {}'.format(test_case_id))
- self.percy_snapshot(
- 'devtools validation no exception: {}'.format(
- test_cases[test_case_id]['name']
- )
- )
diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py
index bfcc1e5b2e..4e6f57bb8d 100644
--- a/tests/integration/test_integration.py
+++ b/tests/integration/test_integration.py
@@ -1,4 +1,3 @@
-import json
from multiprocessing import Value
import datetime
import itertools
diff --git a/tests/integration/test_render.py b/tests/integration/test_render.py
index d087ed12cd..88775a9ebd 100644
--- a/tests/integration/test_render.py
+++ b/tests/integration/test_render.py
@@ -1,10 +1,7 @@
# -*- coding: UTF-8 -*-
-import os
-import textwrap
-
import dash
from dash import Dash
-from dash.dependencies import Input, Output, State, ClientsideFunction
+from dash.dependencies import Input, Output
from dash.exceptions import PreventUpdate
from dash.development.base_component import Component
import dash_html_components as html
@@ -22,8 +19,6 @@
from .utils import wait_for
from multiprocessing import Value
import time
-import re
-import itertools
import json
import string
import plotly
@@ -37,23 +32,6 @@ class Tests(IntegrationTests):
def setUp(self):
pass
- def wait_for_style_to_equal(self, selector, style, assertion_style, timeout=TIMEOUT):
- start = time.time()
- exception = Exception('Time ran out, {} on {} not found'.format(
- assertion_style, selector))
- while time.time() < start + timeout:
- element = self.wait_for_element_by_css_selector(selector)
- try:
- self.assertEqual(
- assertion_style, element.value_of_css_property(style))
- except Exception as e:
- exception = e
- else:
- return
- time.sleep(0.1)
-
- raise exception
-
def wait_for_element_by_css_selector(self, selector, timeout=TIMEOUT):
return WebDriverWait(self.driver, timeout).until(
EC.presence_of_element_located((By.CSS_SELECTOR, selector)),
@@ -75,17 +53,6 @@ def wait_for_text_to_equal(self, selector, assertion_text, timeout=TIMEOUT):
)
)
- def clear_input(self, input_element):
- (
- ActionChains(self.driver)
- .click(input_element)
- .send_keys(Keys.HOME)
- .key_down(Keys.SHIFT)
- .send_keys(Keys.END)
- .key_up(Keys.SHIFT)
- .send_keys(Keys.DELETE)
- ).perform()
-
def request_queue_assertions(
self, check_rejected=True, expected_length=None):
request_queue = self.driver.execute_script(
@@ -109,109 +76,6 @@ def request_queue_assertions(
if expected_length is not None:
self.assertEqual(len(request_queue), expected_length)
- def test_initial_state(self):
- app = Dash(__name__)
- my_class_attrs = {
- 'id': 'p.c.4',
- 'className': 'my-class',
- 'title': 'tooltip',
- 'style': {'color': 'red', 'fontSize': 30},
- }
- app.layout = html.Div([
- 'Basic string',
- 3.14,
- True,
- None,
- html.Div('Child div with basic string', **my_class_attrs),
- html.Div(id='p.c.5'),
- html.Div([
- html.Div('Grandchild div', id='p.c.6.p.c.0'),
- html.Div([
- html.Div('Great grandchild', id='p.c.6.p.c.1.p.c.0'),
- 3.14159,
- 'another basic string'
- ], id='p.c.6.p.c.1'),
- html.Div([
- html.Div(
- html.Div([
- html.Div([
- html.Div(
- id='p.c.6.p.c.2.p.c.0.p.c.p.c.0.p.c.0'
- ),
- '',
- html.Div(
- id='p.c.6.p.c.2.p.c.0.p.c.p.c.0.p.c.2'
- )
- ], id='p.c.6.p.c.2.p.c.0.p.c.p.c.0')
- ], id='p.c.6.p.c.2.p.c.0.p.c'),
- id='p.c.6.p.c.2.p.c.0'
- )
- ], id='p.c.6.p.c.2')
- ], id='p.c.6')
- ])
-
- self.startServer(app)
- el = self.wait_for_element_by_css_selector('#react-entry-point')
-
- # Note: this .html file shows there's no undo/redo button by default
- _dash_app_content_html = os.path.join(
- os.path.dirname(__file__),
- 'test_assets', 'initial_state_dash_app_content.html')
- with open(_dash_app_content_html) as fp:
- rendered_dom = BeautifulSoup(fp.read().strip(), 'lxml')
- fetched_dom = BeautifulSoup(el.get_attribute('outerHTML'), 'lxml')
-
- self.assertEqual(
- fetched_dom.decode(), rendered_dom.decode(),
- "the fetching rendered dom is expected ")
-
- # Check that no errors or warnings were displayed
- self.assertTrue(self.is_console_clean())
-
- self.assertEqual(
- self.driver.execute_script(
- 'return JSON.parse(JSON.stringify('
- 'window.store.getState().layout'
- '))'
- ),
- json.loads(
- json.dumps(app.layout, cls=plotly.utils.PlotlyJSONEncoder)),
- "the state layout is identical to app.layout"
- )
-
- r = requests.get('http://localhost:8050/_dash-dependencies')
- self.assertEqual(r.status_code, 200)
- self.assertEqual(
- r.json(), [],
- "no dependencies present in app as no callbacks are defined"
-
- )
-
- self.assertEqual(
- self.driver.execute_script(
- 'return window.store.getState().paths'
- ),
- {
- abbr: [
- int(token) if token in string.digits
- else token.replace('p', 'props').replace('c', 'children')
- for token in abbr.split('.')
- ]
- for abbr in (
- child.get('id')
- for child in fetched_dom.find(
- id='react-entry-point').findChildren(id=True)
- )
- },
- "paths should refect to the component hierarchy"
- )
-
- self.request_queue_assertions(0)
-
- self.percy_snapshot(name='layout')
-
- self.assertTrue(self.is_console_clean())
-
def click_undo(self):
undo_selector = '._dash-undo-redo span:first-child div:last-child'
undo = self.wait_for_element_by_css_selector(undo_selector)
@@ -305,153 +169,6 @@ def test_of_falsy_child(self):
self.assertTrue(self.is_console_clean())
- def test_simple_callback(self):
- app = Dash(__name__)
- app.layout = html.Div([
- dcc.Input(
- id='input',
- value='initial value'
- ),
- html.Div(
- html.Div([
- 1.5,
- None,
- 'string',
- html.Div(id='output-1')
- ])
- )
- ])
-
- call_count = Value('i', 0)
-
- @app.callback(Output('output-1', 'children'), [Input('input', 'value')])
- def update_output(value):
- call_count.value = call_count.value + 1
- return value
-
- self.startServer(app)
-
- self.wait_for_text_to_equal('#output-1', 'initial value')
- self.percy_snapshot(name='simple-callback-1')
-
- input1 = self.wait_for_element_by_css_selector('#input')
- self.clear_input(input1)
-
- input1.send_keys('hello world')
-
- self.wait_for_text_to_equal('#output-1', 'hello world')
- self.percy_snapshot(name='simple-callback-2')
-
- self.assertEqual(
- call_count.value,
- # an initial call to retrieve the first value + clear is now one
- 2 +
- # one for each hello world character
- len('hello world')
- )
-
- self.request_queue_assertions(
- expected_length=1,
- check_rejected=False)
-
- self.assertTrue(self.is_console_clean())
-
- def test_callbacks_generating_children(self):
- ''' Modify the DOM tree by adding new
- components in the callbacks
- '''
-
- app = Dash(__name__)
- app.layout = html.Div([
- dcc.Input(
- id='input',
- value='initial value'
- ),
- html.Div(id='output')
- ])
-
- @app.callback(Output('output', 'children'), [Input('input', 'value')])
- def pad_output(input):
- return html.Div([
- dcc.Input(
- id='sub-input-1',
- value='sub input initial value'
- ),
- html.Div(id='sub-output-1')
- ])
-
- call_count = Value('i', 0)
-
- # these components don't exist in the initial render
- app.config.supress_callback_exceptions = True
-
- @app.callback(
- Output('sub-output-1', 'children'),
- [Input('sub-input-1', 'value')]
- )
- def update_input(value):
- call_count.value = call_count.value + 1
- return value
-
- self.startServer(app)
-
- wait_for(lambda: call_count.value == 1)
-
- pad_input, pad_div = BeautifulSoup(
- self.driver.find_element_by_css_selector(
- '#react-entry-point').get_attribute('innerHTML'),
- 'lxml').select_one('#output > div').contents
-
- self.assertEqual(pad_input.attrs['value'], 'sub input initial value')
- self.assertEqual(pad_input.attrs['id'], 'sub-input-1')
- self.assertEqual(pad_input.name, 'input')
-
- self.assertTrue(
- pad_div.text == pad_input.attrs['value'] and
- pad_div.get('id') == 'sub-output-1',
- "the sub-output-1 content reflects to sub-input-1 value"
- )
-
- self.percy_snapshot(name='callback-generating-function-1')
-
- # the paths should include these new output IDs
- self.assertEqual(
- self.driver.execute_script('return window.store.getState().paths'),
- {
- 'input': [
- 'props', 'children', 0
- ],
- 'output': ['props', 'children', 1],
- 'sub-input-1': [
- 'props', 'children', 1,
- 'props', 'children',
- 'props', 'children', 0
- ],
- 'sub-output-1': [
- 'props', 'children', 1,
- 'props', 'children',
- 'props', 'children', 1
- ]
- }
- )
-
- # editing the input should modify the sub output
- sub_input = self.driver.find_element_by_id('sub-input-1')
-
- sub_input.send_keys('deadbeef')
- self.wait_for_text_to_equal(
- '#sub-output-1',
- pad_input.attrs['value'] + 'deadbeef')
-
- self.assertEqual(
- call_count.value, len('deadbeef') + 1,
- "the total updates is initial one + the text input changes")
-
- self.request_queue_assertions(call_count.value + 1)
- self.percy_snapshot(name='callback-generating-function-2')
-
- self.assertTrue(self.is_console_clean())
-
def test_radio_buttons_callbacks_generating_children(self):
self.maxDiff = 100 * 1000
app = Dash(__name__)
@@ -762,54 +479,6 @@ def chapter3_assertions():
chapter1_assertions()
self.percy_snapshot(name='chapter-1-again')
- def test_dependencies_on_components_that_dont_exist(self):
- app = Dash(__name__)
- app.layout = html.Div([
- dcc.Input(id='input', value='initial value'),
- html.Div(id='output-1')
- ])
-
- # standard callback
- output_1_call_count = Value('i', 0)
-
- @app.callback(Output('output-1', 'children'), [Input('input', 'value')])
- def update_output(value):
- output_1_call_count.value += 1
- return value
-
- # callback for component that doesn't yet exist in the dom
- # in practice, it might get added by some other callback
- app.config.supress_callback_exceptions = True
- output_2_call_count = Value('i', 0)
-
- @app.callback(
- Output('output-2', 'children'),
- [Input('input', 'value')]
- )
- def update_output_2(value):
- output_2_call_count.value += 1
- return value
-
- self.startServer(app)
-
- self.wait_for_text_to_equal('#output-1', 'initial value')
- self.percy_snapshot(name='dependencies')
- time.sleep(1.0)
- self.assertEqual(output_1_call_count.value, 1)
- self.assertEqual(output_2_call_count.value, 0)
-
- input = self.driver.find_element_by_id('input')
-
- input.send_keys('a')
- self.wait_for_text_to_equal('#output-1', 'initial valuea')
- time.sleep(1.0)
- self.assertEqual(output_1_call_count.value, 2)
- self.assertEqual(output_2_call_count.value, 0)
-
- self.request_queue_assertions(2)
-
- self.assertTrue(self.is_console_clean())
-
def test_event_properties(self):
app = Dash(__name__)
app.layout = html.Div([
@@ -837,203 +506,6 @@ def update_output(n_clicks):
wait_for(lambda: output().text == 'Click')
self.assertEqual(call_count.value, 1)
- def test_event_properties_and_state(self):
- app = Dash(__name__)
- app.layout = html.Div([
- html.Button('Click Me', id='button'),
- dcc.Input(value='Initial State', id='state'),
- html.Div(id='output')
- ])
-
- call_count = Value('i', 0)
-
- @app.callback(Output('output', 'children'),
- [Input('button', 'n_clicks')],
- [State('state', 'value')])
- def update_output(n_clicks, value):
- if(not n_clicks):
- raise PreventUpdate
- call_count.value += 1
- return value
-
- self.startServer(app)
- btn = self.driver.find_element_by_id('button')
- output = lambda: self.driver.find_element_by_id('output')
-
- self.assertEqual(call_count.value, 0)
- self.assertEqual(output().text, '')
-
- btn.click()
- wait_for(lambda: output().text == 'Initial State')
- self.assertEqual(call_count.value, 1)
-
- # Changing state shouldn't fire the callback
- state = self.driver.find_element_by_id('state')
- state.send_keys('x')
- time.sleep(0.75)
- self.assertEqual(output().text, 'Initial State')
- self.assertEqual(call_count.value, 1)
-
- btn.click()
- wait_for(lambda: output().text == 'Initial Statex')
- self.assertEqual(call_count.value, 2)
-
- def test_event_properties_state_and_inputs(self):
- app = Dash(__name__)
- app.layout = html.Div([
- html.Button('Click Me', id='button'),
- dcc.Input(value='Initial Input', id='input'),
- dcc.Input(value='Initial State', id='state'),
- html.Div(id='output')
- ])
-
- call_count = Value('i', 0)
-
- @app.callback(Output('output', 'children'),
- [Input('input', 'value'), Input('button', 'n_clicks')],
- [State('state', 'value')])
- def update_output(input, n_clicks, state):
- call_count.value += 1
- return 'input="{}", state="{}"'.format(input, state)
-
- self.startServer(app)
- btn = lambda: self.driver.find_element_by_id('button')
- output = lambda: self.driver.find_element_by_id('output')
- input = lambda: self.driver.find_element_by_id('input')
- state = lambda: self.driver.find_element_by_id('state')
-
- # callback gets called with initial input
- self.assertEqual(
- output().text,
- 'input="Initial Input", state="Initial State"'
- )
-
- btn().click()
- wait_for(lambda: call_count.value == 2)
- self.assertEqual(
- output().text,
- 'input="Initial Input", state="Initial State"')
-
- input().send_keys('x')
- wait_for(lambda: call_count.value == 3)
- self.assertEqual(
- output().text,
- 'input="Initial Inputx", state="Initial State"')
-
- state().send_keys('x')
- time.sleep(0.75)
- self.assertEqual(call_count.value, 3)
- self.assertEqual(
- output().text,
- 'input="Initial Inputx", state="Initial State"')
-
- btn().click()
- wait_for(lambda: call_count.value == 4)
- self.assertEqual(
- output().text,
- 'input="Initial Inputx", state="Initial Statex"')
-
- def test_state_and_inputs(self):
- app = Dash(__name__)
- app.layout = html.Div([
- dcc.Input(value='Initial Input', id='input'),
- dcc.Input(value='Initial State', id='state'),
- html.Div(id='output')
- ])
-
- call_count = Value('i', 0)
-
- @app.callback(
- Output('output', 'children'), [Input('input', 'value')],
- [State('state', 'value')])
- def update_output(input, state):
- call_count.value += 1
- return 'input="{}", state="{}"'.format(input, state)
-
- self.startServer(app)
- output = lambda: self.driver.find_element_by_id('output')
- input = lambda: self.driver.find_element_by_id('input')
- state = lambda: self.driver.find_element_by_id('state')
-
- # callback gets called with initial input
- self.assertEqual(
- output().text,
- 'input="Initial Input", state="Initial State"'
- )
-
- input().send_keys('x')
- wait_for(lambda: call_count.value == 2)
- self.assertEqual(
- output().text,
- 'input="Initial Inputx", state="Initial State"')
-
- state().send_keys('x')
- time.sleep(0.75)
- self.assertEqual(call_count.value, 2)
- self.assertEqual(
- output().text,
- 'input="Initial Inputx", state="Initial State"')
-
- input().send_keys('y')
- wait_for(lambda: call_count.value == 3)
- self.assertEqual(
- output().text,
- 'input="Initial Inputxy", state="Initial Statex"')
-
- def test_event_properties_creating_inputs(self):
- app = Dash(__name__)
-
- ids = {
- k: k for k in ['button', 'button-output', 'input', 'input-output']
- }
- app.layout = html.Div([
- html.Button(id=ids['button']),
- html.Div(id=ids['button-output'])
- ])
- for script in dcc._js_dist:
- script['namespace'] = 'dash_core_components'
- app.scripts.append_script(script)
-
- app.config.supress_callback_exceptions = True
- call_counts = {
- ids['input-output']: Value('i', 0),
- ids['button-output']: Value('i', 0)
- }
-
- @app.callback(
- Output(ids['button-output'], 'children'),
- [Input(ids['button'], 'n_clicks')])
- def display(n_clicks):
- if(not n_clicks):
- raise PreventUpdate
- call_counts['button-output'].value += 1
- return html.Div([
- dcc.Input(id=ids['input'], value='initial state'),
- html.Div(id=ids['input-output'])
- ])
-
- @app.callback(
- Output(ids['input-output'], 'children'),
- [Input(ids['input'], 'value')])
- def update_input(value):
- call_counts['input-output'].value += 1
- return 'Input is equal to "{}"'.format(value)
-
- self.startServer(app)
- time.sleep(1)
- self.assertEqual(call_counts[ids['button-output']].value, 0)
- self.assertEqual(call_counts[ids['input-output']].value, 0)
-
- btn = lambda: self.driver.find_element_by_id(ids['button'])
- output = lambda: self.driver.find_element_by_id(ids['input-output'])
- with self.assertRaises(Exception):
- output()
-
- btn().click()
- wait_for(lambda: call_counts[ids['input-output']].value == 1)
- self.assertEqual(call_counts[ids['button-output']].value, 1)
- self.assertEqual(output().text, 'Input is equal to "initial state"')
-
def test_chained_dependencies_direct_lineage(self):
app = Dash(__name__)
app.layout = html.Div([
@@ -1323,43 +795,6 @@ def dynamic_output(*args):
self.assertEqual(call_count.value, 1)
- def test_callbacks_called_multiple_times_and_out_of_order(self):
- app = Dash(__name__)
- app.layout = html.Div([
- html.Button(id='input', n_clicks=0),
- html.Div(id='output')
- ])
-
- call_count = Value('i', 0)
-
- @app.callback(
- Output('output', 'children'),
- [Input('input', 'n_clicks')])
- def update_output(n_clicks):
- call_count.value = call_count.value + 1
- if n_clicks == 1:
- time.sleep(4)
- return n_clicks
-
- self.startServer(app)
- button = self.wait_for_element_by_css_selector('#input')
- button.click()
- button.click()
- time.sleep(8)
- self.percy_snapshot(
- name='test_callbacks_called_multiple_times_and_out_of_order'
- )
- self.assertEqual(call_count.value, 3)
- self.assertEqual(
- self.driver.find_element_by_id('output').text,
- '2'
- )
- request_queue = self.driver.execute_script(
- 'return window.store.getState().requestQueue'
- )
- self.assertFalse(request_queue[0]['rejected'])
- self.assertEqual(len(request_queue), 1)
-
def test_callbacks_called_multiple_times_and_out_of_order_multi_output(self):
app = Dash(__name__)
app.layout = html.Div([
@@ -1913,45 +1348,6 @@ def render_content(tab):
self.wait_for_text_to_equal('#graph2_info', json.dumps(graph_2_expected_clickdata))
- def test_hot_reload(self):
- app = dash.Dash(__name__, assets_folder='test_assets')
-
- app.layout = html.Div([
- html.H3('Hot reload')
- ], id='hot-reload-content')
-
- self.startServer(
- app,
- dev_tools_hot_reload=True,
- dev_tools_hot_reload_interval=100,
- dev_tools_hot_reload_max_retry=30,
- )
-
- hot_reload_file = os.path.join(
- os.path.dirname(__file__), 'test_assets', 'hot_reload.css')
-
- self.wait_for_style_to_equal(
- '#hot-reload-content', 'background-color', 'rgba(0, 0, 255, 1)'
- )
-
- with open(hot_reload_file, 'r+') as f:
- old_content = f.read()
- f.truncate(0)
- f.seek(0)
- f.write(textwrap.dedent('''
- #hot-reload-content {
- background-color: red;
- }
- '''))
-
- try:
- self.wait_for_style_to_equal(
- '#hot-reload-content', 'background-color', 'rgba(255, 0, 0, 1)'
- )
- finally:
- with open(hot_reload_file, 'w') as f:
- f.write(old_content)
-
def test_single_input_multi_outputs_on_multiple_components(self):
call_count = Value('i')
diff --git a/tests/package.json b/tests/package.json
deleted file mode 100644
index 48a500deba..0000000000
--- a/tests/package.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "name": "dash_tests",
- "version": "1.0.0",
- "description": "Utilities to help with dash tests",
- "main": "na",
- "scripts": {
- "test": "echo \"Error: no test specified\" && exit 1"
- },
- "author": "chris@plot.ly",
- "license": "ISC"
-}
diff --git a/tests/unit/dash/__init__.py b/tests/unit/dash/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/tests/unit/dash/development/__init__.py b/tests/unit/dash/development/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/tests/unit/dash/test_resources.py b/tests/unit/dash/test_resources.py
deleted file mode 100644
index 4a32f54878..0000000000
--- a/tests/unit/dash/test_resources.py
+++ /dev/null
@@ -1,95 +0,0 @@
-import mock
-import dash_core_components as dcc
-
-import dash
-
-_monkey_patched_js_dist = [
- {
- 'external_url': 'https://external_javascript.js',
- 'relative_package_path': 'external_javascript.js',
- 'namespace': 'dash_core_components'
- },
- {
- 'external_url': 'https://external_css.css',
- 'relative_package_path': 'external_css.css',
- 'namespace': 'dash_core_components'
- },
- {
- 'relative_package_path': 'fake_dcc.js',
- 'dev_package_path': 'fake_dcc.dev.js',
- 'external_url': 'https://component_library.bundle.js',
- 'namespace': 'dash_core_components'
- },
- {
- 'relative_package_path': 'fake_dcc.min.js.map',
- 'dev_package_path': 'fake_dcc.dev.js.map',
- 'external_url': 'https://component_library.bundle.js.map',
- 'namespace': 'dash_core_components',
- 'dynamic': True
- }
-]
-
-
-class StatMock(object):
- st_mtime = 1
-
-
-def test_external(mocker):
- mocker.patch('dash_core_components._js_dist')
- dcc._js_dist = _monkey_patched_js_dist # noqa: W0212,
- dcc.__version__ = 1
-
- app = dash.Dash(
- __name__,
- assets_folder='tests/assets',
- assets_ignore='load_after.+.js'
- )
- app.layout = dcc.Markdown()
- app.scripts.config.serve_locally = False
-
- with mock.patch('dash.dash.os.stat', return_value=StatMock()):
- resource = app._collect_and_register_resources(
- app.scripts.get_all_scripts()
- )
-
- assert resource == [
- 'https://external_javascript.js',
- 'https://external_css.css',
- 'https://component_library.bundle.js'
- ]
-
-
-def test_internal(mocker):
- mocker.patch('dash_core_components._js_dist')
- dcc._js_dist = _monkey_patched_js_dist # noqa: W0212,
- dcc.__version__ = 1
-
- app = dash.Dash(
- __name__,
- assets_folder='tests/assets',
- assets_ignore='load_after.+.js'
- )
- app.layout = dcc.Markdown()
-
- assert app.scripts.config.serve_locally and app.css.config.serve_locally
-
- with mock.patch('dash.dash.os.stat', return_value=StatMock()):
- with mock.patch('dash.dash.importlib.import_module',
- return_value=dcc):
- resource = app._collect_and_register_resources(
- app.scripts.get_all_scripts()
- )
-
- assert resource == [
- '/_dash-component-suites/'
- 'dash_core_components/external_javascript.js?v=1&m=1',
- '/_dash-component-suites/'
- 'dash_core_components/external_css.css?v=1&m=1',
- '/_dash-component-suites/'
- 'dash_core_components/fake_dcc.js?v=1&m=1',
- ]
-
- assert 'fake_dcc.min.js.map' in app.registered_paths['dash_core_components'], \
- 'Dynamic resource not available in registered path {}'.format(
- app.registered_paths['dash_core_components']
- )
diff --git a/tests/unit/dash/development/TestReactComponent.react.js b/tests/unit/development/TestReactComponent.react.js
similarity index 100%
rename from tests/unit/dash/development/TestReactComponent.react.js
rename to tests/unit/development/TestReactComponent.react.js
diff --git a/tests/unit/dash/development/TestReactComponentRequired.react.js b/tests/unit/development/TestReactComponentRequired.react.js
similarity index 100%
rename from tests/unit/dash/development/TestReactComponentRequired.react.js
rename to tests/unit/development/TestReactComponentRequired.react.js
diff --git a/tests/unit/__init__.py b/tests/unit/development/__init__.py
similarity index 100%
rename from tests/unit/__init__.py
rename to tests/unit/development/__init__.py
diff --git a/tests/unit/dash/development/flow_metadata_test.json b/tests/unit/development/flow_metadata_test.json
similarity index 100%
rename from tests/unit/dash/development/flow_metadata_test.json
rename to tests/unit/development/flow_metadata_test.json
diff --git a/tests/unit/dash/development/metadata_required_test.json b/tests/unit/development/metadata_required_test.json
similarity index 100%
rename from tests/unit/dash/development/metadata_required_test.json
rename to tests/unit/development/metadata_required_test.json
diff --git a/tests/unit/dash/development/metadata_test.json b/tests/unit/development/metadata_test.json
similarity index 100%
rename from tests/unit/dash/development/metadata_test.json
rename to tests/unit/development/metadata_test.json
diff --git a/tests/unit/dash/development/metadata_test.py b/tests/unit/development/metadata_test.py
similarity index 100%
rename from tests/unit/dash/development/metadata_test.py
rename to tests/unit/development/metadata_test.py
diff --git a/tests/unit/dash/development/test_base_component.py b/tests/unit/development/test_base_component.py
similarity index 99%
rename from tests/unit/dash/development/test_base_component.py
rename to tests/unit/development/test_base_component.py
index 9040a44622..51d94fe0e5 100644
--- a/tests/unit/dash/development/test_base_component.py
+++ b/tests/unit/development/test_base_component.py
@@ -5,7 +5,6 @@
import shutil
import unittest
import plotly
-
from dash.development.base_component import Component
from dash.development.component_generator import reserved_words
from dash.development._py_components_generation import (
diff --git a/tests/unit/dash/development/test_component_loader.py b/tests/unit/development/test_component_loader.py
similarity index 100%
rename from tests/unit/dash/development/test_component_loader.py
rename to tests/unit/development/test_component_loader.py
diff --git a/tests/unit/test_app_runners.py b/tests/unit/test_app_runners.py
new file mode 100644
index 0000000000..4bd4ed43d1
--- /dev/null
+++ b/tests/unit/test_app_runners.py
@@ -0,0 +1,33 @@
+import time
+import sys
+import requests
+import pytest
+
+import dash_html_components as html
+import dash
+
+
+def test_threaded_server_smoke(dash_thread_server):
+ app = dash.Dash(__name__)
+
+ app.layout = html.Div(
+ [
+ html.Button("click me", id="clicker"),
+ html.Div(id="output", children="hello thread"),
+ ]
+ )
+ dash_thread_server(app, debug=True, use_reloader=False, use_debugger=True)
+ r = requests.get(dash_thread_server.url)
+ assert r.status_code == 200, "the threaded server is reachable"
+ assert 'id="react-entry-point"' in r.text, "the entrypoint is present"
+
+
+@pytest.mark.skipif(
+ sys.version_info < (3,), reason="requires python3 for process testing"
+)
+def test_process_server_smoke(dash_process_server):
+ dash_process_server("simple_app")
+ time.sleep(2.5)
+ r = requests.get(dash_process_server.url)
+ assert r.status_code == 200, "the server is reachable"
+ assert 'id="react-entry-point"' in r.text, "the entrypoint is present"
diff --git a/tests/unit/dash/test_configs.py b/tests/unit/test_configs.py
similarity index 100%
rename from tests/unit/dash/test_configs.py
rename to tests/unit/test_configs.py
diff --git a/tests/unit/test_import.py b/tests/unit/test_import.py
new file mode 100644
index 0000000000..5448882471
--- /dev/null
+++ b/tests/unit/test_import.py
@@ -0,0 +1,14 @@
+import importlib
+import types
+
+
+def test_dash_import_is_correct():
+ imported = importlib.import_module("dash")
+ assert isinstance(imported, types.ModuleType), "dash can be imported"
+
+ with open("./dash/version.py") as fp:
+ assert imported.__version__ in fp.read(), "version is consistent"
+
+ assert (
+ getattr(imported, "Dash").__name__ == "Dash"
+ ), "access to main Dash class is valid"
diff --git a/tests/unit/test_resources.py b/tests/unit/test_resources.py
new file mode 100644
index 0000000000..1aaf496eb8
--- /dev/null
+++ b/tests/unit/test_resources.py
@@ -0,0 +1,90 @@
+import mock
+import dash_core_components as dcc
+import dash
+
+_monkey_patched_js_dist = [
+ {
+ "external_url": "https://external_javascript.js",
+ "relative_package_path": "external_javascript.js",
+ "namespace": "dash_core_components",
+ },
+ {
+ "external_url": "https://external_css.css",
+ "relative_package_path": "external_css.css",
+ "namespace": "dash_core_components",
+ },
+ {
+ "relative_package_path": "fake_dcc.js",
+ "dev_package_path": "fake_dcc.dev.js",
+ "external_url": "https://component_library.bundle.js",
+ "namespace": "dash_core_components",
+ },
+ {
+ "relative_package_path": "fake_dcc.min.js.map",
+ "dev_package_path": "fake_dcc.dev.js.map",
+ "external_url": "https://component_library.bundle.js.map",
+ "namespace": "dash_core_components",
+ "dynamic": True,
+ },
+]
+
+
+class StatMock(object):
+ st_mtime = 1
+
+
+def test_external(mocker):
+ mocker.patch("dash_core_components._js_dist")
+ mocker.patch("dash_html_components._js_dist")
+ dcc._js_dist = _monkey_patched_js_dist # noqa: W0212
+ dcc.__version__ = 1
+
+ app = dash.Dash(
+ __name__, assets_folder="tests/assets", assets_ignore="load_after.+.js"
+ )
+ app.layout = dcc.Markdown()
+ app.scripts.config.serve_locally = False
+
+ resource = app._collect_and_register_resources(
+ app.scripts.get_all_scripts()
+ )
+
+ assert resource == [
+ "https://external_javascript.js",
+ "https://external_css.css",
+ "https://component_library.bundle.js",
+ ]
+
+
+def test_internal(mocker):
+ mocker.patch("dash_core_components._js_dist")
+ mocker.patch("dash_html_components._js_dist")
+ dcc._js_dist = _monkey_patched_js_dist # noqa: W0212,
+ dcc.__version__ = 1
+
+ app = dash.Dash(
+ __name__, assets_folder="tests/assets", assets_ignore="load_after.+.js"
+ )
+ app.layout = dcc.Markdown()
+
+ assert app.scripts.config.serve_locally and app.css.config.serve_locally
+
+ with mock.patch("dash.dash.os.stat", return_value=StatMock()):
+ with mock.patch("dash.dash.importlib.import_module", return_value=dcc):
+ resource = app._collect_and_register_resources(
+ app.scripts.get_all_scripts()
+ )
+
+ assert resource == [
+ "/_dash-component-suites/"
+ "dash_core_components/external_javascript.js?v=1&m=1",
+ "/_dash-component-suites/"
+ "dash_core_components/external_css.css?v=1&m=1",
+ "/_dash-component-suites/" "dash_core_components/fake_dcc.js?v=1&m=1",
+ ]
+
+ assert (
+ "fake_dcc.min.js.map" in app.registered_paths["dash_core_components"]
+ ), "Dynamic resource not available in registered path {}".format(
+ app.registered_paths["dash_core_components"]
+ )
diff --git a/tests/utils.py b/tests/utils.py
deleted file mode 100644
index da35583492..0000000000
--- a/tests/utils.py
+++ /dev/null
@@ -1,72 +0,0 @@
-import time
-
-
-TIMEOUT = 5 # Seconds
-
-
-def invincible(func):
- def wrap():
- try:
- return func()
- except:
- pass
- return wrap
-
-
-class WaitForTimeout(Exception):
- """This should only be raised inside the `wait_for` function."""
- pass
-
-
-def wait_for(condition_function, get_message=None, expected_value=None,
- timeout=TIMEOUT, *args, **kwargs):
- """
- Waits for condition_function to return truthy or raises WaitForTimeout.
- :param (function) condition_function: Should return truthy or
- expected_value on success.
- :param (function) get_message: Optional failure message function
- :param expected_value: Optional return value to wait for. If omitted,
- success is any truthy value.
- :param (float) timeout: max seconds to wait. Defaults to 5
- :param args: Optional args to pass to condition_function.
- :param kwargs: Optional kwargs to pass to condition_function.
- if `timeout` is in kwargs, it will be used to override TIMEOUT
- :raises: WaitForTimeout If condition_function doesn't return True in time.
- Usage:
- def get_element(selector):
- # some code to get some element or return a `False`-y value.
- selector = '.js-plotly-plot'
- try:
- wait_for(get_element, selector)
- except WaitForTimeout:
- self.fail('element never appeared...')
- plot = get_element(selector) # we know it exists.
- """
- def wrapped_condition_function():
- """We wrap this to alter the call base on the closure."""
- if args and kwargs:
- return condition_function(*args, **kwargs)
- if args:
- return condition_function(*args)
- if kwargs:
- return condition_function(**kwargs)
- return condition_function()
-
- start_time = time.time()
- while time.time() < start_time + timeout:
- condition_val = wrapped_condition_function()
- if expected_value is None:
- if condition_val:
- return True
- elif condition_val == expected_value:
- return True
- time.sleep(0.5)
-
- if get_message:
- message = get_message()
- elif expected_value:
- message = 'Final value: {}'.format(condition_val)
- else:
- message = ''
-
- raise WaitForTimeout(message)