Stabilize iOS UI test foundation and fix flaky suites
This commit is contained in:
@@ -32,10 +32,7 @@ class BaseUITestCase: XCTestCase {
|
||||
super.setUp()
|
||||
continueAfterFailure = false
|
||||
|
||||
app = XCUIApplication()
|
||||
app.launchArguments = buildLaunchArguments()
|
||||
app.launchEnvironment = buildLaunchEnvironment()
|
||||
app.launch()
|
||||
app = launchApp(resetState: true)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
@@ -48,8 +45,11 @@ class BaseUITestCase: XCTestCase {
|
||||
|
||||
// MARK: - Launch Configuration
|
||||
|
||||
private func buildLaunchArguments() -> [String] {
|
||||
var args = ["--ui-testing", "--reset-state", "--disable-animations"]
|
||||
private func buildLaunchArguments(resetState: Bool) -> [String] {
|
||||
var args = ["--ui-testing", "--disable-animations", "-AppleLanguages", "(en)", "-AppleLocale", "en_US"]
|
||||
if resetState {
|
||||
args.append("--reset-state")
|
||||
}
|
||||
if bypassSubscription {
|
||||
args.append("--bypass-subscription")
|
||||
}
|
||||
@@ -78,4 +78,29 @@ class BaseUITestCase: XCTestCase {
|
||||
screenshot.lifetime = .keepAlways
|
||||
add(screenshot)
|
||||
}
|
||||
|
||||
// MARK: - Shared Test Utilities
|
||||
|
||||
@discardableResult
|
||||
func launchApp(resetState: Bool) -> XCUIApplication {
|
||||
let application = XCUIApplication()
|
||||
application.launchArguments = buildLaunchArguments(resetState: resetState)
|
||||
application.launchEnvironment = buildLaunchEnvironment()
|
||||
application.launch()
|
||||
return application
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func relaunchPreservingState() -> XCUIApplication {
|
||||
app.terminate()
|
||||
let relaunched = launchApp(resetState: false)
|
||||
app = relaunched
|
||||
return relaunched
|
||||
}
|
||||
|
||||
func assertDayContentVisible(timeout: TimeInterval = 8, file: StaticString = #file, line: UInt = #line) {
|
||||
let hasEntry = app.firstEntryRow.waitForExistence(timeout: timeout)
|
||||
let hasMoodHeader = app.element(UITestID.Day.moodHeader).waitForExistence(timeout: 2)
|
||||
XCTAssertTrue(hasEntry || hasMoodHeader, "Day view should show entry list or mood header", file: file, line: line)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,86 @@
|
||||
|
||||
import XCTest
|
||||
|
||||
enum UITestID {
|
||||
enum Tab {
|
||||
static let day = "tab_day"
|
||||
static let month = "tab_month"
|
||||
static let year = "tab_year"
|
||||
static let insights = "tab_insights"
|
||||
static let settings = "tab_settings"
|
||||
}
|
||||
|
||||
enum Day {
|
||||
static let moodHeader = "mood_header"
|
||||
static let entryRowPrefix = "entry_row_"
|
||||
static let sectionPrefix = "day_section_"
|
||||
static let emptyStateNoData = "empty_state_no_data"
|
||||
}
|
||||
|
||||
enum Settings {
|
||||
static let header = "settings_header"
|
||||
static let customizeTab = "settings_tab_customize"
|
||||
static let settingsTab = "settings_tab_settings"
|
||||
static let upgradeBanner = "upgrade_banner"
|
||||
static let subscribeButton = "subscribe_button"
|
||||
static let whyUpgradeButton = "why_upgrade_button"
|
||||
static let browseThemesButton = "browse_themes_button"
|
||||
static let clearDataButton = "settings_clear_data"
|
||||
static let analyticsToggle = "settings_analytics_toggle"
|
||||
}
|
||||
|
||||
enum Customize {
|
||||
static func themeButton(_ name: String) -> String { "customize_theme_\(name.lowercased())" }
|
||||
static func votingLayoutButton(_ name: String) -> String { "customize_voting_\(name.lowercased())" }
|
||||
static func dayStyleButton(_ name: String) -> String { "customize_daystyle_\(name.lowercased())" }
|
||||
static func iconPackButton(_ name: String) -> String { "customize_iconpack_\(name.lowercased())" }
|
||||
static func appThemeCard(_ name: String) -> String { "apptheme_card_\(name.lowercased())" }
|
||||
static let pickerDoneButton = "apptheme_picker_done"
|
||||
static let previewCancelButton = "apptheme_preview_cancel"
|
||||
static let previewApplyButton = "apptheme_preview_apply"
|
||||
}
|
||||
|
||||
enum EntryDetail {
|
||||
static let sheet = "entry_detail_sheet"
|
||||
static let doneButton = "entry_detail_done"
|
||||
static let deleteButton = "entry_detail_delete"
|
||||
static let noteButton = "entry_detail_note_button"
|
||||
static let noteArea = "entry_detail_note_area"
|
||||
}
|
||||
|
||||
enum NoteEditor {
|
||||
static let text = "note_editor_text"
|
||||
static let save = "note_editor_save"
|
||||
static let cancel = "note_editor_cancel"
|
||||
}
|
||||
|
||||
enum Onboarding {
|
||||
static let welcome = "onboarding_welcome"
|
||||
static let time = "onboarding_time"
|
||||
static let day = "onboarding_day"
|
||||
static let dayToday = "onboarding_day_today"
|
||||
static let dayYesterday = "onboarding_day_yesterday"
|
||||
static let style = "onboarding_style"
|
||||
static let subscription = "onboarding_subscription"
|
||||
static let subscribe = "onboarding_subscribe_button"
|
||||
static let skip = "onboarding_skip_button"
|
||||
}
|
||||
|
||||
enum Paywall {
|
||||
static let monthOverlay = "paywall_month_overlay"
|
||||
static let yearOverlay = "paywall_year_overlay"
|
||||
static let insightsOverlay = "paywall_insights_overlay"
|
||||
}
|
||||
|
||||
enum Insights {
|
||||
static let header = "insights_header"
|
||||
}
|
||||
|
||||
enum Month {
|
||||
static let grid = "month_grid"
|
||||
}
|
||||
}
|
||||
|
||||
extension XCUIElement {
|
||||
|
||||
/// Wait for the element to exist in the hierarchy.
|
||||
@@ -36,11 +116,17 @@ extension XCUIElement {
|
||||
/// Tap the element after waiting for it to become hittable.
|
||||
/// - Parameter timeout: Maximum seconds to wait before tapping.
|
||||
func tapWhenReady(timeout: TimeInterval = 5, file: StaticString = #file, line: UInt = #line) {
|
||||
guard waitUntilHittable(timeout: timeout) else {
|
||||
XCTFail("Element \(identifier) not hittable after \(timeout)s", file: file, line: line)
|
||||
guard waitForExistence(timeout: timeout) else {
|
||||
XCTFail("Element \(identifier) not found after \(timeout)s", file: file, line: line)
|
||||
return
|
||||
}
|
||||
tap()
|
||||
if isHittable {
|
||||
tap()
|
||||
return
|
||||
}
|
||||
|
||||
// Coordinate tap fallback for iOS 26 overlays where XCUI reports false-negative hittability.
|
||||
coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
}
|
||||
|
||||
/// Wait for the element to disappear from the hierarchy.
|
||||
@@ -56,10 +142,82 @@ extension XCUIElement {
|
||||
|
||||
extension XCUIApplication {
|
||||
|
||||
/// Find any element matching an accessibility identifier.
|
||||
func element(_ identifier: String) -> XCUIElement {
|
||||
let element = descendants(matching: .any).matching(identifier: identifier).firstMatch
|
||||
return element
|
||||
}
|
||||
|
||||
/// Wait for any element matching the identifier to exist.
|
||||
func waitForElement(identifier: String, timeout: TimeInterval = 5) -> XCUIElement {
|
||||
let element = descendants(matching: .any).matching(identifier: identifier).firstMatch
|
||||
let element = element(identifier)
|
||||
_ = element.waitForExistence(timeout: timeout)
|
||||
return element
|
||||
}
|
||||
|
||||
var entryRows: XCUIElementQuery {
|
||||
descendants(matching: .any).matching(NSPredicate(format: "identifier BEGINSWITH %@", UITestID.Day.entryRowPrefix))
|
||||
}
|
||||
|
||||
var firstEntryRow: XCUIElement {
|
||||
entryRows.firstMatch
|
||||
}
|
||||
|
||||
func tapTab(identifier: String, labels: [String], timeout: TimeInterval = 5, file: StaticString = #file, line: UInt = #line) {
|
||||
let idMatch = tabBars.buttons[identifier]
|
||||
if idMatch.waitForExistence(timeout: 1) {
|
||||
idMatch.tapWhenReady(timeout: timeout, file: file, line: line)
|
||||
return
|
||||
}
|
||||
|
||||
for label in labels {
|
||||
let labelMatch = tabBars.buttons[label]
|
||||
if labelMatch.waitForExistence(timeout: 1) {
|
||||
labelMatch.tapWhenReady(timeout: timeout, file: file, line: line)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
XCTFail("Unable to find tab by id \(identifier) or labels \(labels)", file: file, line: line)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func swipeUntilExists(
|
||||
_ element: XCUIElement,
|
||||
direction: SwipeDirection = .up,
|
||||
maxSwipes: Int = 6,
|
||||
timeoutPerTry: TimeInterval = 0.6
|
||||
) -> Bool {
|
||||
if element.waitForExistence(timeout: timeoutPerTry) {
|
||||
return true
|
||||
}
|
||||
|
||||
for _ in 0..<maxSwipes {
|
||||
switch direction {
|
||||
case .up:
|
||||
swipeUp()
|
||||
case .down:
|
||||
swipeDown()
|
||||
case .left:
|
||||
swipeLeft()
|
||||
case .right:
|
||||
swipeRight()
|
||||
@unknown default:
|
||||
swipeUp()
|
||||
}
|
||||
|
||||
if element.waitForExistence(timeout: timeoutPerTry) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
enum SwipeDirection {
|
||||
case up
|
||||
case down
|
||||
case left
|
||||
case right
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user