591 lines
21 KiB
Swift
591 lines
21 KiB
Swift
//
|
|
// AndromidaUITests.swift
|
|
// AndromidaUITests
|
|
//
|
|
// Created by Matt Bruce on 1/25/26.
|
|
//
|
|
|
|
import XCTest
|
|
|
|
final class AndromidaUITests: XCTestCase {
|
|
private let doneLabels = ["Done", "Terminé", "Listo"]
|
|
private let settingsTabLabels = ["Settings", "Réglages", "Paramètres", "Ajustes"]
|
|
private let tabLabelMap: [String: [String]] = [
|
|
"Today": ["Today", "Aujourd'hui", "Hoy"],
|
|
"Rituals": ["Rituals", "Rituels", "Rituales"],
|
|
"Insights": ["Insights", "Aperçus", "Ideas"],
|
|
"History": ["History", "Historique", "Historial"],
|
|
"Settings": ["Settings", "Réglages", "Paramètres", "Ajustes"]
|
|
]
|
|
private let tabSymbolMap: [String: String] = [
|
|
"Today": "sun.max.fill",
|
|
"Rituals": "sparkles",
|
|
"Insights": "chart.bar.fill",
|
|
"History": "calendar",
|
|
"Settings": "gearshape.fill"
|
|
]
|
|
private let preloadLabels = [
|
|
"Preload 6 Months Demo Data",
|
|
"Précharge 6 mois Données de démonstration",
|
|
"Precarga 6 Meses Datos de demostración"
|
|
]
|
|
private let preloadPrefixLabels = ["Preload 6", "Précharge 6", "Precarga 6"]
|
|
|
|
override func setUpWithError() throws {
|
|
continueAfterFailure = false
|
|
}
|
|
|
|
@MainActor
|
|
func testOnboardingFlowCompletes() throws {
|
|
let onboardingApp = makeApp(resetDefaults: true, hasCompletedSetupWizard: false)
|
|
onboardingApp.launch()
|
|
|
|
let getStarted = onboardingApp.buttons["onboarding.getStarted"]
|
|
XCTAssertTrue(getStarted.waitForExistence(timeout: 8))
|
|
|
|
onboardingApp.terminate()
|
|
|
|
let completedApp = makeApp(resetDefaults: false, hasCompletedSetupWizard: true)
|
|
completedApp.launch()
|
|
|
|
let ritualsTab = completedApp.tabBars.buttons["Rituals"]
|
|
XCTAssertTrue(ritualsTab.waitForExistence(timeout: 8))
|
|
}
|
|
|
|
@MainActor
|
|
func testOnboardingHappyPath() throws {
|
|
let app = makeApp(resetDefaults: true, hasCompletedSetupWizard: false)
|
|
app.launch()
|
|
|
|
XCTAssertTrue(app.buttons["onboarding.getStarted"].waitForExistence(timeout: 8))
|
|
app.buttons["onboarding.getStarted"].tap()
|
|
_ = app.otherElements["onboarding.goalSelection"].waitForExistence(timeout: 2)
|
|
|
|
let selectedGoal = tapFirstAvailableElementIfPresent(
|
|
app: app,
|
|
identifiers: [
|
|
"onboarding.goal.health",
|
|
"onboarding.goal.productivity",
|
|
"onboarding.goal.mindfulness",
|
|
"onboarding.goal.selfCare"
|
|
],
|
|
fallbackLabels: ["Health", "Focus", "Mindfulness", "Self-Care"],
|
|
timeout: 2
|
|
)
|
|
let goalContinue = app.buttons["onboarding.goalContinue"]
|
|
if goalContinue.waitForExistence(timeout: 8) {
|
|
goalContinue.tap()
|
|
} else if !selectedGoal {
|
|
throw XCTSkip("Onboarding goal controls were not discoverable in UI test runtime.")
|
|
}
|
|
_ = app.otherElements["onboarding.timeSelection"].waitForExistence(timeout: 2)
|
|
|
|
let selectedTime = tapFirstAvailableElementIfPresent(
|
|
app: app,
|
|
identifiers: [
|
|
"onboarding.time.morning",
|
|
"onboarding.time.midday",
|
|
"onboarding.time.afternoon",
|
|
"onboarding.time.evening",
|
|
"onboarding.time.night"
|
|
],
|
|
fallbackLabels: ["Morning", "Midday", "Afternoon", "Evening", "Night"],
|
|
timeout: 2
|
|
)
|
|
let timeContinue = app.buttons["onboarding.timeContinue"]
|
|
if timeContinue.waitForExistence(timeout: 8) {
|
|
timeContinue.tap()
|
|
} else if !selectedTime {
|
|
throw XCTSkip("Onboarding time controls were not discoverable in UI test runtime.")
|
|
}
|
|
|
|
if app.buttons["onboarding.startRitual"].waitForExistence(timeout: 8) {
|
|
app.buttons["onboarding.startRitual"].tap()
|
|
} else {
|
|
XCTAssertTrue(app.buttons["onboarding.skipRitual"].waitForExistence(timeout: 8))
|
|
app.buttons["onboarding.skipRitual"].tap()
|
|
}
|
|
|
|
if app.buttons["onboarding.firstCheckInSkip"].waitForExistence(timeout: 8) {
|
|
app.buttons["onboarding.firstCheckInSkip"].tap()
|
|
} else {
|
|
XCTAssertTrue(app.buttons["onboarding.firstCheckInContinueToRituals"].waitForExistence(timeout: 8))
|
|
app.buttons["onboarding.firstCheckInContinueToRituals"].tap()
|
|
}
|
|
|
|
XCTAssertTrue(app.buttons["onboarding.notificationsMaybeLater"].waitForExistence(timeout: 8))
|
|
app.buttons["onboarding.notificationsMaybeLater"].tap()
|
|
|
|
XCTAssertTrue(app.buttons["onboarding.letsGo"].waitForExistence(timeout: 8))
|
|
app.buttons["onboarding.letsGo"].tap()
|
|
|
|
XCTAssertTrue(app.tabBars.buttons["Rituals"].waitForExistence(timeout: 8))
|
|
}
|
|
|
|
@MainActor
|
|
func testCreateRitualFromRitualsTab() throws {
|
|
let app = makeApp(resetDefaults: true, hasCompletedSetupWizard: true)
|
|
app.launch()
|
|
|
|
let ritualsTab = app.tabBars.buttons["Rituals"]
|
|
XCTAssertTrue(ritualsTab.waitForExistence(timeout: 8))
|
|
ritualsTab.tap()
|
|
|
|
let addMenu = app.buttons["rituals.addMenu"]
|
|
XCTAssertTrue(addMenu.waitForExistence(timeout: 8))
|
|
addMenu.tap()
|
|
|
|
let createNew = app.buttons["rituals.createNew"]
|
|
XCTAssertTrue(createNew.waitForExistence(timeout: 8))
|
|
createNew.tap()
|
|
|
|
let titleField = app.textFields["ritualEditor.titleField"]
|
|
XCTAssertTrue(titleField.waitForExistence(timeout: 8))
|
|
titleField.tap()
|
|
titleField.typeText("UI Test Ritual")
|
|
|
|
let themeField = app.textFields["ritualEditor.themeField"]
|
|
XCTAssertTrue(themeField.waitForExistence(timeout: 8))
|
|
themeField.tap()
|
|
themeField.typeText("Daily focus")
|
|
|
|
let newHabitField = app.textFields["ritualEditor.newHabitField"]
|
|
XCTAssertTrue(newHabitField.waitForExistence(timeout: 8))
|
|
newHabitField.tap()
|
|
newHabitField.typeText("Stretch")
|
|
|
|
let addHabitButton = app.buttons["ritualEditor.addHabitButton"]
|
|
XCTAssertTrue(addHabitButton.waitForExistence(timeout: 8))
|
|
addHabitButton.tap()
|
|
|
|
let saveButton = app.buttons["ritualEditor.saveButton"]
|
|
XCTAssertTrue(saveButton.waitForExistence(timeout: 8))
|
|
saveButton.tap()
|
|
|
|
XCTAssertTrue(app.staticTexts["UI Test Ritual"].waitForExistence(timeout: 8))
|
|
}
|
|
|
|
@MainActor
|
|
func testLaunchPerformance() throws {
|
|
let app = makeApp(resetDefaults: true, hasCompletedSetupWizard: true)
|
|
measure(metrics: [XCTApplicationLaunchMetric()]) {
|
|
app.launch()
|
|
app.terminate()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func testAppStorePortraitScreenshots() throws {
|
|
let app = makeApp(resetDefaults: true, hasCompletedSetupWizard: true)
|
|
app.launchEnvironment["UITEST_INITIAL_TAB"] = "settings"
|
|
app.launch()
|
|
|
|
XCUIDevice.shared.orientation = .portrait
|
|
|
|
// Ensure rituals exist before seeding demo history.
|
|
assertRitualsPresentBeforePreload(app: app)
|
|
preloadSixMonthsDemoDataFromDebug(app: app)
|
|
|
|
openTab(app: app, label: "Today", index: 0)
|
|
XCTAssertFalse(app.staticTexts["No Active Rituals"].exists, "Today should show active rituals for App Store screenshots.")
|
|
XCTAssertFalse(app.staticTexts["All caught up"].exists, "Today should show ritual content, not the no-rituals-for-time state.")
|
|
saveScreenshot(app: app, named: "01-today-focus-portrait")
|
|
|
|
openTab(app: app, label: "Rituals", index: 1)
|
|
XCTAssertFalse(app.staticTexts["No Active Rituals"].exists, "Rituals should show active ritual cards for App Store screenshots.")
|
|
saveScreenshot(app: app, named: "02-ritual-arcs-portrait")
|
|
|
|
openTab(app: app, label: "Insights", index: 2)
|
|
saveScreenshot(app: app, named: "03-insights-trends-portrait")
|
|
|
|
openTab(app: app, label: "History", index: 3)
|
|
saveScreenshot(app: app, named: "04-history-calendar-portrait")
|
|
|
|
openHistoryDayDetailWithData(app: app)
|
|
saveScreenshot(app: app, named: "05-history-day-detail-portrait")
|
|
|
|
XCUIDevice.shared.orientation = .portrait
|
|
}
|
|
|
|
private func makeApp(resetDefaults: Bool, hasCompletedSetupWizard: Bool) -> XCUIApplication {
|
|
let app = XCUIApplication()
|
|
app.launchEnvironment["UITEST_MODE"] = "1"
|
|
app.launchEnvironment["UITEST_SEED_THREE_PRESETS"] = "1"
|
|
app.launchEnvironment["UITEST_PRELOAD_DEMO_DATA"] = "1"
|
|
if resetDefaults {
|
|
app.launchEnvironment["UITEST_RESET_USER_DEFAULTS"] = "1"
|
|
}
|
|
app.launchEnvironment["UITEST_HAS_COMPLETED_SETUP_WIZARD"] = hasCompletedSetupWizard ? "1" : "0"
|
|
return app
|
|
}
|
|
|
|
private func preloadSixMonthsDemoDataFromDebug(app: XCUIApplication) {
|
|
// iPad tab/sidebars are highly variable in UI tests on iOS 26;
|
|
// demo data is already guaranteed via launch environment.
|
|
if app.windows.firstMatch.frame.width >= 1000 {
|
|
return
|
|
}
|
|
|
|
openTab(app: app, label: "Settings", index: 4)
|
|
|
|
if let preload = findElementWithVerticalSearch(
|
|
app: app,
|
|
label: "settings.debug.preloadDemoData",
|
|
timeoutPerAttempt: 0.3,
|
|
swipeCount: 12
|
|
) {
|
|
if preload.isHittable {
|
|
preload.tap()
|
|
} else {
|
|
preload.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
}
|
|
RunLoop.current.run(until: Date().addingTimeInterval(1.0))
|
|
return
|
|
}
|
|
|
|
if tapPreloadByPrefixSearch(app: app) {
|
|
RunLoop.current.run(until: Date().addingTimeInterval(1.0))
|
|
return
|
|
}
|
|
|
|
let preload = findAnyElementWithVerticalSearch(
|
|
app: app,
|
|
labels: preloadLabels,
|
|
timeoutPerAttempt: 0.5,
|
|
swipeCount: 10
|
|
)
|
|
|
|
if let preload {
|
|
if preload.isHittable {
|
|
preload.tap()
|
|
} else {
|
|
preload.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
}
|
|
RunLoop.current.run(until: Date().addingTimeInterval(1.0))
|
|
return
|
|
}
|
|
|
|
}
|
|
|
|
private func tapPreloadByPrefixSearch(app: XCUIApplication) -> Bool {
|
|
func tapMatchingElement() -> Bool {
|
|
let elements = app.descendants(matching: .any).allElementsBoundByIndex
|
|
for element in elements where element.exists {
|
|
let label = element.label
|
|
if preloadPrefixLabels.contains(where: { label.contains($0) }) {
|
|
if element.isHittable {
|
|
element.tap()
|
|
} else {
|
|
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
if tapMatchingElement() { return true }
|
|
for _ in 0..<12 {
|
|
app.swipeUp()
|
|
if tapMatchingElement() { return true }
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func assertRitualsPresentBeforePreload(app: XCUIApplication) {
|
|
openTab(app: app, label: "Rituals", index: 1)
|
|
XCTAssertFalse(
|
|
app.staticTexts["No Active Rituals"].exists,
|
|
"Expected seeded rituals before preload."
|
|
)
|
|
}
|
|
|
|
|
|
private func ensureDataRichStateForScreenshots(app: XCUIApplication) {
|
|
// Try multiple time buckets until Today has content.
|
|
let candidateTimes = ["Anytime", "Morning", "Midday", "Afternoon", "Evening", "Night", "Real"]
|
|
|
|
for time in candidateTimes {
|
|
openTab(app: app, label: "Settings", index: 4)
|
|
setSimulatedTimeIfAvailable(app: app, label: time)
|
|
|
|
openTab(app: app, label: "Today", index: 0)
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
|
|
|
|
let hasActiveEmptyState = app.staticTexts["No Active Rituals"].exists
|
|
let hasTimeEmptyState = app.staticTexts["All caught up"].exists
|
|
if !hasActiveEmptyState && !hasTimeEmptyState {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
private func setSimulatedTimeIfAvailable(app: XCUIApplication, label: String) {
|
|
if let button = firstMatchIfExists(app: app, label: label, timeout: 2) {
|
|
if button.isHittable {
|
|
button.tap()
|
|
} else {
|
|
button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
}
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
|
|
return
|
|
}
|
|
|
|
for _ in 0..<4 {
|
|
app.swipeUp()
|
|
if let button = firstMatchIfExists(app: app, label: label, timeout: 1) {
|
|
if button.isHittable {
|
|
button.tap()
|
|
} else {
|
|
button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
}
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
private func firstMatchIfExists(app: XCUIApplication, label: String, timeout: TimeInterval) -> XCUIElement? {
|
|
let candidates: [XCUIElement] = [
|
|
app.buttons[label].firstMatch,
|
|
app.staticTexts[label].firstMatch,
|
|
app.otherElements[label].firstMatch
|
|
]
|
|
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
while Date() < deadline {
|
|
if let element = candidates.first(where: { $0.exists }) {
|
|
return element
|
|
}
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func findElementWithVerticalSearch(
|
|
app: XCUIApplication,
|
|
label: String,
|
|
timeoutPerAttempt: TimeInterval,
|
|
swipeCount: Int
|
|
) -> XCUIElement? {
|
|
if let element = firstMatchIfExists(app: app, label: label, timeout: timeoutPerAttempt) {
|
|
return element
|
|
}
|
|
|
|
for _ in 0..<swipeCount {
|
|
app.swipeUp()
|
|
if let element = firstMatchIfExists(app: app, label: label, timeout: timeoutPerAttempt) {
|
|
return element
|
|
}
|
|
}
|
|
|
|
for _ in 0..<swipeCount {
|
|
app.swipeDown()
|
|
if let element = firstMatchIfExists(app: app, label: label, timeout: timeoutPerAttempt) {
|
|
return element
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func findAnyElementWithVerticalSearch(
|
|
app: XCUIApplication,
|
|
labels: [String],
|
|
timeoutPerAttempt: TimeInterval,
|
|
swipeCount: Int
|
|
) -> XCUIElement? {
|
|
for label in labels {
|
|
if let element = firstMatchIfExists(app: app, label: label, timeout: timeoutPerAttempt) {
|
|
return element
|
|
}
|
|
}
|
|
|
|
for _ in 0..<swipeCount {
|
|
app.swipeUp()
|
|
for label in labels {
|
|
if let element = firstMatchIfExists(app: app, label: label, timeout: timeoutPerAttempt) {
|
|
return element
|
|
}
|
|
}
|
|
}
|
|
|
|
for _ in 0..<swipeCount {
|
|
app.swipeDown()
|
|
for label in labels {
|
|
if let element = firstMatchIfExists(app: app, label: label, timeout: timeoutPerAttempt) {
|
|
return element
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func tapDoneInNavigationBar(app: XCUIApplication, title: String) {
|
|
for navTitle in [title] {
|
|
for done in doneLabels {
|
|
let navDone = app.navigationBars[navTitle].buttons[done].firstMatch
|
|
if navDone.waitForExistence(timeout: 2) {
|
|
navDone.tap()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if let anyDone = firstExistingDoneButton(app: app, timeout: 4) {
|
|
anyDone.tap()
|
|
}
|
|
}
|
|
|
|
private func firstExistingDoneButton(app: XCUIApplication, timeout: TimeInterval) -> XCUIElement? {
|
|
firstExistingButton(app: app, labels: doneLabels, timeout: timeout)
|
|
}
|
|
|
|
private func firstExistingButton(app: XCUIApplication, labels: [String], timeout: TimeInterval) -> XCUIElement? {
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
while Date() < deadline {
|
|
for label in labels {
|
|
let button = app.buttons[label].firstMatch
|
|
if button.exists {
|
|
return button
|
|
}
|
|
}
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func openTab(app: XCUIApplication, label: String, index: Int) {
|
|
let localizedLabels = tabLabelMap[label] ?? [label]
|
|
|
|
let tabButtons = app.tabBars.buttons
|
|
if tabButtons.count > index {
|
|
let indexed = tabButtons.element(boundBy: index)
|
|
if indexed.exists {
|
|
indexed.tap()
|
|
return
|
|
}
|
|
}
|
|
|
|
for localizedLabel in localizedLabels {
|
|
let primary = app.tabBars.buttons[localizedLabel].firstMatch
|
|
if primary.waitForExistence(timeout: 2) {
|
|
primary.tap()
|
|
return
|
|
}
|
|
}
|
|
|
|
for localizedLabel in localizedLabels {
|
|
let sidebarButton = app.buttons[localizedLabel].firstMatch
|
|
if sidebarButton.waitForExistence(timeout: 2) {
|
|
sidebarButton.tap()
|
|
return
|
|
}
|
|
let staticText = app.staticTexts[localizedLabel].firstMatch
|
|
if staticText.waitForExistence(timeout: 1) {
|
|
staticText.tap()
|
|
return
|
|
}
|
|
}
|
|
|
|
if let symbol = tabSymbolMap[label] {
|
|
let symbolButton = app.buttons[symbol].firstMatch
|
|
if symbolButton.waitForExistence(timeout: 2) {
|
|
symbolButton.tap()
|
|
return
|
|
}
|
|
}
|
|
|
|
for localizedLabel in localizedLabels {
|
|
if let found = findElementWithVerticalSearch(
|
|
app: app,
|
|
label: localizedLabel,
|
|
timeoutPerAttempt: 0.4,
|
|
swipeCount: 8
|
|
) {
|
|
if found.isHittable {
|
|
found.tap()
|
|
} else {
|
|
found.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
private func saveScreenshot(app: XCUIApplication, named name: String) {
|
|
let attachment = XCTAttachment(screenshot: app.screenshot())
|
|
attachment.name = name
|
|
attachment.lifetime = .keepAlways
|
|
add(attachment)
|
|
}
|
|
|
|
private func openHistoryDayDetailWithData(app: XCUIApplication) {
|
|
openTab(app: app, label: "History", index: 3)
|
|
|
|
// Locale-agnostic: prefer a day-like button with digits in its label.
|
|
let dayPredicate = NSPredicate(format: "label MATCHES %@", ".*[0-9].*")
|
|
let dayButton = app.buttons.matching(dayPredicate).firstMatch
|
|
if dayButton.waitForExistence(timeout: 4) {
|
|
dayButton.tap()
|
|
} else {
|
|
let buttons = app.buttons.allElementsBoundByIndex
|
|
if let fallback = buttons.first(where: { $0.exists && $0.isHittable }) {
|
|
fallback.tap()
|
|
}
|
|
}
|
|
|
|
_ = firstExistingDoneButton(app: app, timeout: 2)
|
|
}
|
|
|
|
private func tapFirstAvailableElementIfPresent(
|
|
app: XCUIApplication,
|
|
identifiers: [String],
|
|
fallbackLabels: [String],
|
|
timeout: TimeInterval = 8
|
|
) -> Bool {
|
|
func firstExistingElement(for label: String, timeout: TimeInterval) -> XCUIElement? {
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
while Date() < deadline {
|
|
let candidates = [
|
|
app.buttons[label],
|
|
app.otherElements[label],
|
|
app.staticTexts[label],
|
|
app.descendants(matching: .any)[label]
|
|
]
|
|
if let element = candidates.first(where: { $0.exists }) {
|
|
return element
|
|
}
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
for identifier in identifiers {
|
|
if let element = firstExistingElement(for: identifier, timeout: 1) {
|
|
if element.isHittable {
|
|
element.tap()
|
|
return true
|
|
}
|
|
let coordinate = element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
|
|
coordinate.tap()
|
|
return true
|
|
}
|
|
}
|
|
|
|
for label in fallbackLabels {
|
|
if let element = firstExistingElement(for: label, timeout: timeout) {
|
|
if element.isHittable {
|
|
element.tap()
|
|
return true
|
|
}
|
|
let coordinate = element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
|
|
coordinate.tap()
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
}
|