Stabilize iOS UI test foundation and fix flaky suites

This commit is contained in:
Trey t
2026-02-17 22:24:08 -06:00
parent c28d7a59eb
commit 56ac783219
38 changed files with 543 additions and 585 deletions

View File

@@ -13,45 +13,47 @@ struct CustomizeScreen {
// MARK: - Theme Mode Buttons
func themeButton(named name: String) -> XCUIElement {
app.buttons["customize_theme_\(name.lowercased())"]
app.buttons[UITestID.Customize.themeButton(name)]
}
// MARK: - Voting Layout Buttons
func votingLayoutButton(named name: String) -> XCUIElement {
app.buttons["customize_voting_\(name.lowercased())"]
app.buttons[UITestID.Customize.votingLayoutButton(name)]
}
// MARK: - Day View Style Buttons
func dayViewStyleButton(named name: String) -> XCUIElement {
app.buttons["customize_daystyle_\(name.lowercased())"]
app.buttons[UITestID.Customize.dayStyleButton(name)]
}
func iconPackButton(named name: String) -> XCUIElement {
app.buttons[UITestID.Customize.iconPackButton(name)]
}
func appThemeCard(named name: String) -> XCUIElement {
app.element(UITestID.Customize.appThemeCard(name))
}
// MARK: - Actions
func selectTheme(_ name: String) {
let button = themeButton(named: name)
_ = button.waitForExistence(timeout: 5)
button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
tapHorizontallyScrollableButton(themeButton(named: name))
}
func selectVotingLayout(_ name: String) {
let button = votingLayoutButton(named: name)
if button.exists && !button.isHittable {
app.swipeLeft()
}
_ = button.waitForExistence(timeout: 5)
button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
tapHorizontallyScrollableButton(votingLayoutButton(named: name))
}
func selectDayViewStyle(_ name: String) {
let button = dayViewStyleButton(named: name)
if button.exists && !button.isHittable {
app.swipeLeft()
}
_ = button.waitForExistence(timeout: 5)
button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
tapHorizontallyScrollableButton(dayViewStyleButton(named: name))
}
func selectIconPack(_ name: String) {
let button = iconPackButton(named: name)
_ = app.swipeUntilExists(button, direction: .up, maxSwipes: 6)
button.tapWhenReady(timeout: 5)
}
// MARK: - Assertions
@@ -63,4 +65,42 @@ struct CustomizeScreen {
file: file, line: line
)
}
@discardableResult
func openThemePicker(file: StaticString = #file, line: UInt = #line) -> Bool {
let browseButton = app.element(UITestID.Settings.browseThemesButton)
guard browseButton.waitForExistence(timeout: 5) else {
XCTFail("Browse Themes button should exist", file: file, line: line)
return false
}
browseButton.tapWhenReady(timeout: 5, file: file, line: line)
let firstCard = appThemeCard(named: "Zen Garden")
return firstCard.waitForExistence(timeout: 5)
}
// MARK: - Private
private func tapHorizontallyScrollableButton(_ button: XCUIElement) {
if button.waitForExistence(timeout: 1) {
button.tapWhenReady(timeout: 3)
return
}
for _ in 0..<6 {
app.swipeLeft()
if button.waitForExistence(timeout: 1) {
button.tapWhenReady(timeout: 3)
return
}
}
for _ in 0..<6 {
app.swipeRight()
if button.waitForExistence(timeout: 1) {
button.tapWhenReady(timeout: 3)
return
}
}
}
}

View File

