Skip to content

fix: stabilize flaky UI tests on CI shared runners#211

Merged
BrunoCerberus merged 2 commits intomasterfrom
fix/flaky-ui-tests-ci
Mar 11, 2026
Merged

fix: stabilize flaky UI tests on CI shared runners#211
BrunoCerberus merged 2 commits intomasterfrom
fix/flaky-ui-tests-ci

Conversation

@BrunoCerberus
Copy link
Owner

Summary

  • Enhanced navigateBack() with retry logic — adds post-tap wait (0.5s), optional nav bar verification, and automatic retry when taps are swallowed on slow CI runners
  • Fixed "Failed to terminate" teardown errors — waits up to 10s for app termination to complete before next test launches
  • Increased accessibility audit stabilization waits (2s → 3-4s) and consolidated issue filters to handle CI accessibility tree readiness
  • Fixed search field clear race condition — added proper UI propagation wait and placeholder value comparison
  • Replaced synchronous .exists with waitForAny() in MediaUITests for CI reliability
  • Switched waitForExistencesafeWaitForExistence in category tab filter to avoid Xcode 26 C++ exception crashes

Context

Analysis of the last 5 CI pipeline runs showed UI tests failing intermittently (3/5 runs) while passing locally. The most consistently flaky tests were:

Test CI Failure Rate Root Cause
testHomeContentInteractionsAndSettingsFlow 3/3 Navigation back tap swallowed on slow CI
testMediaAccessibilityAudit 2/3 Accessibility tree not stable before audit
testMediaTabShowsSegmentedControl 2/3 "Failed to terminate" + synchronous check
testNavigationFlow 1/3 Same navigation back timing issue
testSearchFlow 1/3 Clear button race condition

Test plan

  • All 40 UI tests pass locally (0 failures, 0 skips)
  • CI pipeline passes on this PR
  • Verify no regressions in unit or snapshot tests

🤖 Generated with Claude Code

Address timing and synchronization issues causing intermittent UI test
failures on GitHub Actions shared runners while passing locally.

Key fixes:
- Enhanced navigateBack() with post-tap wait, retry logic, and optional
  nav bar verification to handle swallowed taps on slow CI
- Added termination wait in tearDown to prevent "Failed to terminate"
  errors between consecutive tests
- Increased accessibility audit stabilization waits for CI accessibility
  tree readiness
- Fixed search field clear race condition with proper UI propagation wait
- Replaced synchronous .exists checks with waitForAny() for CI reliability
- Switched waitForExistence to safeWaitForExistence to avoid Xcode 26
  C++ exception crashes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Mar 11, 2026

Claude finished @BrunoCerberus's task in 2m 14s —— View job


Review: fix/flaky-ui-tests-ci

PR approved. Well-targeted fixes for CI flakiness backed by solid root cause analysis of the last 5 pipeline runs.

  • Read all 6 changed files and analyze the diff
  • Review BaseUITestCase.swift — termination wait + navigateBack retry logic
  • Review AccessibilityAuditTests.swift — consolidated audit handler + increased waits
  • Review HomeUITests.swift — multi-layered navigation recovery
  • Review MediaUITests.swiftwaitForAny() replacing synchronous .exists
  • Review NavigationUITests.swiftnavigateBack(waitForNavBar:) adoption
  • Review PulseSearchUITests.swift — search field clear race condition fix

Summary

Changes are well-structured and address each flaky test with a targeted fix:

Change Assessment
navigateBack(waitForNavBar:) retry pattern Clean solution for swallowed taps on slow CI runners
Termination wait in tearDown (10s) Directly addresses "Failed to terminate" cascade errors
Consolidated auditIssueHandler + auditTypes Good DRY improvement — was duplicated across 5 tests
Search field placeholderValue comparison Correctly handles iOS behavior where cleared field returns placeholder as .value
waitForAny() in MediaUITests Right pattern for CI vs. synchronous .exists checks
safeWaitForExistence in category filter Consistent with the Xcode 26 C++ exception crash mitigation

