From f16164da8865b119d836deb2653d1c4e5a5db9e0 Mon Sep 17 00:00:00 2001 From: Brandon Schabell Date: Fri, 2 Oct 2026 23:22:04 -0500 Subject: [PATCH] Fix flaky tests. Fix issue with input reverting to previous value during debounce --- .ai/PERFORMANCE.md | 7 +- .github/workflows/benchmarks.yml | 10 ++- CHANGELOG.md | 1 + .../src/components/Input.tsx | 4 +- .../tests/integration/dropdown/test_a11y.py | 82 +++++++++++-------- .../dropdown/test_dropdown_search_order.py | 33 +++++--- .../integration/input/test_number_input.py | 26 +++++- .../integration/store/test_component_props.py | 11 ++- .../tests/selenium/test_pagination.py | 3 +- .../test_basic_long_callback008.py | 8 +- tests/conftest.py | 17 +++- .../callbacks/test_basic_callback.py | 7 +- .../devtools/test_callback_validation.py | 8 +- .../integration/renderer/test_dependencies.py | 7 +- tests/integration/test_csp.py | 2 +- .../test_stream_callbacks_integration.py | 13 ++- tests/websocket/test_ws_get_prop.py | 1 + tests/websocket/test_ws_stream.py | 5 ++ 18 files changed, 162 insertions(+), 83 deletions(-) diff --git a/.ai/PERFORMANCE.md b/.ai/PERFORMANCE.md index 47d91f8321..7a4d3956f9 100644 --- a/.ai/PERFORMANCE.md +++ b/.ai/PERFORMANCE.md @@ -88,8 +88,11 @@ production bundle, runs the harness against `baseline.json`, and: over its absolute `fail_ms`, or `> 2x` the baseline p90 *after normalizing out machine speed* (see "Machine-independent gating" above); - **warns** (without failing) on a smaller drift - over `warn_ms`, or `> 1.3x` - the normalized baseline - and always upserts a single sticky **PR comment** - with the table (and the machine scale) so the numbers are visible on every run; + the normalized baseline - and always writes the table (and the machine + scale) to the **job summary** so the numbers are visible on every run; +- upserts a single sticky **PR comment** with the same table, for PRs from + branches in this repo only. Fork PRs get a read-only `GITHUB_TOKEN`, so the + comment is skipped and the job summary is the place to look; - uploads `results.json` + `summary.md` as artifacts. Thresholds live per-scenario in `scenarios.py` (`warn_ms` / `fail_ms`, keyed by diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 9558e05f40..09a192cb48 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -100,8 +100,14 @@ jobs: benchmarks/summary.md retention-days: 30 + - name: Write results to the job summary + if: always() + run: cat benchmarks/summary.md >> "$GITHUB_STEP_SUMMARY" || true + - name: Comment results on the PR - if: always() && github.event_name == 'pull_request' + if: >- + always() && github.event_name == 'pull_request' + && github.event.pull_request.head.repo.full_name == github.repository uses: actions/github-script@v7 with: script: | @@ -127,7 +133,7 @@ jobs: if: always() run: | if [ "${{ steps.bench.outputs.status }}" != "0" ]; then - echo "Benchmarks exceeded a hard (fail) threshold. See the PR comment." + echo "Benchmarks exceeded a hard (fail) threshold. See the job summary." exit 1 fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fb24b979c..9b5d15cb74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#3986](https://github.com/plotly/dash/pull/3986) Adjust `_run_before_hooks` in the `fastapi` backend to honor a response returned by a `before_request` function, matching the `flask` backend's behavior. ### Fixed +- [#4041](https://github.com/plotly/dash/pull/4041) Fix flaky integration tests by replacing fixed sleeps and immediate asserts with waits, and remove their `@flaky` retry markers. - [#3980](https://github.com/plotly/dash/pull/3980) Fix the three `before_request` hooks (`Dash._setup_server` and the pages `router_sync` / `router_async`) publishing their "already done" guard flag before the setup work behind it had run. Under a multi-threaded WSGI worker such as `gunicorn -k gthread` (or under an ASGI worker for the async router), a second request arriving mid-setup could observe the flag already set, skip setup, then read `registered_paths` / `callback_map` / the pages router callback while they were still being registered - causing the first burst of component bundle requests after a restart to 500 with `Error loading dependency. "" is not a registered library`, or the pages router to hit `DuplicateCallback` when two workers raced past the guard. Each hook body now runs under a lock (`threading.Lock` for the two sync hooks, an `asyncio.Lock` bound to the running loop for the async router) and only publishes the flag after all work completes. Fixes [#3971](https://github.com/plotly/dash/issues/3971). - [#3944](https://github.com/plotly/dash/pull/3944) Fix `dash.testing` runner backend detection for wrapped FastAPI/Quart servers so threaded Flask-only options are not passed to ASGI runners. - [#3955](https://github.com/plotly/dash/pull/3955) Unpin `selenium` in the testing requirements (was capped at `<=4.2.0` from 2022) and require `>=4.11.0`, so it can drive current stable Chrome via Selenium Manager and stop the widespread CI flakiness. diff --git a/components/dash-core-components/src/components/Input.tsx b/components/dash-core-components/src/components/Input.tsx index fc92c7b516..01df1e4f2c 100644 --- a/components/dash-core-components/src/components/Input.tsx +++ b/components/dash-core-components/src/components/Input.tsx @@ -208,9 +208,9 @@ function Input({ input.current.value = roundedValue.toString(); setValue(roundedValue.toString()); - onEvent(); + setPropValue(props.value, roundedValue); }, - [step, props.min, props.max, onEvent] + [step, props.min, props.max, props.value, setPropValue] ); useEffect(() => { diff --git a/components/dash-core-components/tests/integration/dropdown/test_a11y.py b/components/dash-core-components/tests/integration/dropdown/test_a11y.py index 846b836360..22f2375c36 100644 --- a/components/dash-core-components/tests/integration/dropdown/test_a11y.py +++ b/components/dash-core-components/tests/integration/dropdown/test_a11y.py @@ -1,6 +1,6 @@ import pytest -from flaky import flaky from dash import Dash, Input, Output +from dash.testing import wait from dash.dcc import Dropdown from dash.html import Div, Label, P, Span from selenium.common.exceptions import TimeoutException @@ -261,9 +261,6 @@ def test_a11y005_selection_visibility_multi(dash_duo): assert dash_duo.get_logs() == [] -# ActionChains keyboard nav depends on the menu input having focus, which can -# lag menu-open under CI load and drop a keystroke; retry the test when it does. -@flaky(max_runs=3) def test_a11y006_multi_select_keyboard_focus_retention(dash_duo): def send_keys(key): actions = ActionChains(dash_duo.driver) @@ -296,20 +293,21 @@ def update_output(value): dropdown = dash_duo.find_element("#dropdown") dropdown.click() dash_duo.wait_for_element(".dash-dropdown-options") + wait_for_focus(dash_duo, "search") # Select 3 items by alternating ArrowDown and Spacebar send_keys(Keys.ARROW_DOWN) # Move to first option - sleep(0.05) + wait_for_focus(dash_duo, "Option 0") send_keys(Keys.SPACE) # Select Option 0 dash_duo.wait_for_text_to_equal("#output", "Selected: ['Option 0']") send_keys(Keys.ARROW_DOWN) # Move to second option - sleep(0.05) + wait_for_focus(dash_duo, "Option 1") send_keys(Keys.SPACE) # Select Option 1 dash_duo.wait_for_text_to_equal("#output", "Selected: ['Option 0', 'Option 1']") send_keys(Keys.ARROW_DOWN) # Move to third option - sleep(0.05) + wait_for_focus(dash_duo, "Option 2") send_keys(Keys.SPACE) # Select Option 2 dash_duo.wait_for_text_to_equal( "#output", "Selected: ['Option 0', 'Option 1', 'Option 2']" @@ -400,24 +398,12 @@ def update_output(value): assert dash_duo.get_logs() == [] -@flaky(max_runs=3) def test_a11y008_home_end_pageup_pagedown_navigation(dash_duo): def send_keys(key): actions = ActionChains(dash_duo.driver) actions.send_keys(key) actions.perform() - def get_focused_option_text(): - return dash_duo.driver.execute_script( - """ - const focused = document.activeElement; - if (focused && focused.closest('.dash-options-list-option')) { - return focused.closest('.dash-options-list-option').textContent.trim(); - } - return null; - """ - ) - app = Dash(__name__) app.layout = Div( [ @@ -434,6 +420,7 @@ def get_focused_option_text(): dropdown = dash_duo.find_element("#dropdown") dropdown.send_keys(Keys.ENTER) # Open with Enter key dash_duo.wait_for_element(".dash-dropdown-options") + wait_for_focus(dash_duo, "search") # Navigate from search input to options send_keys(Keys.ARROW_DOWN) # Move from search to first option @@ -443,8 +430,7 @@ def get_focused_option_text(): send_keys(Keys.ARROW_DOWN) # Move to third option sleep(0.05) send_keys(Keys.ARROW_DOWN) # Move to fourth option - sleep(0.05) - assert get_focused_option_text() == "Option 3" + wait_for_focus(dash_duo, "Option 3") send_keys(Keys.HOME) # Should go back to search input (index 0) # Verify we're back at search input @@ -454,35 +440,29 @@ def get_focused_option_text(): # Now arrow down to first option send_keys(Keys.ARROW_DOWN) - sleep(0.1) - assert get_focused_option_text() == "Option 0" + wait_for_focus(dash_duo, "Option 0") # Test End key - should go to last option send_keys(Keys.END) - sleep(0.1) - assert get_focused_option_text() == "Option 49" + wait_for_focus(dash_duo, "Option 49") # Test PageUp - should jump up by 10 send_keys(Keys.PAGE_UP) - sleep(0.1) - assert get_focused_option_text() == "Option 39" + wait_for_focus(dash_duo, "Option 39") # Test PageDown - should jump down by 10 send_keys(Keys.PAGE_DOWN) - sleep(0.1) - assert get_focused_option_text() == "Option 49" + wait_for_focus(dash_duo, "Option 49") # Test PageUp from middle send_keys(Keys.HOME) # Back to search input (index 0) send_keys(Keys.PAGE_DOWN) # Jump to index 10 (Option 9) sleep(0.1) send_keys(Keys.PAGE_DOWN) # Jump to index 20 (Option 19) - sleep(0.1) - assert get_focused_option_text() == "Option 19" + wait_for_focus(dash_duo, "Option 19") send_keys(Keys.PAGE_UP) # Jump to index 10 (Option 9) - sleep(0.1) - assert get_focused_option_text() == "Option 9" + wait_for_focus(dash_duo, "Option 9") assert dash_duo.get_logs() == [] @@ -515,10 +495,11 @@ def update_output(value): dropdown = dash_duo.find_element("#dropdown") dropdown.click() dash_duo.wait_for_element(".dash-dropdown-search") + wait_for_focus(dash_duo, "search") # Type to filter, then Enter selects the first visible option send_keys("a") - sleep(0.1) + wait_for_option_texts(dash_duo, ["Apple", "Banana"]) send_keys(Keys.ENTER) dash_duo.wait_for_text_to_equal("#output", "Selected: ['Apple']") assert dash_duo.driver.execute_script( @@ -535,7 +516,7 @@ def update_output(value): # Filtering to a different option selects that one send_keys(Keys.BACKSPACE) send_keys("b") - sleep(0.1) + wait_for_option_texts(dash_duo, ["Banana"]) send_keys(Keys.ENTER) dash_duo.wait_for_text_to_equal("#output", "Selected: ['Banana']") @@ -570,9 +551,10 @@ def update_output(value): dropdown = dash_duo.find_element("#dropdown") dropdown.click() dash_duo.wait_for_element(".dash-dropdown-search") + wait_for_focus(dash_duo, "search") send_keys("a") - sleep(0.1) + wait_for_option_texts(dash_duo, ["Apple", "Banana"]) send_keys(Keys.ENTER) dash_duo.wait_for_text_to_equal("#output", "Selected: Apple") @@ -862,3 +844,31 @@ def test_a11y009_dropdown_component_labels_render_correctly(dash_duo): assert rendered_labels == ["red", "yellow", "blue"] assert dash_duo.get_logs() == [] + + +def wait_for_focus(dash_duo, target): + # The dropdown moves focus in a requestAnimationFrame, so a key sent before + # focus lands goes to the wrong element. target is "search" for the search + # input, or the text of an option. + wait.until( + lambda: dash_duo.driver.execute_script( + "const el = document.activeElement;" + "const option = el.closest('.dash-options-list-option');" + "return option ? option.textContent.trim() : el.type;" + ) + == target, + 3, + ) + + +def wait_for_option_texts(dash_duo, texts): + # Enter acts on the search value from the last render, so let the filter + # render first. + wait.until( + lambda: dash_duo.driver.execute_script( + "return [...document.querySelectorAll('.dash-dropdown-option')]" + ".map(o => o.textContent.trim());" + ) + == texts, + 3, + ) diff --git a/components/dash-core-components/tests/integration/dropdown/test_dropdown_search_order.py b/components/dash-core-components/tests/integration/dropdown/test_dropdown_search_order.py index bcc6bb684a..ac1b7e2abd 100644 --- a/components/dash-core-components/tests/integration/dropdown/test_dropdown_search_order.py +++ b/components/dash-core-components/tests/integration/dropdown/test_dropdown_search_order.py @@ -1,5 +1,5 @@ from dash import Dash, html, dcc, Input, Output -from flaky import flaky +from dash.testing import wait from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.action_chains import ActionChains from time import sleep @@ -39,14 +39,23 @@ def test_ddso001_search_preserves_custom_order(dash_duo): assert dash_duo.get_logs() == [] -# Keyboard nav here goes through ActionChains (document.activeElement); the -# selection depends on the menu input having focus, which can lag menu-open under -# CI load and drop a keystroke. Retry the whole test when that happens. -@flaky(max_runs=3) def test_ddso002_multi_search_preserves_custom_order(dash_duo): def send_keys(key): ActionChains(dash_duo.driver).send_keys(key).perform() + def wait_for_focus(target): + # The dropdown moves focus in a requestAnimationFrame, so a key sent + # before focus lands goes to the wrong element. + wait.until( + lambda: dash_duo.driver.execute_script( + "const el = document.activeElement;" + "const option = el.closest('.dash-options-list-option');" + "return option ? option.textContent.trim() : el.type;" + ) + == target, + 3, + ) + app = Dash(__name__) app.layout = html.Div( [ @@ -70,12 +79,13 @@ def update_output(value): dropdown = dash_duo.find_element("#dropdown") dropdown.click() dash_duo.wait_for_element(".dash-dropdown-options") + wait_for_focus("search") # Select '12' (second option) send_keys(Keys.ARROW_DOWN) sleep(0.2) send_keys(Keys.ARROW_DOWN) - sleep(0.2) + wait_for_focus("12") send_keys(Keys.SPACE) dash_duo.wait_for_text_to_equal("#output", "Selected: ['12']") sleep(0.2) @@ -84,7 +94,7 @@ def update_output(value): send_keys(Keys.ARROW_DOWN) sleep(0.2) send_keys(Keys.ARROW_DOWN) - sleep(0.2) + wait_for_focus("111") send_keys(Keys.SPACE) dash_duo.wait_for_text_to_equal("#output", "Selected: ['12', '111']") sleep(0.2) @@ -93,12 +103,13 @@ def update_output(value): send_keys(Keys.HOME) sleep(0.2) send_keys("1") - sleep(0.2) # Presents selected options first and rest in original order - options = dash_duo.find_elements(".dash-dropdown-option") - assert len(options) == 5 - assert [opt.text for opt in options] == ["12", "111", "11 Text", "112", "110"] + wait.until( + lambda: [opt.text for opt in dash_duo.find_elements(".dash-dropdown-option")] + == ["12", "111", "11 Text", "112", "110"], + 3, + ) assert dash_duo.get_logs() == [] diff --git a/components/dash-core-components/tests/integration/input/test_number_input.py b/components/dash-core-components/tests/integration/input/test_number_input.py index db6997c8cb..3a57273714 100644 --- a/components/dash-core-components/tests/integration/input/test_number_input.py +++ b/components/dash-core-components/tests/integration/input/test_number_input.py @@ -53,7 +53,7 @@ def test_inni003_invalid_numbers_range(dash_dcc, input_range_app): elem_range = dash_dcc.find_element("#range") elem_range.send_keys("1999") - assert dash_dcc.find_element("#out").text == "1999" + dash_dcc.wait_for_text_to_equal("#out", "1999") for invalid_number in ("0.0", "12", "10e10"): elem_range.send_keys(invalid_number) @@ -283,3 +283,27 @@ def update_output(val): dash_dcc.wait_for_text_to_equal("#output", "16") assert dash_dcc.get_logs() == [] + + +def test_inni012_stepper_clicks_during_debounce(dash_dcc, debounce_number_app): + dash_dcc.start_server(debounce_number_app) + + input_elem = dash_dcc.find_element("#input-fast") + increment_btn = dash_dcc.find_element("#input-fast~.dash-stepper-increment") + decrement_btn = dash_dcc.find_element("#input-fast~.dash-stepper-decrement") + + input_elem.send_keys("100") + dash_dcc.wait_for_text_to_equal("#div-fast", "100") + + # Each click lands inside the 0.25s debounce started by the previous one. + for _ in range(5): + increment_btn.click() + dash_dcc.wait_for_text_to_equal("#div-fast", "103") + decrement_btn.click() + dash_dcc.wait_for_text_to_equal("#div-fast", "100") + + time.sleep(0.5) + assert input_elem.get_attribute("value") == "100" + assert dash_dcc.find_element("#div-fast").text == "100" + + assert dash_dcc.get_logs() == [] diff --git a/components/dash-core-components/tests/integration/store/test_component_props.py b/components/dash-core-components/tests/integration/store/test_component_props.py index d2e8ebd15e..5456997f6b 100644 --- a/components/dash-core-components/tests/integration/store/test_component_props.py +++ b/components/dash-core-components/tests/integration/store/test_component_props.py @@ -1,6 +1,5 @@ import json import time -from pytest import approx from dash import Dash, Input, State, Output, dcc, html from dash.exceptions import PreventUpdate import dash.testing.wait as wait @@ -57,22 +56,22 @@ def init_output(ts, data): dash_dcc.start_server(app) + before = int(time.time() * 1000) dash_dcc.find_element("#set-init-storage").click() - # the python ts ends at seconds while javascript one ends at ms - ts = float(time.time() * 1000) wait.until( lambda: "initialized" in dash_dcc.find_element("#init-output").text, timeout=3 ) + after = time.time() * 1000 output_data = json.loads(dash_dcc.find_element("#init-output").text) assert ( output_data.get("data") == "initialized" ), "the data should be the text set in on_init" - assert ts == approx( - output_data.get("ts"), abs=40 - ), "the modified_timestamp should be updated right after the click action" + assert ( + before <= output_data.get("ts") <= after + ), "the modified_timestamp should be updated by the click action" assert dash_dcc.get_logs() == [] diff --git a/components/dash-table/tests/selenium/test_pagination.py b/components/dash-table/tests/selenium/test_pagination.py index 3e427a3712..1f5108b693 100644 --- a/components/dash-table/tests/selenium/test_pagination.py +++ b/components/dash-table/tests/selenium/test_pagination.py @@ -1,6 +1,7 @@ import dash from dash import Input, Output, html, dcc from dash.exceptions import PreventUpdate +from dash.testing import wait from dash.dash_table import DataTable import pytest @@ -242,7 +243,7 @@ def test_tpag011_valid_page(test): target = test.table("table") test.find_element("#button").click() - assert target.paging.current.get_value() == "1" + wait.until(lambda: target.paging.current.get_value() == "1", 3) assert test.get_log_errors() == [] test.table("table").is_ready() diff --git a/tests/background_callback/test_basic_long_callback008.py b/tests/background_callback/test_basic_long_callback008.py index dcd70420f8..9856cb50f6 100644 --- a/tests/background_callback/test_basic_long_callback008.py +++ b/tests/background_callback/test_basic_long_callback008.py @@ -54,9 +54,11 @@ def make_expect(n): multi = dash_duo.wait_for_element("#multi-output") for i in range(1, 4): - with app.test_lock: - multi.click() - dash_duo.wait_for_element("#multi-output:disabled") + # No app.test_lock here: app_error never takes it, and a diskcache write + # from this thread while the server forks the job leaves the job unable + # to save its result. + multi.click() + dash_duo.wait_for_element("#multi-output:disabled") expect = make_expect(i) dash_duo.wait_for_text_to_equal("#output-status", f"Updated: {i}") for j, e in enumerate(expect): diff --git a/tests/conftest.py b/tests/conftest.py index 783422f89b..e0da617b33 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,8 +6,16 @@ from dash._configs import DASH_ENV_VARS -@pytest.hookimpl(trylast=True) +_session_exitstatus = 0 + + def pytest_sessionfinish(session, exitstatus): + global _session_exitstatus # pylint: disable=global-statement + _session_exitstatus = exitstatus + + +@pytest.hookimpl(trylast=True) +def pytest_unconfigure(config): # CI safety net for the background/async suites. After pytest has finished # and written its reports, those suites can leave non-daemon threads or # workers (celery, diskcache, lingering test servers) that keep the @@ -15,12 +23,13 @@ def pytest_sessionfinish(session, exitstatus): # timeout even though every test passed. pytest-timeout only bounds # individual tests, not this post-session shutdown. When DASH_TEST_FORCE_EXIT # is set we hard-exit once the session is done so the step can't hang. - # Gated by the env var so local runs and other jobs are unaffected; runs - # trylast so the junit report is already written. + # Gated by the env var so local runs and other jobs are unaffected. This + # runs in pytest_unconfigure, not pytest_sessionfinish, so the terminal + # summary with the failure tracebacks is printed before we exit. if os.environ.get("DASH_TEST_FORCE_EXIT"): sys.stdout.flush() sys.stderr.flush() - os._exit(int(exitstatus)) + os._exit(int(_session_exitstatus)) @pytest.fixture diff --git a/tests/integration/callbacks/test_basic_callback.py b/tests/integration/callbacks/test_basic_callback.py index 3d8f94bd57..0cd8ea2500 100644 --- a/tests/integration/callbacks/test_basic_callback.py +++ b/tests/integration/callbacks/test_basic_callback.py @@ -417,11 +417,16 @@ def update_text(data): input1 = dash_duo.find_element("#input") dash_duo.clear_input(input1) + dash_duo.wait_for_element('#output-1[data-cb=""]') + # Wait for each key's callback before the next key, or dcc.Input can merge + # quick keystrokes into one value change and one call. + typed = "" for key in "hello world": with lock: input1.send_keys(key) - time.sleep(0.05) # Small delay to prevent callback debouncing + typed += key + dash_duo.wait_for_element(f'#output-1[data-cb="{typed}"]') 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/devtools/test_callback_validation.py b/tests/integration/devtools/test_callback_validation.py index e4f89432d5..d9171fe4d9 100644 --- a/tests/integration/devtools/test_callback_validation.py +++ b/tests/integration/devtools/test_callback_validation.py @@ -2,7 +2,6 @@ import flask import pytest -from flaky import flaky from dash import ( Dash, @@ -32,7 +31,10 @@ def check_errors(dash_duo, specs): found = [] for i in range(cnt): - msg = dash_duo.find_elements(".dash-fe-error__title")[i].text + title = dash_duo.find_elements(".dash-fe-error__title")[i] + # Error cards fade in from opacity 0 and Selenium reads the text of an + # invisible element as "", so wait for the title to become readable. + msg = wait.until(lambda: title.text, 3) # plotly-cloud (a default install dep) injects a `_plotly-cloud-*` # component into every layout via a dash_hooks entry point, so it # shows up in the "string ids in the current layout" list. Strip it @@ -150,7 +152,6 @@ def x(a): check_errors(dash_duo, specs) -@flaky(max_runs=3) def test_dvcv003_duplicate_outputs_same_callback(dash_duo): app = Dash(__name__) app.layout = html.Div([html.Div(id="a"), html.Div(id="b")]) @@ -667,7 +668,6 @@ def c2(children): check_errors(dash_duo, specs) -@flaky(max_runs=3) def test_dvcv013_circular_3_step(dash_duo): app = Dash(__name__) diff --git a/tests/integration/renderer/test_dependencies.py b/tests/integration/renderer/test_dependencies.py index fc23f6b729..017d55f4e4 100644 --- a/tests/integration/renderer/test_dependencies.py +++ b/tests/integration/renderer/test_dependencies.py @@ -2,10 +2,7 @@ from dash import Dash, html, dcc, Input, Output -from flaky import flaky - -@flaky(max_runs=3) def test_rddp001_dependencies_on_components_that_dont_exist(dash_duo): app = Dash(__name__, suppress_callback_exceptions=True) app.layout = html.Div( @@ -30,11 +27,11 @@ def update_output_2(value): dash_duo.start_server(app) - assert dash_duo.find_element("#output-1").text == "initial value" + dash_duo.wait_for_text_to_equal("#output-1", "initial value") assert output_1_call_count.value == 1 and output_2_call_count.value == 0 dash_duo.find_element("#input").send_keys("a") - assert dash_duo.find_element("#output-1").text == "initial valuea" + dash_duo.wait_for_text_to_equal("#output-1", "initial valuea") assert output_1_call_count.value == 2 and output_2_call_count.value == 0 diff --git a/tests/integration/test_csp.py b/tests/integration/test_csp.py index 2b2c547d08..a3cff041a3 100644 --- a/tests/integration/test_csp.py +++ b/tests/integration/test_csp.py @@ -55,4 +55,4 @@ def test_incs001_csp_hashes_inline_scripts( dash_duo.start_server(app) dash_duo.find_element("#input_element").send_keys("xyz") - assert dash_duo.wait_for_element("#output_element").text == "xyz" + dash_duo.wait_for_text_to_equal("#output_element", "xyz") diff --git a/tests/streaming/test_stream_callbacks_integration.py b/tests/streaming/test_stream_callbacks_integration.py index 86f45bbbf7..98ae133cbd 100644 --- a/tests/streaming/test_stream_callbacks_integration.py +++ b/tests/streaming/test_stream_callbacks_integration.py @@ -1,5 +1,6 @@ """Browser integration tests for streaming callbacks over HTTP (NDJSON).""" import asyncio +import threading import time from dash import ( @@ -24,25 +25,29 @@ def test_stst001_stream_progressive_render(dash_duo): ] ) + step_2 = threading.Event() + done = threading.Event() + @app.callback( Output("out", "children"), Input("btn", "n_clicks"), prevent_initial_call=True, ) async def stream_cb(n): - # Each step stays up well past the driver's 0.5s poll interval, so - # the wait below cannot miss it. + # Hold each step until the test has seen it. yield "step-1" - await asyncio.sleep(1.0) + await asyncio.to_thread(step_2.wait, 10) yield "step-2" - await asyncio.sleep(1.0) + await asyncio.to_thread(done.wait, 10) yield "done" dash_duo.start_server(app) dash_duo.find_element("#btn").click() # Each yield renders while the callback is still running. dash_duo.wait_for_text_to_equal("#out", "step-1") + step_2.set() dash_duo.wait_for_text_to_equal("#out", "step-2") + done.set() dash_duo.wait_for_text_to_equal("#out", "done") assert dash_duo.get_logs() == [] diff --git a/tests/websocket/test_ws_get_prop.py b/tests/websocket/test_ws_get_prop.py index 178f855fce..983cb85465 100644 --- a/tests/websocket/test_ws_get_prop.py +++ b/tests/websocket/test_ws_get_prop.py @@ -135,6 +135,7 @@ async def read_callback(_): return json.dumps([before, after, dynamic]) dash_duo.start_server(app) + dash_duo.wait_for_element("#read") dash_duo.driver.execute_script( "window.dash_clientside.set_props(arguments[0], " "{data: {records: [{value: 'browser'}]}})", diff --git a/tests/websocket/test_ws_stream.py b/tests/websocket/test_ws_stream.py index 262cdd3d79..d0e34d490a 100644 --- a/tests/websocket/test_ws_stream.py +++ b/tests/websocket/test_ws_stream.py @@ -7,6 +7,7 @@ """ import asyncio import json +import threading import pytest @@ -128,6 +129,8 @@ def test_wsst004_browser_stream_over_websocket(dash_duo): ] ) + seen_first_frame = threading.Event() + @app.callback( Output("out", "children"), Input("btn", "n_clicks"), @@ -135,6 +138,7 @@ def test_wsst004_browser_stream_over_websocket(dash_duo): ) async def stream_cb(n): yield "streaming" + await asyncio.to_thread(seen_first_frame.wait, 10) for token in ["a", "b", "c"]: await asyncio.sleep(0.2) patch = Patch() @@ -146,6 +150,7 @@ async def stream_cb(n): dash_duo.find_element("#btn").click() # Intermediate frame renders before the stream finishes. dash_duo.wait_for_text_to_equal("#out", "streaming") + seen_first_frame.set() # Patch frames appended exactly once each. dash_duo.wait_for_text_to_equal("#out", "streamingabc") assert dash_duo.get_logs() == []