Skip to content

Unpin selenium to fix CI flakiness against current Chrome - #3955

Merged
T4rk1n merged 26 commits into
devfrom
fix/unpin-selenium
Aug 21, 2026
Merged

Unpin selenium to fix CI flakiness against current Chrome#3955
T4rk1n merged 26 commits into
devfrom
fix/unpin-selenium

Conversation

@T4rk1n

@T4rk1n T4rk1n commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Problem

Recent pushes and PRs have been going red intermittently across the browser-based integration tests. The failures are scattered across unrelated Selenium tests (test_persistence, test_csp, test_multi_output, test_derived_props, async callbacks, table server tests…), a different subset each run, all surfacing as StaleElementReferenceException / TimeoutException. That pattern is environmental flakiness, not a bad merge (which would fail the same test deterministically).

Root cause

  • requirements/testing.txt pinned selenium>=3.141.0,<=4.2.0 (selenium 4.2.0 is from 2022).
  • Every CI browser job uses browser-actions/setup-chrome@v1 with chrome-version: stableunpinned — so CI now installs Chrome 151.
  • Selenium 4.2 predates Selenium Manager (added in 4.6), so it cannot reliably provision/drive current Chrome, producing timing/staleness flakiness.

Two amplifiers: the dependabot pip bump that would have raised selenium never landed on dev, and the recent React 18/19 test matrix roughly doubled the browser shards, so a single flake reddens the whole run more often.

Fix

Bump the pin to selenium>=4.11.0,<=4.46.0. The >=4.11.0 floor guarantees a mature Selenium Manager that auto-provisions a chromedriver matching whatever stable Chrome CI installs (this is why install-chromedriver: false in the setup step remains correct).

Compatibility checks

  • No removed find_element_by_* APIs anywhere in dash/ (those were dropped in selenium 4.3).
  • Driver construction already uses the modern API: webdriver.Chrome(options=...) / webdriver.Remote(command_executor=..., options=...).
  • Verified the full API surface dash/testing/browser.py uses, plus Selenium Manager availability, against selenium 4.46.0.

Follow-ups (not in this PR)

  • A few tests exhaust all 3 reruns (e.g. test_async_cbsc001_simple_callback) and may be genuinely broken rather than flaky — worth a targeted look once this settles the noise.
  • Optionally pin Chrome for fully reproducible runs; modern selenium tracks stable fine either way.

T4rk1n added 2 commits August 18, 2026 16:39
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.
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.*, ...).
@T4rk1n

T4rk1n commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up commit 1845c32: the first CI run surfaced two deterministic breaks that the old selenium<=4.2.0 cap had been masking (both now fixed):

  1. dash/testing/browser.py set the Firefox marionette capability, which modern selenium/geckodriver reject (InvalidArgumentException: marionette is not the name of a known capability). It's the implicit protocol now — removed. This is what failed the Lint & Unit jobs (test_browser_smoke[Firefox]).
  2. Three test modules used the find_element(s)_by_* helper methods that selenium removed in 4.3 (AttributeError: 'WebElement' object has no attribute 'find_element_by_tag_name'). Migrated to find_element(s)(By.*, ...). This failed a Main Dash Chrome group.

Remaining StaleElementReferenceException flakes in that first run should be resolved by the selenium bump itself (matching driver for Chrome 151). Re-running CI to confirm.

T4rk1n added 4 commits August 19, 2026 10:57
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.
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.
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.
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.
@T4rk1n

T4rk1n commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

CI verified — systemic flakiness fixed

