// // BaseUITestCase.swift // SportsTimeUITests // // Base class for all UI tests. Provides launch configuration, // screenshot-on-failure, and centralized wait helpers. // import XCTest // MARK: - Base Test Case class BaseUITestCase: XCTestCase { /// The application under test. Configured in setUp. var app: XCUIApplication! /// Standard timeout for element existence checks. static let defaultTimeout: TimeInterval = 15 /// Short timeout for elements expected to appear quickly. static let shortTimeout: TimeInterval = 5 /// Extended timeout for bootstrap / planning engine operations. static let longTimeout: TimeInterval = 30 override func setUpWithError() throws { continueAfterFailure = false // Keep UI tests in a consistent orientation to avoid layout-dependent flakiness. XCUIDevice.shared.orientation = .portrait app = XCUIApplication() app.launchArguments = [ "--ui-testing", "--disable-animations", "--reset-state" ] app.launch() } override func tearDownWithError() throws { // Capture a screenshot on test failure for post-mortem debugging. if let failure = testRun, failure.failureCount > 0 { let screenshot = XCTAttachment(screenshot: app.screenshot()) screenshot.name = "Failure-\(name)" screenshot.lifetime = .keepAlways add(screenshot) } app = nil } // MARK: - Screenshot Helpers /// Captures a named screenshot attached to the test report. func captureScreenshot(named name: String) { let screenshot = XCTAttachment(screenshot: app.screenshot()) screenshot.name = name screenshot.lifetime = .keepAlways add(screenshot) } /// Polls until the condition becomes true or the timeout expires. @discardableResult func waitUntil( timeout: TimeInterval = BaseUITestCase.defaultTimeout, pollInterval: TimeInterval = 0.2, _ message: String? = nil, file: StaticString = #filePath, line: UInt = #line, condition: @escaping () -> Bool ) -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if condition() { return true } let remaining = deadline.timeIntervalSinceNow let interval = min(pollInterval, max(0.01, remaining)) RunLoop.current.run(until: Date().addingTimeInterval(interval)) } let success = condition() XCTAssertTrue(success, message ?? "Condition was not met within \(timeout)s", file: file, line: line) return success } } // MARK: - Wait Helpers @MainActor extension XCUIElement { /// Waits until the element exists, failing with a descriptive message. @discardableResult func waitForExistenceOrFail( timeout: TimeInterval = BaseUITestCase.defaultTimeout, _ message: String? = nil, file: StaticString = #filePath, line: UInt = #line ) -> XCUIElement { let msg = message ?? "Expected \(self) to exist within \(timeout)s" XCTAssertTrue(waitForExistence(timeout: timeout), msg, file: file, line: line) return self } /// Waits until the element exists AND is hittable. @discardableResult func waitUntilHittable( timeout: TimeInterval = BaseUITestCase.defaultTimeout, _ message: String? = nil, file: StaticString = #filePath, line: UInt = #line ) -> XCUIElement { waitForExistenceOrFail(timeout: timeout, message, file: file, line: line) let predicate = NSPredicate(format: "isHittable == true") let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self) let result = XCTWaiter.wait(for: [expectation], timeout: timeout) let msg = message ?? "Expected \(self) to be hittable within \(timeout)s" XCTAssertEqual(result, .completed, msg, file: file, line: line) return self } /// Waits until the element no longer exists. func waitForNonExistence( timeout: TimeInterval = BaseUITestCase.defaultTimeout, _ message: String? = nil, file: StaticString = #filePath, line: UInt = #line ) { let predicate = NSPredicate(format: "exists == false") let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self) let result = XCTWaiter.wait(for: [expectation], timeout: timeout) let msg = message ?? "Expected \(self) to disappear within \(timeout)s" XCTAssertEqual(result, .completed, msg, file: file, line: line) } /// Scrolls a scroll view until this element is hittable, or times out. @discardableResult func scrollIntoView( in scrollView: XCUIElement, direction: ScrollDirection = .down, maxScrolls: Int = 10, file: StaticString = #filePath, line: UInt = #line ) -> XCUIElement { if exists && isHittable { return self } func attemptScroll(_ scrollDirection: ScrollDirection, attempts: Int) -> Bool { var remaining = attempts while (!exists || !isHittable) && remaining > 0 { switch scrollDirection { case .down: scrollView.swipeUp(velocity: .slow) case .up: scrollView.swipeDown(velocity: .slow) } remaining -= 1 } return exists && isHittable } if attemptScroll(direction, attempts: maxScrolls) { return self } let reverseDirection: ScrollDirection = direction == .down ? .up : .down if attemptScroll(reverseDirection, attempts: maxScrolls) { return self } XCTFail( "Could not scroll \(self) into view after \(maxScrolls) scrolls in either direction", file: file, line: line ) return self } } enum ScrollDirection: Equatable { case up, down }