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)