@@ -19,13 +19,17 @@ struct DayScreen {
var horribleButton: XCUIElement { app.buttons["mood_button_horrible"] }
/// The mood header container
var moodHeader: XCUIElement { app.otherElements["mood_header"] }
var moodHeader: XCUIElement { app.element(UITestID.Day.moodHeader) }
// MARK: - Entry List
/// Find an entry row by its date string (format: "M/d/yyyy")
/// Find an entry row by its raw identifier date payload (yyyyMMdd).
func entryRow(dateString: String) -> XCUIElement {
app.descendants(matching: .any).matching(identifier: "entry_row_\(dateString)").firstMatch
app.element("\(UITestID.Day.entryRowPrefix)\(dateString)")
}
var anyEntryRow: XCUIElement {
app.firstEntryRow
}
// MARK: - Actions
@@ -37,7 +41,7 @@ struct DayScreen {
XCTFail("Mood button '\(mood.rawValue)' not found", file: file, line: line)
return
}
button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
button.tapWhenReady(timeout: 5, file: file, line: line)
// Wait for the celebration animation to finish and entry to appear.
// The mood header disappears after logging today's mood.
@@ -70,6 +74,14 @@ struct DayScreen {
)
}
func assertAnyEntryExists(file: StaticString = #file, line: UInt = #line) {
XCTAssertTrue(
anyEntryRow.waitForExistence(timeout: 5),
"At least one entry row should exist",
file: file, line: line
)
}
// MARK: - Private
private func moodButton(for mood: MoodChoice) -> XCUIElement {

View File

@@ -12,9 +12,9 @@ struct EntryDetailScreen {
// MARK: - Elements
var navigationTitle: XCUIElement { app.navigationBars["Entry Details"] }
var doneButton: XCUIElement { app.buttons["entry_detail_done"] }
var deleteButton: XCUIElement { app.buttons["entry_detail_delete"] }
var sheet: XCUIElement { app.element(UITestID.EntryDetail.sheet) }
var doneButton: XCUIElement { app.element(UITestID.EntryDetail.doneButton) }
var deleteButton: XCUIElement { app.element(UITestID.EntryDetail.deleteButton) }
var moodGrid: XCUIElement { app.otherElements["entry_detail_mood_grid"] }
/// Mood buttons inside the detail sheet's mood grid.
@@ -27,32 +27,39 @@ struct EntryDetailScreen {
func dismiss() {
let button = doneButton
_ = button.waitForExistence(timeout: 5)
button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
button.tapWhenReady(timeout: 5)
}
func selectMood(_ mood: MoodChoice) {
let button = moodButton(for: mood)
_ = button.waitForExistence(timeout: 5)
button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
button.tapWhenReady(timeout: 5)
}
func deleteEntry() {
let button = deleteButton
_ = button.waitForExistence(timeout: 5)
button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
// Confirm the delete alert
let deleteAlert = app.alerts["Delete Entry"]
let confirmButton = deleteAlert.buttons["Delete"]
_ = confirmButton.waitForExistence(timeout: 5)
confirmButton.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
button.tapWhenReady(timeout: 5)
let alert = app.alerts.firstMatch
guard alert.waitForExistence(timeout: 5) else { return }
let deleteButton = alert.buttons.matching(NSPredicate(format: "label CONTAINS[cd] %@", "Delete")).firstMatch
if deleteButton.waitForExistence(timeout: 2) {
deleteButton.tapWhenReady()
return
}
// Fallback: destructive action is usually the last button.
let fallback = alert.buttons.element(boundBy: max(alert.buttons.count - 1, 0))
if fallback.exists {
fallback.tapWhenReady()
}
}
// MARK: - Assertions
func assertVisible(file: StaticString = #file, line: UInt = #line) {
XCTAssertTrue(
navigationTitle.waitForExistence(timeout: 5),
sheet.waitForExistence(timeout: 5),
"Entry Detail sheet should be visible",
file: file, line: line
)
@@ -60,7 +67,7 @@ struct EntryDetailScreen {
func assertDismissed(file: StaticString = #file, line: UInt = #line) {
XCTAssertTrue(
navigationTitle.waitForDisappearance(timeout: 5),
sheet.waitForDisappearance(timeout: 5),
"Entry Detail sheet should be dismissed",
file: file, line: line
)

View File

@@ -12,10 +12,10 @@ struct NoteEditorScreen {
// MARK: - Elements
var navigationTitle: XCUIElement { app.navigationBars["Journal Note"] }
var textEditor: XCUIElement { app.textViews["note_editor_text"] }
var saveButton: XCUIElement { app.buttons["note_editor_save"] }
var cancelButton: XCUIElement { app.buttons["note_editor_cancel"] }
var navigationTitle: XCUIElement { app.navigationBars.firstMatch }
var textEditor: XCUIElement { app.textViews[UITestID.NoteEditor.text] }
var saveButton: XCUIElement { app.buttons[UITestID.NoteEditor.save] }
var cancelButton: XCUIElement { app.buttons[UITestID.NoteEditor.cancel] }
// MARK: - Actions
@@ -47,7 +47,7 @@ struct NoteEditorScreen {
func assertVisible(file: StaticString = #file, line: UInt = #line) {
XCTAssertTrue(
navigationTitle.waitForExistence(timeout: 5),
textEditor.waitForExistence(timeout: 5),
"Note editor should be visible",
file: file, line: line
)
@@ -55,7 +55,7 @@ struct NoteEditorScreen {
func assertDismissed(file: StaticString = #file, line: UInt = #line) {
XCTAssertTrue(
navigationTitle.waitForDisappearance(timeout: 5),
textEditor.waitForDisappearance(timeout: 5),
"Note editor should be dismissed",
file: file, line: line
)

View File

@@ -12,14 +12,16 @@ struct OnboardingScreen {
// MARK: - Screen Elements
var welcomeScreen: XCUIElement { app.otherElements["onboarding_welcome"] }
var dayScreen: XCUIElement { app.otherElements["onboarding_day"] }
var subscriptionScreen: XCUIElement { app.otherElements["onboarding_subscription"] }
var welcomeScreen: XCUIElement { app.element(UITestID.Onboarding.welcome) }
var timeScreen: XCUIElement { app.element(UITestID.Onboarding.time) }
var dayScreen: XCUIElement { app.element(UITestID.Onboarding.day) }
var styleScreen: XCUIElement { app.element(UITestID.Onboarding.style) }
var subscriptionScreen: XCUIElement { app.element(UITestID.Onboarding.subscription) }
var dayTodayButton: XCUIElement { app.buttons.matching(NSPredicate(format: "identifier == %@", "onboarding_day_today")).firstMatch }
var dayYesterdayButton: XCUIElement { app.buttons.matching(NSPredicate(format: "identifier == %@", "onboarding_day_yesterday")).firstMatch }
var subscribeButton: XCUIElement { app.buttons.matching(NSPredicate(format: "identifier == %@", "onboarding_subscribe_button")).firstMatch }
var skipButton: XCUIElement { app.buttons.matching(NSPredicate(format: "identifier == %@", "onboarding_skip_button")).firstMatch }
var dayTodayButton: XCUIElement { app.element(UITestID.Onboarding.dayToday) }
var dayYesterdayButton: XCUIElement { app.element(UITestID.Onboarding.dayYesterday) }
var subscribeButton: XCUIElement { app.element(UITestID.Onboarding.subscribe) }
var skipButton: XCUIElement { app.element(UITestID.Onboarding.skip) }
// MARK: - Actions
@@ -41,7 +43,7 @@ struct OnboardingScreen {
// Day -> select Today, then swipe
if dayTodayButton.waitForExistence(timeout: 3) {
dayTodayButton.tap()
dayTodayButton.tapWhenReady()
}
swipeToNext()
@@ -50,7 +52,7 @@ struct OnboardingScreen {
// Subscription -> tap "Maybe Later"
if skipButton.waitForExistence(timeout: 5) {
skipButton.tap()
skipButton.tapWhenReady()
}
}

View File

@@ -12,61 +12,41 @@ struct SettingsScreen {
// MARK: - Elements
var settingsHeader: XCUIElement { app.staticTexts["settings_header"] }
var customizeSegment: XCUIElement { app.buttons["Customize"] }
var settingsHeader: XCUIElement { app.element(UITestID.Settings.header) }
var customizeSegment: XCUIElement { app.element(UITestID.Settings.customizeTab) }
var settingsSegment: XCUIElement { app.element(UITestID.Settings.settingsTab) }
var upgradeBanner: XCUIElement {
app.descendants(matching: .any).matching(identifier: "upgrade_banner").firstMatch
app.element(UITestID.Settings.upgradeBanner)
}
var subscribeButton: XCUIElement {
app.descendants(matching: .any).matching(identifier: "subscribe_button").firstMatch
app.element(UITestID.Settings.subscribeButton)
}
var whyUpgradeButton: XCUIElement { app.buttons["why_upgrade_button"] }
var browseThemesButton: XCUIElement { app.buttons["browse_themes_button"] }
var clearDataButton: XCUIElement { app.buttons["settings_clear_data"].firstMatch }
var analyticsToggle: XCUIElement { app.descendants(matching: .any).matching(identifier: "settings_analytics_toggle").firstMatch }
var showOnboardingButton: XCUIElement { app.buttons["settings_show_onboarding"].firstMatch }
var whyUpgradeButton: XCUIElement { app.element(UITestID.Settings.whyUpgradeButton) }
var browseThemesButton: XCUIElement { app.element(UITestID.Settings.browseThemesButton) }
var clearDataButton: XCUIElement { app.element(UITestID.Settings.clearDataButton) }
var analyticsToggle: XCUIElement { app.element(UITestID.Settings.analyticsToggle) }
var showOnboardingButton: XCUIElement { app.buttons["settings_show_onboarding"] }
// MARK: - Actions
func tapCustomizeTab() {
let segment = customizeSegment
_ = segment.waitForExistence(timeout: 5)
segment.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
tapSegment(identifier: UITestID.Settings.customizeTab, fallbackLabel: "Customize")
}
func tapSettingsTab() {
// Find the "Settings" segment in the segmented control (not the tab bar button).
// Try segmentedControls first, then fall back to finding by exclusion.
let segCtrl = app.segmentedControls.buttons["Settings"]
if segCtrl.waitForExistence(timeout: 3) {
segCtrl.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
return
}
// Fallback: find a "Settings" button that is NOT the tab bar button
let candidates = app.buttons.matching(NSPredicate(format: "label == 'Settings'")).allElementsBoundByIndex
let tabBarBtn = app.tabBars.buttons["Settings"]
for candidate in candidates where candidate.frame != tabBarBtn.frame {
candidate.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
return
}
tapSegment(identifier: UITestID.Settings.settingsTab, fallbackLabel: "Settings")
}
func tapClearData() {
let button = clearDataButton
if button.exists && !button.isHittable {
app.swipeUp()
}
_ = button.waitForExistence(timeout: 5)
button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
_ = app.swipeUntilExists(button, direction: .up, maxSwipes: 6)
button.tapWhenReady(timeout: 5)
}
func tapAnalyticsToggle() {
let toggle = analyticsToggle
if toggle.exists && !toggle.isHittable {
app.swipeUp()
}
_ = toggle.waitForExistence(timeout: 5)
toggle.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
_ = app.swipeUntilExists(toggle, direction: .up, maxSwipes: 6)
toggle.tapWhenReady(timeout: 5)
}
// MARK: - Assertions
@@ -94,4 +74,26 @@ struct SettingsScreen {
file: file, line: line
)
}
// MARK: - Private
private func tapSegment(identifier: String, fallbackLabel: String) {
let byID = app.element(identifier)
if byID.waitForExistence(timeout: 2) {
byID.tapWhenReady()
return
}
let segmentedButton = app.segmentedControls.buttons[fallbackLabel]
if segmentedButton.waitForExistence(timeout: 2) {
segmentedButton.tapWhenReady()
return
}
let candidates = app.buttons.matching(NSPredicate(format: "label == %@", fallbackLabel)).allElementsBoundByIndex
let tabBarButton = app.tabBars.buttons[fallbackLabel]
if let nonTabButton = candidates.first(where: { $0.frame != tabBarButton.frame }) {
nonTabButton.tapWhenReady()
}
}
}

View File

@@ -10,43 +10,43 @@ import XCTest
struct TabBarScreen {
let app: XCUIApplication
// MARK: - Tab Buttons (using localized labels)
// MARK: - Tab Buttons
var dayTab: XCUIElement { app.tabBars.buttons["Day"] }
var monthTab: XCUIElement { app.tabBars.buttons["Month"] }
var yearTab: XCUIElement { app.tabBars.buttons["Year"] }
var insightsTab: XCUIElement { app.tabBars.buttons["Insights"] }
var settingsTab: XCUIElement { app.tabBars.buttons["Settings"] }
var dayTab: XCUIElement { tab(identifier: UITestID.Tab.day, labels: ["Day", "Main"]) }
var monthTab: XCUIElement { tab(identifier: UITestID.Tab.month, labels: ["Month"]) }
var yearTab: XCUIElement { tab(identifier: UITestID.Tab.year, labels: ["Year", "Filter"]) }
var insightsTab: XCUIElement { tab(identifier: UITestID.Tab.insights, labels: ["Insights"]) }
var settingsTab: XCUIElement { tab(identifier: UITestID.Tab.settings, labels: ["Settings"]) }
// MARK: - Actions
@discardableResult
func tapDay() -> DayScreen {
tapTab(dayTab)
app.tapTab(identifier: UITestID.Tab.day, labels: ["Day", "Main"])
return DayScreen(app: app)
}
@discardableResult
func tapMonth() -> TabBarScreen {
tapTab(monthTab)
app.tapTab(identifier: UITestID.Tab.month, labels: ["Month"])
return self
}
@discardableResult
func tapYear() -> TabBarScreen {
tapTab(yearTab)
app.tapTab(identifier: UITestID.Tab.year, labels: ["Year", "Filter"])
return self
}
@discardableResult
func tapInsights() -> TabBarScreen {
tapTab(insightsTab)
app.tapTab(identifier: UITestID.Tab.insights, labels: ["Insights"])
return self
}
@discardableResult
func tapSettings() -> SettingsScreen {
tapTab(settingsTab)
app.tapTab(identifier: UITestID.Tab.settings, labels: ["Settings"])
return SettingsScreen(app: app)
}
@@ -57,15 +57,27 @@ struct TabBarScreen {
}
func assertTabBarVisible() {
XCTAssertTrue(dayTab.waitForExistence(timeout: 5), "Tab bar should be visible")
let visible = dayTab.waitForExistence(timeout: 5) ||
monthTab.waitForExistence(timeout: 1) ||
settingsTab.waitForExistence(timeout: 1)
XCTAssertTrue(visible, "Tab bar should be visible")
}
// MARK: - Private
// MARK: - Element Resolution
/// Tap a tab bar button. Uses coordinate tap to avoid iOS 26 Liquid Glass
/// overlay elements reporting buttons as not hittable.
private func tapTab(_ tab: XCUIElement) {
_ = tab.waitForExistence(timeout: 5)
tab.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
private func tab(identifier: String, labels: [String]) -> XCUIElement {
let idMatch = app.tabBars.buttons[identifier]
if idMatch.exists {
return idMatch
}
for label in labels {
let match = app.tabBars.buttons[label]
if match.exists {
return match
}
}
return app.tabBars.buttons[labels.first ?? identifier]
}
}