Minor observations (non-blocking):

  • Recovery path in navigateBack doesn't verify success after retry, but callers handle this with their own assertions — acceptable
  • Wait times are tuned empirically (0.5s–4.0s) and may need adjustment as CI environment changes, but that's inherent to UI test stabilization work

Comment on lines +477 to +491
// If a target nav bar was specified, wait for it to appear with retry
if let navBarTitle {
if !safeWaitForExistence(app.navigationBars[navBarTitle], timeout: timeout) {
// Recovery: try navigating back again (tap may have been swallowed)
let retryBack = app.buttons["backButton"]
if retryBack.exists {
let center = retryBack.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
center.tap()
wait(for: 0.5)
} else {
app.swipeRight()
wait(for: 0.5)
}
}
}
Copy link

Choose a reason for hiding this comment

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

Nit: After the retry tap, this doesn't verify whether the navigation actually succeeded. If the nav bar still doesn't appear after the retry, the caller may not realize navigation failed silently. Consider returning a Bool or adding a final safeWaitForExistence check so call sites can assert on the outcome.

That said, the call sites currently do their own assertion after calling navigateBack(waitForNavBar:), so this is a minor concern — no change needed right now.

navigateToMediaTab()
wait(for: 2.0)
// Media tab loads async content — give extra time for the accessibility tree to stabilize
wait(for: 4.0)
Copy link

Choose a reason for hiding this comment

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

The Media tab gets 4.0s stabilization while all other tabs get 3.0s. The comment explains why (async content), which is good. Just noting that if CI flakiness persists on other tabs, you have room to bump them too — but this asymmetry seems reasonable for now.

Comment on lines 216 to 228
if !returnedToHome {
// Recovery: navigate back via Home tab
navigateToTab("Home")
// Recovery attempt 1: try back button again (tap may have been swallowed on slow CI)
let retryBack = app.navigationBars.buttons.firstMatch
if retryBack.exists {
let center = retryBack.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
center.tap()
wait(for: 1.0)
}
// Recovery attempt 2: navigate back via Home tab
if !safeWaitForExistence(app.navigationBars["News"], timeout: 5) {
navigateToTab("Home")
}
}
Copy link

Choose a reason for hiding this comment

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

Good multi-layered recovery strategy. The graduated approach (retry back button -> then fall back to Home tab) is well-structured and should handle the two most common CI failure modes (swallowed tap vs. stuck navigation state).

Comment on lines 144 to +147
var fieldValue = app.searchFields.firstMatch.value as? String ?? ""
let placeholderValue = app.searchFields.firstMatch.placeholderValue ?? ""
var isCleared = emptyStateReturned || !fieldValue.lowercased().contains("apple")
|| fieldValue == placeholderValue
Copy link

Choose a reason for hiding this comment

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

Good fix for the race condition. The placeholderValue comparison is important because on iOS, after clearing a search field, the .value property often returns the placeholder text rather than an empty string. Without this check, the assertion would false-fail when the field was correctly cleared.

Copy link

@claude claude bot left a comment

Choose a reason for hiding this comment

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

LGTM - well-targeted fixes for CI flakiness with good root cause analysis.

Key strengths:

  • The navigateBack(waitForNavBar:) retry pattern is a clean solution for swallowed taps on slow CI
  • Termination wait in tearDown directly addresses the "Failed to terminate" cascade
  • Consolidating accessibility audit issue handlers into a shared method is good DRY improvement
  • Search field placeholder value comparison correctly handles the iOS search field clear behavior
  • Using waitForAny() instead of synchronous .exists in MediaUITests is the right pattern for CI

Minor observations (non-blocking):

  • The recovery path in navigateBack doesn't verify success after retry, but callers handle this with their own assertions
  • Wait times are tuned empirically (0.5s, 1.0s, 1.5s, etc.) — these may need adjustment as CI environment changes, but that's inherent to UI test stabilization