Full run + targeted re-runs of the flaky jobs. The two structural causes of the cross-PR flakiness are resolved:

  • selenium/Chrome mismatch — the pinned selenium 4.2.0 couldn't drive the current stable Chrome (151); bumped to >=4.11.0 (Selenium Manager auto-provisions a matching driver). Exposed and fixed four deterministic selenium-4 breaks along the way: marionette capability, removed find_element_by_*, move_to_element_with_offset center-origin, and TimeoutException.args[0].msg.
  • "Setup virtual display" hang — the step ran apt-get install -y xvfb, which intermittently blocked on the runner's dpkg lock. xvfb is preinstalled on the runners, so the apt-get calls were removed; the step now just starts Xvfb. 26 display-step instances ran with zero hangs.

Remaining reds are pre-existing flakes, not regressions

On re-run, the DCC and Main Dash failures (test_rdcap003, test_msmh003 markdown re-render race, test_inni004) all passed — confirmed flaky. The one stubborn failure, test_async_cbsc001_simple_callback (AssertionError: initial count + each key stroke), is already failing on dev HEAD (run 32175191617), so it predates this branch.

Recommend merging this PR (it strictly improves CI) and tracking the async-callback test failure + the racy per-test flakes in a separate issue.

T4rk1n added 2 commits August 19, 2026 13:15
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.
…meout

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.
@T4rk1n

T4rk1n commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

✅ Full green run

Run 32283425251 passed with zero failed jobs after the anti-hang + gating fixes.

The two systemic problems are gone:

  • No more hangs. The 'Run Async Callback Tests' step was wedging until the job timeout. Root cause: ThreadedRunner.stop()'s Flask path did an unbounded self.thread.join(); if the SystemExit thread-kill fails to unwind a worker, teardown blocks forever. Bounded it (FastAPI/Quart already were). Added pytest-timeout (180s/test, via the [ci] extra) so any future hang fails with a stack dump naming the test instead of stalling the step.
  • Callback-count flakes fixed. cbsc001/cbsc008 (async + sync) asserted an exact one-callback-per-keystroke count the renderer never guaranteed — it coalesces same-identity callbacks still queued in requested state. Rewrote them to gate each keystroke on the previous callback having executed (until(...)), so coalescing can't happen; dropped the Lock/sleep/@flaky. Async step now 26 passed, background 36 passed.

Remaining occasional reds (test_lcbc017, test_persistence, test_rdcap003, test_dveh002) are the pre-existing low-frequency flake tail — they cleared on re-run and predate this branch.

Optional follow-up (not in this PR): add if: ${{ !cancelled() }} to the Async step so a Background flake stops skipping it, and the graceful-Flask-shutdown change to cut the teardown-kill noise at the source.

T4rk1n added 8 commits August 20, 2026 11:48
test_tdrp004_navigate_selected_cells read the derived-prop display cells
with one-shot find_element().get_attribute() while keystrokes were still
firing. props_container re-renders wholesale on every table-prop change,
so the element went stale between find and read, failing Table Group 1
consistently once selenium was unpinned.

Add a wait_prop() helper that re-finds the element each poll and waits for
the value to settle, and use it for the tab-navigation assertions.
grbs007: the clickData callback also fires on load with clickData=None,
setting the textarea to "null". The test read the value right after the
click and raced that initial value - data != "" passed but json.loads
returned None. Wait for the real click payload before parsing.

dvcv003: the devtools error overlay intermittently reports an empty
error title under React 19; mark it @flaky(max_runs=3), matching the
existing convention for these overlay tests in this file (dvcv013).
test_rdcap003_side_effect_regression clicked #a and then counted the
checklist options synchronously, racing the opts callback that re-renders
them - so it read the previous count (assert 2 == 3). @flaky did not help
because a slow runner loses the race on every rerun. Poll for the
expected option count instead.
msmh003: counted the re-highlighted <span>s synchronously after the
click, racing the callback-driven markdown swap (assert 2 == 3). Poll for
the new span count.

