From e99dd680156cd29073e7d8f8bbb0cac3e46e4c5d Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 18 Aug 2026 16:39:20 -0400 Subject: [PATCH 1/8] Unpin selenium to fix CI flakiness against current Chrome The testing requirements capped selenium at <=4.2.0 (2022), which predates Selenium Manager. CI installs the current stable Chrome (now 151) via an unpinned browser-actions/setup-chrome, and selenium 4.2 cannot reliably provision or drive it, producing scattered StaleElementReferenceException / TimeoutException failures across unrelated browser integration tests on every push and PR. Require selenium>=4.11.0 (mature Selenium Manager auto-provisions a matching chromedriver) up to the current latest 4.46.0. --- CHANGELOG.md | 1 + requirements/testing.txt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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/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 From 1845c32f4eb01d8ff8fdb44e0f27e62a5887b808 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 18 Aug 2026 16:52:39 -0400 Subject: [PATCH 2/8] Migrate testing helpers off selenium APIs removed in 4.3+ Unpinning selenium exposed two deterministic breaks the 4.2.0 cap had hidden: - browser.py set the 'marionette' Firefox capability, which modern selenium/geckodriver reject with InvalidArgumentException (marionette is the implicit, only protocol now). Removed it. - Three test modules used the find_element(s)_by_* helper methods that selenium removed in 4.3. Migrated them to find_element(s)(By.*, ...). --- .../tests/integration/misc/test_markdown_highlight.py | 6 ++++-- .../upload/test_children_accept_any_component.py | 11 ++++++----- dash/testing/browser.py | 2 -- .../integration/callbacks/test_multiple_callbacks.py | 7 ++++--- 4 files changed, 14 insertions(+), 12 deletions(-) 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/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/browser.py b/dash/testing/browser.py index a6382c17dd..e306fe85db 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( 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() From bec35498eec51213b3270c2dc42689239f3eaf93 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 19 Aug 2026 10:57:58 -0400 Subject: [PATCH 3/8] Fix ActionChains offsets for selenium 4.3+ center-origin change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selenium 4.3 changed move_to_element_with_offset to measure the offset from the element's center instead of its top-left corner. The dash_duo drag/click helpers (click_at_coord_fractions, zoom_in_graph_by_ratio) and the dcc page object helpers passed top-left-based fractional offsets (width*fx, height*fy), so under modern selenium they overshot past the element edge and raised MoveTargetOutOfBoundsException — failing the slider drag/step tests and the graph tooltip center-hover test. Convert the proportional offsets to center-relative (width*(fx-0.5)) and cast to int (W3C actions require integer pixels). Small fixed-pixel offsets (5, 8) are left as-is: they stay within any element regardless of origin. --- .../tests/dash_core_components_page.py | 16 ++++++++++++---- .../tests/integration/tooltip/test_tooltip.py | 7 ++++--- dash/testing/browser.py | 6 ++++-- 3 files changed, 20 insertions(+), 9 deletions(-) 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/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/dash/testing/browser.py b/dash/testing/browser.py index e306fe85db..d86de118e6 100644 --- a/dash/testing/browser.py +++ b/dash/testing/browser.py @@ -596,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() @@ -611,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): From 87228538ac06dd7be603166069de656425a60e3e Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 19 Aug 2026 11:04:36 -0400 Subject: [PATCH 4/8] Read selenium TimeoutException message via .msg, not .args[0] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dash_duo's _wait_for helpers raise selenium's TimeoutException(str(message)). Modern selenium's WebDriverException.__init__ calls super().__init__() with no args, so the message lives on .msg and .args is empty — test_duo's err.value.args[0] assertions raised IndexError. Read .msg, selenium's stable message accessor. --- tests/integration/test_duo.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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" ) From a5a474fca76e569efb61a1429ba3ae99d6c70d3f Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 19 Aug 2026 11:47:02 -0400 Subject: [PATCH 5/8] Stop Xvfb from hanging the Setup virtual display CI step The step backgrounded Xvfb with a bare '&', so it inherited the step's stdout/stderr pipe to the Actions runner. Xvfb never exits, so that pipe never reached EOF and the runner blocked on the step indefinitely (intermittent 'Setup virtual display' hangs across the browser-test jobs). Redirect Xvfb's output to /dev/null and disown it so the step's pipe closes and the step completes immediately. --- .github/workflows/testing.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 8153a00876..4c104e97d4 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -118,7 +118,8 @@ jobs: 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 @@ -675,7 +676,8 @@ jobs: 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 @@ -780,7 +782,8 @@ jobs: 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 @@ -977,7 +980,8 @@ jobs: 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 @@ -1061,7 +1065,8 @@ jobs: 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 From a96074bb32e92cf374cc352bce17fd4848181c5e Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 19 Aug 2026 12:07:27 -0400 Subject: [PATCH 6/8] Drop apt-get from Setup virtual display; Xvfb is preinstalled The redirect/disown alone did not stop the hang: the real culprit is 'apt-get update && apt-get install -y xvfb', which intermittently blocks on the runner's dpkg/apt lock (apt-daily / unattended-upgrades). xvfb is already preinstalled on the GitHub Ubuntu runners ('xvfb is already the newest version'), so the install is pure risk. Just start the preinstalled Xvfb; if it were ever absent the step fails fast instead of hanging. --- .github/workflows/testing.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 4c104e97d4..d19d669fb7 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -116,8 +116,6 @@ jobs: - name: Setup virtual display run: | - sudo apt-get update - sudo apt-get install -y xvfb sudo Xvfb :99 -ac -screen 0 1280x1024x24 >/dev/null 2>&1 & disown echo "DISPLAY=:99" >> $GITHUB_ENV @@ -674,8 +672,6 @@ jobs: - name: Setup virtual display run: | - sudo apt-get update - sudo apt-get install -y xvfb sudo Xvfb :99 -ac -screen 0 1280x1024x24 >/dev/null 2>&1 & disown echo "DISPLAY=:99" >> $GITHUB_ENV @@ -780,8 +776,6 @@ jobs: - name: Setup virtual display run: | - sudo apt-get update - sudo apt-get install -y xvfb sudo Xvfb :99 -ac -screen 0 1280x1024x24 >/dev/null 2>&1 & disown echo "DISPLAY=:99" >> $GITHUB_ENV @@ -978,8 +972,6 @@ jobs: - name: Setup virtual display run: | - sudo apt-get update - sudo apt-get install -y xvfb sudo Xvfb :99 -ac -screen 0 1280x1024x24 >/dev/null 2>&1 & disown echo "DISPLAY=:99" >> $GITHUB_ENV @@ -1063,8 +1055,6 @@ jobs: - name: Setup virtual display run: | - sudo apt-get update - sudo apt-get install -y xvfb sudo Xvfb :99 -ac -screen 0 1280x1024x24 >/dev/null 2>&1 & disown echo "DISPLAY=:99" >> $GITHUB_ENV From 28873e080b12a0ebde192b9f7ad19d6ae5bd0994 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 19 Aug 2026 13:15:43 -0400 Subject: [PATCH 7/8] Fix flaky callback-count tests with deterministic keystroke gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_(async_)cbsc001/cbsc008 assert an exact one-callback-per-keystroke count, but the renderer coalesces same-identity callbacks still queued in its 'requested' state (requestedCallbacks.ts) into a single request. Two keystrokes landing in that batching window collapse into one invocation, so the count undershoots. The Lock choreography the tests used to serialize typing no longer holds now that async callbacks execute concurrently, and React 19's more aggressive event batching plus faster Chrome typing pushed the failure rate to ~90% locally — routinely exhausting the flaky retries. Gate each keystroke on the previous callback having executed (wait until the counter reflects it) so a keystroke's callback always leaves the 'requested' queue before the next is sent and can never be coalesced. This makes the exact-count assertion correct by construction; drop the Lock, the per-keystroke sleeps, and the @flaky retries. --- tests/async_tests/test_async_callbacks.py | 44 ++++++++++--------- .../callbacks/test_basic_callback.py | 22 ++++++---- 2 files changed, 37 insertions(+), 29 deletions(-) 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") From 057eacb156b44976a5efde2935fb30c38868aeb9 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 19 Aug 2026 13:44:59 -0400 Subject: [PATCH 8/8] Prevent hung tests from wedging CI: bound teardown join + per-test timeout Two changes so a stuck test/server can no longer hang a whole CI step (the 'Run Async Callback Tests' step was wedging for the full job timeout): - ThreadedRunner.stop() Flask path called self.thread.join() with no timeout. If the injected SystemExit fails to unwind a worker stuck in a C call, that join blocks teardown forever. Bound it with stop_timeout (FastAPI and Quart paths already join with a timeout); the following until_not then fails fast instead of hanging. - Add pytest-timeout (requirements/ci.txt, installed via the [ci] extra in every test job) and set a 180s per-test cap in pytest.ini. Any remaining hang now fails with a full thread stack dump naming the test, instead of stalling the step until the job-level timeout. --- dash/testing/application_runners.py | 7 +++++-- pytest.ini | 5 +++++ requirements/ci.txt | 1 + 3 files changed, 11 insertions(+), 2 deletions(-) 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/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"