diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 8153a00876..d19d669fb7 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -116,9 +116,8 @@ jobs: - name: Setup virtual display run: | - sudo apt-get update - sudo apt-get install -y xvfb - sudo Xvfb :99 -ac -screen 0 1280x1024x24 & + sudo Xvfb :99 -ac -screen 0 1280x1024x24 >/dev/null 2>&1 & + disown echo "DISPLAY=:99" >> $GITHUB_ENV - name: Run lint @@ -673,9 +672,8 @@ jobs: - name: Setup virtual display run: | - sudo apt-get update - sudo apt-get install -y xvfb - sudo Xvfb :99 -ac -screen 0 1280x1024x24 & + sudo Xvfb :99 -ac -screen 0 1280x1024x24 >/dev/null 2>&1 & + disown echo "DISPLAY=:99" >> $GITHUB_ENV - name: Build/Setup test components @@ -778,9 +776,8 @@ jobs: - name: Setup virtual display run: | - sudo apt-get update - sudo apt-get install -y xvfb - sudo Xvfb :99 -ac -screen 0 1280x1024x24 & + sudo Xvfb :99 -ac -screen 0 1280x1024x24 >/dev/null 2>&1 & + disown echo "DISPLAY=:99" >> $GITHUB_ENV - name: Install HTML components dependencies @@ -975,9 +972,8 @@ jobs: - name: Setup virtual display run: | - sudo apt-get update - sudo apt-get install -y xvfb - sudo Xvfb :99 -ac -screen 0 1280x1024x24 & + sudo Xvfb :99 -ac -screen 0 1280x1024x24 >/dev/null 2>&1 & + disown echo "DISPLAY=:99" >> $GITHUB_ENV - name: Remove DCC Python package and run tests @@ -1059,9 +1055,8 @@ jobs: - name: Setup virtual display run: | - sudo apt-get update - sudo apt-get install -y xvfb - sudo Xvfb :99 -ac -screen 0 1280x1024x24 & + sudo Xvfb :99 -ac -screen 0 1280x1024x24 >/dev/null 2>&1 & + disown echo "DISPLAY=:99" >> $GITHUB_ENV - name: Install Table test dependencies diff --git a/CHANGELOG.md b/CHANGELOG.md index 85a455204e..8cc92dbd2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#3646](https://github.com/plotly/dash/pull/3646) Remove React 16 support (`16.14.0` is no longer an accepted value for `REACT_VERSION` / `_set_react_version`). ### Fixed +- Unpin `selenium` in the testing requirements (was capped at `<=4.2.0`, from 2022) and require `>=4.11.0`. The old cap predated Selenium Manager, so the pinned selenium could not drive the current stable Chrome (151+) that CI installs, producing widespread `StaleElementReferenceException`/`TimeoutException` flakiness across the browser-based integration tests. Modern selenium auto-provisions a matching chromedriver, restoring stable CI runs. - [#3941](https://github.com/plotly/dash/pull/3941) Fix the FastAPI and Quart backends opening a WebSocket connection on every page load, even for apps with no WebSocket callbacks. The renderer keyed the connection on the mere presence of WebSocket infrastructure (always advertised by these backends) rather than on whether it was needed. The socket now opens eagerly only when `websocket_callbacks=True`; with just per-callback `websocket=True` it opens lazily on the first such callback dispatch, and an app with no WebSocket callbacks never opens one. Fixes [#3939](https://github.com/plotly/dash/issues/3939). - [#3916](https://github.com/plotly/dash/pull/3916) Fixed a regression where dragging multiple files into `dcc.Upload` would upload only the first file when `multiple=True` - [#3922](https://github.com/plotly/dash/pull/3922) Fix `dcc.Input(type="number")` stepper behavior when only `min` is set. diff --git a/components/dash-core-components/tests/dash_core_components_page.py b/components/dash-core-components/tests/dash_core_components_page.py index 8f2c353af1..690c429f23 100644 --- a/components/dash-core-components/tests/dash_core_components_page.py +++ b/components/dash-core-components/tests/dash_core_components_page.py @@ -103,14 +103,18 @@ def click_and_hold_at_coord_fractions(self, elem_or_selector, fx, fy): elem = self._get_element(elem_or_selector) ActionChains(self.driver).move_to_element_with_offset( - elem, elem.size["width"] * fx, elem.size["height"] * fy + elem, + int(elem.size["width"] * (fx - 0.5)), + int(elem.size["height"] * (fy - 0.5)), ).click_and_hold().perform() def move_to_coord_fractions(self, elem_or_selector, fx, fy): elem = self._get_element(elem_or_selector) ActionChains(self.driver).click_and_hold().move_to_element_with_offset( - elem, elem.size["width"] * fx, elem.size["height"] * fy + elem, + int(elem.size["width"] * (fx - 0.5)), + int(elem.size["height"] * (fy - 0.5)), ).perform() def release(self): @@ -120,7 +124,11 @@ def click_and_drag_at_coord_fractions(self, elem_or_selector, fx1, fy1, fx2, fy2 elem = self._get_element(elem_or_selector) ActionChains(self.driver).move_to_element_with_offset( - elem, elem.size["width"] * fx1, elem.size["height"] * fy1 + elem, + int(elem.size["width"] * (fx1 - 0.5)), + int(elem.size["height"] * (fy1 - 0.5)), ).click_and_hold().move_to_element_with_offset( - elem, elem.size["width"] * fx2, elem.size["height"] * fy2 + elem, + int(elem.size["width"] * (fx2 - 0.5)), + int(elem.size["height"] * (fy2 - 0.5)), ).release().perform() diff --git a/components/dash-core-components/tests/integration/misc/test_markdown_highlight.py b/components/dash-core-components/tests/integration/misc/test_markdown_highlight.py index 361fcc7586..5606595b89 100644 --- a/components/dash-core-components/tests/integration/misc/test_markdown_highlight.py +++ b/components/dash-core-components/tests/integration/misc/test_markdown_highlight.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +from selenium.webdriver.common.by import By + from dash import Dash, Input, Output, dcc, html @@ -71,11 +73,11 @@ def update_md(nclicks): # a highlighted node will have children which are what color the text code = dash_dcc.wait_for_element("code[data-highlighted='yes']") - assert len(code.find_elements_by_tag_name("span")) == 2 + assert len(code.find_elements(By.TAG_NAME, "span")) == 2 dash_dcc.find_element("#md-trigger").click() code = dash_dcc.wait_for_element("code[data-highlighted='yes']") - assert len(code.find_elements_by_tag_name("span")) == 3 + assert len(code.find_elements(By.TAG_NAME, "span")) == 3 assert dash_dcc.get_logs() == [] diff --git a/components/dash-core-components/tests/integration/tooltip/test_tooltip.py b/components/dash-core-components/tests/integration/tooltip/test_tooltip.py index 8ecb1e8a4e..d121bbcecd 100644 --- a/components/dash-core-components/tests/integration/tooltip/test_tooltip.py +++ b/components/dash-core-components/tests/integration/tooltip/test_tooltip.py @@ -68,9 +68,10 @@ def update_tooltip_content(hoverData): elem = dash_dcc.find_element("#graph .nsewdrag") with lock: - # hover on the center of the graph + # hover on the center of the graph (offset is measured from the + # element center in selenium >= 4.3, so 0, 0 is the center) ActionChains(dash_dcc.driver).move_to_element_with_offset( - elem, elem.size["width"] / 2, elem.size["height"] / 2 + elem, 0, 0 ).click().perform() dash_dcc.wait_for_text_to_equal("#graph-tooltip", loading_text) @@ -83,7 +84,7 @@ def update_tooltip_content(hoverData): elem = dash_dcc.find_element("#graph .nsewdrag") ActionChains(dash_dcc.driver).move_to_element_with_offset( - elem, 5, elem.size["height"] - 5 + elem, int(5 - elem.size["width"] / 2), int(elem.size["height"] / 2 - 5) ).perform() until(lambda: not dash_dcc.find_element("#graph-tooltip").is_displayed(), 3) diff --git a/components/dash-core-components/tests/integration/upload/test_children_accept_any_component.py b/components/dash-core-components/tests/integration/upload/test_children_accept_any_component.py index 7ee9d9d03c..d4d962cf27 100644 --- a/components/dash-core-components/tests/integration/upload/test_children_accept_any_component.py +++ b/components/dash-core-components/tests/integration/upload/test_children_accept_any_component.py @@ -1,4 +1,5 @@ import time +from selenium.webdriver.common.by import By from dash import Dash, dcc, html @@ -41,16 +42,16 @@ def test_upca001_upload_children_gallery(dash_dcc): time.sleep(0.5) dash_dcc.percy_snapshot("upca001 children gallery") - first_child = dash_dcc.find_element("#upload").find_element_by_css_selector( - ":first-child" + first_child = dash_dcc.find_element("#upload").find_element( + By.CSS_SELECTOR, ":first-child" ) # Check that there is no default style since className is specified style = first_child.get_attribute("style") assert "opacity: 0.5" not in style - first_child = dash_dcc.find_element( - "#upload-no-className" - ).find_element_by_css_selector(":first-child") + first_child = dash_dcc.find_element("#upload-no-className").find_element( + By.CSS_SELECTOR, ":first-child" + ) # Check that there is default style since no className is specified style = first_child.get_attribute("style") diff --git a/dash/testing/application_runners.py b/dash/testing/application_runners.py index b318d2419d..b3e440f542 100644 --- a/dash/testing/application_runners.py +++ b/dash/testing/application_runners.py @@ -240,9 +240,12 @@ def stop(self): loop.call_soon_threadsafe(quart_shutdown_event.set) # type: ignore[reportOptionalMemberAccess] self.thread.join(timeout=self.stop_timeout) # type: ignore[reportOptionalMemberAccess] else: - # Fall back to killing threads for Flask/other backends + # Fall back to killing threads for Flask/other backends. Bound the + # join: if the injected SystemExit fails to unwind a worker stuck in + # a C call, an unbounded join() would block teardown forever and + # hang the whole test step. self.thread.kill() # type: ignore[reportOptionalMemberAccess] - self.thread.join() # type: ignore[reportOptionalMemberAccess] + self.thread.join(timeout=self.stop_timeout) # type: ignore[reportOptionalMemberAccess] wait.until_not(self.thread.is_alive, self.stop_timeout) # type: ignore[reportOptionalMemberAccess] self._app = None self.started = False diff --git a/dash/testing/browser.py b/dash/testing/browser.py index a6382c17dd..d86de118e6 100644 --- a/dash/testing/browser.py +++ b/dash/testing/browser.py @@ -539,8 +539,6 @@ def _get_chrome(self): def _get_firefox(self): options = self._get_wd_options() - options.set_capability("marionette", True) - options.set_preference("browser.download.dir", self.download_path) options.set_preference("browser.download.folderList", 2) options.set_preference( @@ -598,7 +596,7 @@ def zoom_in_graph_by_ratio( w, h = elem.size["width"], elem.size["height"] try: ActionChains(self.driver).move_to_element_with_offset( - elem, w * start_fraction, h * start_fraction + elem, int(w * (start_fraction - 0.5)), int(h * (start_fraction - 0.5)) ).drag_and_drop_by_offset( elem, w * zoom_box_fraction, h * zoom_box_fraction ).perform() @@ -613,7 +611,9 @@ def click_at_coord_fractions(self, elem_or_selector, fx, fy): elem = self._get_element(elem_or_selector) ActionChains(self.driver).move_to_element_with_offset( - elem, elem.size["width"] * fx, elem.size["height"] * fy + elem, + int(elem.size["width"] * (fx - 0.5)), + int(elem.size["height"] * (fy - 0.5)), ).click().perform() def get_logs(self): diff --git a/pytest.ini b/pytest.ini index 8b16d3d9a5..d256019308 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,5 +3,10 @@ junit_family = xunit1 testpaths = tests/ addopts = -rsxX -vv +# Safety net: no single test may run longer than this. A hung test then fails +# with a full thread stack dump (naming the culprit) instead of wedging the +# whole CI step until the job-level timeout. Legitimately-slow tests can raise +# it with @pytest.mark.timeout(n). +timeout = 180 log_format = %(asctime)s | %(levelname)s | %(name)s:%(lineno)d | %(message)s log_cli_level = ERROR diff --git a/requirements/ci.txt b/requirements/ci.txt index 8e18280d04..be523339fc 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -18,6 +18,7 @@ pytest-sugar==1.1.1 pyzmq>=26.0.0 xlrd>=2.0.1 pytest-rerunfailures +pytest-timeout jupyterlab<4.0.0 pyright==1.1.398;python_version>="3.7" mypy==1.15.0;python_version>="3.12" diff --git a/requirements/testing.txt b/requirements/testing.txt index 306ec4f0d6..37fd92ceec 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -5,7 +5,7 @@ lxml>=4.6.2 percy-python-selenium>=1.0.0 pytest>=6.0.2 requests[security]>=2.21.0 -selenium>=3.141.0,<=4.2.0 +selenium>=4.11.0,<=4.46.0 waitress>=1.4.4 multiprocess>=0.70.12 psutil>=5.8.0 diff --git a/tests/async_tests/test_async_callbacks.py b/tests/async_tests/test_async_callbacks.py index a2f3562efa..0eef3b1afb 100644 --- a/tests/async_tests/test_async_callbacks.py +++ b/tests/async_tests/test_async_callbacks.py @@ -1,8 +1,6 @@ import json import time -import flaky - from multiprocessing import Lock, Value import pytest @@ -25,15 +23,14 @@ no_update, ) from dash.exceptions import PreventUpdate +from dash.testing.wait import until from tests.integration.utils import json_engine from tests.utils import is_dash_async -@flaky.flaky(max_runs=3) def test_async_cbsc001_simple_callback(dash_duo): if not is_dash_async(): return - lock = Lock() app = Dash(__name__) app.layout = html.Div( @@ -46,9 +43,8 @@ def test_async_cbsc001_simple_callback(dash_duo): @app.callback(Output("output-1", "children"), [Input("input", "value")]) async def update_output(value): - with lock: - call_count.value = call_count.value + 1 - return value + call_count.value = call_count.value + 1 + return value dash_duo.start_server(app) @@ -57,9 +53,16 @@ async def update_output(value): input_ = dash_duo.find_element("#input") dash_duo.clear_input(input_) - for key in "hello world": - with lock: - input_.send_keys(key) + # Gate each keystroke on the previous callback having executed. If two + # keystroke callbacks are in the renderer's `requested` queue at once it + # coalesces them into a single request (see requestedCallbacks.ts), which + # undercounts invocations. Waiting for each keystroke to be processed + # keeps the one-callback-per-keystroke invariant the assertion relies on. + until(lambda: call_count.value == 2, timeout=3) + + for i, key in enumerate("hello world"): + input_.send_keys(key) + until(lambda i=i: call_count.value == 3 + i, timeout=3) dash_duo.wait_for_text_to_equal("#output-1", "hello world") @@ -375,11 +378,9 @@ async def set_path(n): dash_duo.wait_for_text_to_equal("#out", '[{"a": "/2:a"}] - /2') -@flaky.flaky(max_runs=3) def test_async_cbsc008_wildcard_prop_callbacks(dash_duo): if not is_dash_async(): return - lock = Lock() app = Dash(__name__) app.layout = html.Div( @@ -406,10 +407,9 @@ def test_async_cbsc008_wildcard_prop_callbacks(dash_duo): @app.callback(Output("output-1", "data-cb"), [Input("input", "value")]) async def update_data(value): - with lock: - if not percy_enabled.value: - input_call_count.value += 1 - return value + if not percy_enabled.value: + input_call_count.value += 1 + return value @app.callback(Output("output-1", "children"), [Input("output-1", "data-cb")]) async def update_text(data): @@ -424,10 +424,14 @@ async def update_text(data): input1 = dash_duo.find_element("#input") dash_duo.clear_input(input1) - for key in "hello world": - with lock: - input1.send_keys(key) - time.sleep(0.05) # allow some time for debounced callback to be sent + # Gate each keystroke on the previous callback having executed so the + # renderer cannot coalesce two in-flight callbacks into one request and + # undercount invocations (see requestedCallbacks.ts). + until(lambda: input_call_count.value == 2, timeout=3) + + for i, key in enumerate("hello world"): + input1.send_keys(key) + until(lambda i=i: input_call_count.value == 3 + i, timeout=3) dash_duo.wait_for_text_to_equal("#output-1", "hello world") assert dash_duo.find_element("#output-1").get_attribute("data-cb") == "hello world" diff --git a/tests/integration/callbacks/test_basic_callback.py b/tests/integration/callbacks/test_basic_callback.py index 6e724c186f..3d8f94bd57 100644 --- a/tests/integration/callbacks/test_basic_callback.py +++ b/tests/integration/callbacks/test_basic_callback.py @@ -24,12 +24,11 @@ callback_context, ) from dash.exceptions import PreventUpdate +from dash.testing.wait import until from tests.integration.utils import json_engine def test_cbsc001_simple_callback(dash_duo): - lock = Lock() - app = Dash(__name__) app.layout = html.Div( [ @@ -41,9 +40,8 @@ def test_cbsc001_simple_callback(dash_duo): @app.callback(Output("output-1", "children"), [Input("input", "value")]) def update_output(value): - with lock: - call_count.value = call_count.value + 1 - return value + call_count.value = call_count.value + 1 + return value dash_duo.start_server(app) @@ -52,10 +50,16 @@ def update_output(value): input_ = dash_duo.find_element("#input") dash_duo.clear_input(input_) - for key in "hello world": - with lock: - input_.send_keys(key) - time.sleep(0.05) # Small delay to prevent callback debouncing + # Gate each keystroke on the previous callback having executed. If two + # keystroke callbacks are in the renderer's `requested` queue at once it + # coalesces them into a single request (see requestedCallbacks.ts), which + # undercounts invocations. Waiting for each keystroke to be processed + # keeps the one-callback-per-keystroke invariant the assertion relies on. + until(lambda: call_count.value == 2, timeout=3) + + for i, key in enumerate("hello world"): + input_.send_keys(key) + until(lambda i=i: call_count.value == 3 + i, timeout=3) dash_duo.wait_for_text_to_equal("#output-1", "hello world") diff --git a/tests/integration/callbacks/test_multiple_callbacks.py b/tests/integration/callbacks/test_multiple_callbacks.py index f78d029386..f3bce4d3b5 100644 --- a/tests/integration/callbacks/test_multiple_callbacks.py +++ b/tests/integration/callbacks/test_multiple_callbacks.py @@ -2,6 +2,7 @@ from multiprocessing import Value, Lock import pytest +from selenium.webdriver.common.by import By from dash import Dash, Input, Output, State, callback_context, html, dcc, dash_table from dash.exceptions import PreventUpdate @@ -648,9 +649,9 @@ def set_display_children(selected_country, selected_city): assert out_call_count.value == 1 all_labels = dash_duo.find_elements("label") - canada_opt = next( - i for i in all_labels if i.text == "Canada" - ).find_element_by_tag_name("input") + canada_opt = next(i for i in all_labels if i.text == "Canada").find_element( + By.TAG_NAME, "input" + ) with out_lock: canada_opt.click() diff --git a/tests/integration/test_duo.py b/tests/integration/test_duo.py index fa5728b6ee..8ffd04a0a7 100644 --- a/tests/integration/test_duo.py +++ b/tests/integration/test_duo.py @@ -12,23 +12,23 @@ def test_duo001_wait_for_text_error(dash_duo): with pytest.raises(TimeoutException) as err: dash_duo.wait_for_text_to_equal("#content", "Invalid", timeout=1.0) - assert err.value.args[0] == "text -> Invalid not found within 1.0s, found: Content" + assert err.value.msg == "text -> Invalid not found within 1.0s, found: Content" with pytest.raises(TimeoutException) as err: dash_duo.wait_for_text_to_equal("#content", "None", timeout=1.0) - assert err.value.args[0] == "text -> None not found within 1.0s, found: Content" + assert err.value.msg == "text -> None not found within 1.0s, found: Content" with pytest.raises(TimeoutException) as err: dash_duo.wait_for_text_to_equal("#none", "None", timeout=1.0) - assert err.value.args[0] == "text -> None not found within 1.0s, #none not found" + assert err.value.msg == "text -> None not found within 1.0s, #none not found" with pytest.raises(TimeoutException) as err: dash_duo.wait_for_contains_text("#content", "invalid", timeout=1.0) assert ( - err.value.args[0] + err.value.msg == "text -> invalid not found inside element within 1.0s, found: Content" ) @@ -36,7 +36,7 @@ def test_duo001_wait_for_text_error(dash_duo): dash_duo.wait_for_contains_text("#content", "None", timeout=1.0) assert ( - err.value.args[0] + err.value.msg == "text -> None not found inside element within 1.0s, found: Content" ) @@ -44,7 +44,7 @@ def test_duo001_wait_for_text_error(dash_duo): dash_duo.wait_for_contains_text("#none", "none", timeout=1.0) assert ( - err.value.args[0] + err.value.msg == "text -> none not found inside element within 1.0s, #none not found" ) @@ -59,6 +59,6 @@ def test_duo002_wait_for_text_value(dash_duo): dash_duo.wait_for_contains_text("#value-item", "None", timeout=1.0) assert ( - err.value.args[0] + err.value.msg == "text -> None not found inside element within 1.0s, found: Item" )