The testMediaTabShowsSegmentedControl crash ("Timed out while evaluating
UI query") was caused by waitForAny polling .exists on 3 elements every
0.1s, overwhelming the accessibility framework on CI shared runners.

Changes:
- Replace waitForAny with safeWaitForExistence on single element in
  testMediaTabShowsSegmentedControl to minimize UI query pressure
- Increase waitForAny/waitForAnyMatch polling interval from 0.1s to 0.5s
  globally to reduce accessibility framework load across all tests
- Add immediate check before entering polling loop for faster returns
- Increase Media tab render wait from 2s to 3s for CI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Mar 11, 2026

Claude finished @BrunoCerberus's task in 2m 13s —— View job


Review: fix/flaky-ui-tests-ci (updated)

PR approved. The latest push (897fb9e) adds UI query pressure reduction on top of the existing CI stabilization fixes. Both commits together form a well-targeted set of changes backed by clear root cause analysis.

  • Read PR diff and all 6 changed files
  • Review BaseUITestCase.swift — termination wait, navigateBack retry, polling interval changes
  • Review AccessibilityAuditTests.swift — consolidated audit handler + increased waits
  • Review HomeUITests.swift — multi-layered navigation recovery + safeWaitForExistence
  • Review MediaUITests.swift — stabilization wait + single-element query pattern
  • Review NavigationUITests.swiftnavigateBack(waitForNavBar:) adoption
  • Review PulseSearchUITests.swift — search field clear race condition fix

Summary

All changes are well-structured and each fix maps directly to a specific CI failure mode:

Change File Assessment
Termination wait (10s) in tearDown BaseUITestCase.swift:121-124 Directly addresses "Failed to terminate" cascade — clean fix
navigateBack(waitForNavBar:) retry pattern BaseUITestCase.swift:471-498 Coordinate-based tap + retry for swallowed taps on slow CI
Polling interval 0.1s → 0.5s in waitForAny/waitForAnyMatch BaseUITestCase.swift:406-434 Reduces UI query pressure; immediate check before loop preserves fast-path
Consolidated auditIssueHandler + auditTypes AccessibilityAuditTests.swift:14-29 Good DRY improvement — was duplicated across 5 tests
Multi-layered Settings back recovery HomeUITests.swift:216-228 Graduated approach (retry tap → Home tab fallback) handles two failure modes
safeWaitForExistence for category filter HomeUITests.swift:102 Consistent with Xcode 26 C++ exception crash mitigation
Stabilization wait + single-element query MediaUITests.swift:38-57 Right approach to reduce concurrent UI queries on CI
navigateBack(waitForNavBar: "News") adoption NavigationUITests.swift:61,87 Consistent use of the new retry-enabled navigation
Search field placeholderValue comparison PulseSearchUITests.swift:145-154 Correctly handles iOS behavior where cleared field returns placeholder as .value
Post-clear wait (1.0s) PulseSearchUITests.swift:140 Addresses race condition on slow CI simulators

Minor observations (non-blocking):

  • The consolidated auditIssueHandler now applies UISearchBar and Label not human-readable filters to all audit tests (previously only Search and Settings respectively). This is a reasonable broadening since these are system component issues.
  • Recovery path in navigateBack doesn't verify success after retry, but callers handle this with their own assertions — acceptable pattern.
  • Wait times are empirically tuned (0.5s–4.0s) and may need adjustment as CI environment changes, but that's inherent to UI test stabilization work.

Copy link

@claude claude bot left a comment

Choose a reason for hiding this comment

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

LGTM — well-targeted CI stabilization fixes with solid root cause analysis. The changes correctly address each failure mode (swallowed taps, termination races, UI query pressure, search field clearing) without over-engineering.

@BrunoCerberus BrunoCerberus merged commit 83ee1c3 into master Mar 11, 2026
7 checks passed
@BrunoCerberus BrunoCerberus deleted the fix/flaky-ui-tests-ci branch March 11, 2026 07:24
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.

1 participant