import XCTest class BaseUITestCase: XCTestCase { let app = XCUIApplication() /// Element on current screen — if it's not there in 2s, the app is broken let defaultTimeout: TimeInterval = 2 /// Screen transitions, tab switches let navigationTimeout: TimeInterval = 5 /// Initial auth flow only (cold start) let loginTimeout: TimeInterval = 15 var includeResetStateLaunchArgument: Bool { true } /// Override to `true` in tests that need the standalone login screen /// (skips onboarding). Default is `false` so tests that navigate from /// onboarding or test onboarding screens work without extra config. var completeOnboarding: Bool { false } var additionalLaunchArguments: [String] { [] } /// Override to `true` in suites where each test needs a clean app launch /// (e.g., login/onboarding tests that leave stale field text between tests). var relaunchBetweenTests: Bool { false } /// Tracks whether the app has been launched for the current test suite. /// Reset once per suite via `class setUp()`, so the first test in each /// suite gets a fresh app launch while subsequent tests reuse the session. private static var hasLaunchedForCurrentSuite = false override class func setUp() { super.setUp() hasLaunchedForCurrentSuite = false } override func setUpWithError() throws { continueAfterFailure = false XCUIDevice.shared.orientation = .portrait // Auto-dismiss any system alerts (notifications, tracking, etc.) addUIInterruptionMonitor(withDescription: "System Alert") { alert in let buttons = ["Allow", "OK", "Don't Allow", "Not Now", "Dismiss", "Allow While Using App"] for label in buttons { let button = alert.buttons[label] if button.exists { button.tap() return true } } return false } var launchArguments = [ "--ui-testing", "--disable-animations" ] if completeOnboarding { launchArguments.append("--complete-onboarding") } if includeResetStateLaunchArgument { launchArguments.append("--reset-state") } launchArguments.append(contentsOf: additionalLaunchArguments) app.launchArguments = launchArguments // First test in each suite always gets a clean app launch (handles parallel clone reuse). // Subsequent tests reuse the running app unless relaunchBetweenTests is true. let needsLaunch = !Self.hasLaunchedForCurrentSuite || relaunchBetweenTests || app.state != .runningForeground if needsLaunch { if app.state == .runningForeground { app.terminate() } app.launch() app.otherElements["ui.app.ready"].waitForExistenceOrFail(timeout: loginTimeout) Self.hasLaunchedForCurrentSuite = true } } override func tearDownWithError() throws { if let run = testRun, !run.hasSucceeded { let attachment = XCTAttachment(screenshot: app.screenshot()) attachment.name = "Failure-\(name)" attachment.lifetime = .keepAlways add(attachment) } } } extension XCUIElement { @discardableResult func waitForExistenceOrFail( timeout: TimeInterval, message: String? = nil, file: StaticString = #filePath, line: UInt = #line ) -> XCUIElement { if !waitForExistence(timeout: timeout) { XCTFail(message ?? "Expected element to exist: \(self)", file: file, line: line) } return self } @discardableResult func waitUntilHittable( timeout: TimeInterval, message: String? = nil, file: StaticString = #filePath, line: UInt = #line ) -> XCUIElement { let predicate = NSPredicate(format: "exists == true AND hittable == true") let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self) let result = XCTWaiter().wait(for: [expectation], timeout: timeout) if result != .completed { XCTFail(message ?? "Expected element to become hittable: \(self)", file: file, line: line) } return self } @discardableResult func waitForNonExistence( timeout: TimeInterval, message: String? = nil, file: StaticString = #filePath, line: UInt = #line ) -> Bool { let predicate = NSPredicate(format: "exists == false") let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self) let result = XCTWaiter().wait(for: [expectation], timeout: timeout) if result != .completed { XCTFail(message ?? "Expected element to disappear: \(self)", file: file, line: line) return false } return true } func scrollIntoView( in scrollView: XCUIElement, maxSwipes: Int = 8, file: StaticString = #filePath, line: UInt = #line ) { if isHittable { return } for _ in 0..