msps001: #dropdownsingle typed "one" + Enter with no wait, so Enter could
fire before the list filtered and nothing was selected - the field then
persisted as null. Wait for the filtered option before pressing Enter,
mirroring the #dropdownmulti path.
test_arb008_set_props_chain_cb clicked #generated-button via
wait_for_element().click(); the button re-renders as its n_clicks updates,
so the handle went stale between find and click and threw
StaleElementReferenceException - failing all 3 @flaky reruns across
multiple runs once selenium was unpinned. Re-find and retry the click
until it lands.

@camdecoster camdecoster left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks fine. Would it be worth pinning Chrome to keep a stable test environment?

Comment thread CHANGELOG.md Outdated
Co-authored-by: Cameron DeCoster <cameron.decoster@gmail.com>
@T4rk1n

T4rk1n commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Looks fine. Would it be worth pinning Chrome to keep a stable test environment?

I'd rather not pin chrome, when it's pinned it accumulates regressions we don't see on tests and it takes longer to fix afterwards (This took 3 days because selenium was pinned for so long).

T4rk1n added 6 commits August 21, 2026 11:47
dveh002: read #output synchronously after 3 clicks, racing the callback
and seeing the initial 'button clicks: 0'. Poll with wait_for_text_to_equal.

msps001: clicked #dropdownmulti while the #dropdownsingle menu overlay was
still closing, so the click was intercepted. Wait for the single dropdown's
menu to close first.
empt001: counted table rows synchronously right after clicking clear,
racing the callback (assert 3 == 0). Poll for the rows to drop.

msps001: select #dropdownsingle by clicking the filtered option instead of
pressing Enter - Enter did not reliably close the menu under load, leaving
an overlay that intercepted the next dropdown's click.
The background-callbacks job repeatedly wedged its step for the full 15-min
timeout even though every test passed: after pytest prints its summary, the
suite leaves non-daemon workers (celery/diskcache/lingering servers) that
block interpreter shutdown. pytest-timeout only bounds individual tests, not
this post-session shutdown.

Add a trylast pytest_sessionfinish hook (tests/conftest.py) that hard-exits
via os._exit once the session is done - after the junit report is written and
with the real exit status preserved - gated by DASH_TEST_FORCE_EXIT so local
runs and other jobs are unaffected. Set that env on the background-callbacks
job.
test_ddso002 drove keyboard navigation with ActionChains send_keys, which
target document.activeElement; right after the menu opens the search input
may not have focus yet, so ARROW_DOWN/SPACE were dropped and no option was
selected (wait_for_text timed out). Send the keys to the .dash-dropdown-search
element directly, which selenium focuses first.
ddso002: sending nav keys straight to the search input broke selection
deterministically (SPACE typed a space instead of selecting). Restore the
ActionChains approach and mark @flaky(max_runs=3) instead - the real flake is
menu-input focus lagging menu-open, which needs the focused activeElement.

rdmo002: the 'with lock' gating did not stop the renderer coalescing queued
callbacks, so call_count came up short of 7. Gate each keystroke on its
callback landing (wait.until) before sending the next.
test_rdps008 counted .column-header--delete synchronously right after
clicking #deletable, racing the callback that re-renders the table (assert
1 == 0). Poll for the expected count with wait.until.
T4rk1n added 3 commits August 21, 2026 13:46
test_a11y006 and test_a11y008 drive keyboard navigation with ActionChains
send_keys, which target document.activeElement; right after the menu opens
the input may not have focus yet, dropping a keystroke (timeout, or landing
on Option 2 instead of Option 3). Same class as ddso002 - sending keys to the
input element instead breaks selection, so retry with @flaky(max_runs=3).
The threaded test runner handed out ports from a monotonic counter without
checking availability. A previous test's server can linger on its port for a
moment after teardown, so reusing that number failed with 'address already in
use'; the test then hung until the per-test timeout (seen intermittently in the
async/background suites). Probe each candidate port and skip any still bound
before starting the server.
@sonarqubecloud

Copy link
Copy Markdown

@T4rk1n
T4rk1n merged commit 388f934 into dev Aug 21, 2026
53 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants