diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c4f85c..9b9eae0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -233,9 +233,18 @@ jobs: - name: Boot and configure simulator run: | UDID="${{ steps.sim.outputs.udid }}" + # Raise the per-host pseudo-terminal cap. xcodebuild allocates a + # fresh PTY per test launch and never reclaims them, so the macOS + # default of 127 starves late in long suites as "Lost connection to + # the application" and element-not-hittable errors. + sudo sysctl -w kern.tty.ptmx_max=999 || true xcrun simctl boot "$UDID" || true xcrun simctl bootstatus "$UDID" || true xcrun simctl spawn "$UDID" defaults write -g AppleLocale en_US + # Pin the simulator timezone — runner image defaults to UTC, which + # puts CI deeper into "today" the later in the day a job fires. + xcrun simctl spawn "$UDID" defaults write -g AppleTimeZone "America/Los_Angeles" + xcrun simctl spawn "$UDID" launchctl setenv TZ "America/Los_Angeles" xcrun simctl spawn "$UDID" defaults write com.apple.Preferences KeyboardAutocorrection -bool false xcrun simctl spawn "$UDID" defaults write com.apple.Preferences KeyboardAutocapitalization -bool false xcrun simctl spawn "$UDID" defaults write com.apple.Preferences KeyboardPrediction -bool false @@ -251,12 +260,54 @@ jobs: with: name: derived-data-${{ matrix.ios-label }} path: DerivedData + - name: Pre-warm app launch + run: | + UDID="${{ steps.sim.outputs.udid }}" + APP_PATH=$(find DerivedData/Build/Products -name "SF50 TOLD.app" -type d | head -n 1) + if [ -z "$APP_PATH" ]; then + echo "::warning::SF50 TOLD.app not found under derived data; skipping pre-warm" + exit 0 + fi + echo "Pre-warming using $APP_PATH" + xcrun simctl install "$UDID" "$APP_PATH" + xcrun simctl launch "$UDID" codes.tim.SF50-TOLD || true + sleep 5 + xcrun simctl terminate "$UDID" codes.tim.SF50-TOLD || true - name: Run UI tests run: | set -o pipefail + rm -rf /tmp/ui-tests.xcresult + # `-retry-tests-on-failure -test-iterations 3` reruns only failing + # tests up to 3x. xcodebuild exits non-zero whenever any iteration + # fails (even when a later retry passed), so swallow the exit code + # and decide pass/fail by parsing the xcresult below. xcodebuild test-without-building \ -scheme "SF50 TOLD" -testPlan "SF50 TOLD UI Tests" \ -destination "platform=iOS Simulator,id=${{ steps.sim.outputs.udid }}" \ - -destination-timeout 300 \ + -destination-timeout 600 \ -derivedDataPath DerivedData \ - | xcbeautify --renderer github-actions + -resultBundlePath /tmp/ui-tests.xcresult \ + -retry-tests-on-failure -test-iterations 3 \ + 2>&1 | xcbeautify --renderer github-actions || true + # Counts a test as a "real failure" only if EVERY iteration failed. + # A retried-then-passed test has both 'Failed' and 'Passed' Test Case + # nodes and the `select(... | not)` predicate drops it. + FAILED=$(xcrun xcresulttool get test-results tests \ + --path /tmp/ui-tests.xcresult --format json \ + | jq '[.. | objects | select(.nodeType? == "Test Case") | {name: (.nodeIdentifier // .name), result}] | group_by(.name) | map(select([.[].result] | any(. == "Passed" or . == "Skipped" or . == "Expected Failure") | not)) | length') + if [ "$FAILED" = "0" ]; then + echo "UI tests passed (after retries)" + else + echo "UI tests failed: $FAILED test(s) failed every iteration" + xcrun xcresulttool get test-results tests \ + --path /tmp/ui-tests.xcresult --format json \ + | jq -r '[.. | objects | select(.nodeType? == "Test Case") | {name: (.nodeIdentifier // .name), result}] | group_by(.name) | map(select([.[].result] | any(. == "Passed" or . == "Skipped" or . == "Expected Failure") | not)) | .[] | .[0].name' + exit 1 + fi + - name: Upload xcresult on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: ui-tests-xcresult-${{ matrix.device }}-${{ matrix.ios-label }} + path: /tmp/ui-tests.xcresult + retention-days: 7 diff --git a/.github/workflows/periphery.yml b/.github/workflows/periphery.yml new file mode 100644 index 0000000..f47f8b7 --- /dev/null +++ b/.github/workflows/periphery.yml @@ -0,0 +1,62 @@ +name: Periphery + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + periphery: + name: Run Periphery + runs-on: macos-26 + steps: + - name: Disable Spotlight indexing + run: | + sudo mdutil -a -i off + sudo launchctl bootout system/com.apple.metadata.mds 2>/dev/null || true + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - uses: SwiftyLab/setup-swift@latest + with: + swift-version: "6.3" + - name: Ensure iOS simulator runtime + run: | + VERSION=$(xcrun --sdk iphonesimulator --show-sdk-version) + if xcrun simctl list runtimes | grep -q "iOS ${VERSION}"; then + echo "iOS ${VERSION} runtime already installed" + else + echo "iOS ${VERSION} runtime not found, downloading..." + xcodebuild -downloadPlatform iOS + fi + - name: Create simulator + id: sim + run: | + while xcrun simctl delete "periphery-sim" 2>/dev/null; do :; done + UDID=$(xcrun simctl create "periphery-sim" com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro) + echo "udid=$UDID" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v6 + - name: Generate NOTAM API config + env: + NOTAM_API_TOKEN: ${{ secrets.NOTAM_API_TOKEN }} + NOTAM_API_BASE_URL: ${{ secrets.NOTAM_API_BASE_URL }} + run: | + mkdir -p "SF50 TOLD/NOTAM" + cat > "SF50 TOLD/NOTAM/NOTAMAPIConfig.xcconfig" << EOF + NOTAM_API_TOKEN = ${NOTAM_API_TOKEN} + NOTAM_API_BASE_URL = https:/\$()/notams.fly.dev + EOF + - name: Enable macros + run: defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES + - name: Install Periphery + run: brew install periphery + - name: Run Periphery + run: | + periphery scan -- \ + -destination "platform=iOS Simulator,id=${{ steps.sim.outputs.udid }}" \ + -destination-timeout 300 diff --git a/.periphery.yml b/.periphery.yml new file mode 100644 index 0000000..3f6863e --- /dev/null +++ b/.periphery.yml @@ -0,0 +1,9 @@ +project: SF50 TOLD.xcodeproj +schemes: + - SF50 TOLD +exclude_tests: true +exclude_targets: + - SF50 TOLDTests + - SF50 SharedTests + - SF50 TOLDUITests +retain_public: false diff --git a/Gemfile.lock b/Gemfile.lock index 33cbb10..3cdf7fc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -253,6 +253,7 @@ CHECKSUMS base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + bundler (4.0.11) sha256=5bcec0fb78302e48d02ee46f10ee6e6942be647ba5b44a6d1ddfda9a240ce785 claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a diff --git a/SF50 Runways/Views/SelectedAirportWidgetEntryView.swift b/SF50 Runways/Views/SelectedAirportWidgetEntryView.swift index 91e26e4..29ab848 100644 --- a/SF50 Runways/Views/SelectedAirportWidgetEntryView.swift +++ b/SF50 Runways/Views/SelectedAirportWidgetEntryView.swift @@ -1,4 +1,3 @@ -import SF50_Shared import SwiftUI import WidgetKit diff --git a/SF50 TOLD/Extensions.swift b/SF50 TOLD/Extensions.swift index 1228a02..834165b 100644 --- a/SF50 TOLD/Extensions.swift +++ b/SF50 TOLD/Extensions.swift @@ -1,5 +1,3 @@ -import SF50_Shared - extension Array where Element: Equatable { mutating func appendRemovingDuplicates(of newElement: Element) { self.removeAll { $0 == newElement } diff --git a/SF50 TOLD/TLR/TLRModels.swift b/SF50 TOLD/TLR/TLRModels.swift index 429e583..0977af2 100644 --- a/SF50 TOLD/TLR/TLRModels.swift +++ b/SF50 TOLD/TLR/TLRModels.swift @@ -116,15 +116,6 @@ struct AircraftInfo { let emptyWeight: Measurement } -/// Wind information for TLR display. Direction is nil for variable or calm winds. -struct WindInfo { - /// Wind direction in degrees true (nil for variable/calm). - let direction: Measurement? - - /// Wind speed. - let speed: Measurement -} - /// Runway analysis results showing weight limits. /// /// ``RunwayInfo`` captures the maximum weight that can be used for a runway @@ -162,27 +153,6 @@ struct PerformanceDistance { // MARK: - Takeoff Data Structures -/// Planned takeoff conditions for display in the TLR header. -struct TakeoffData { - /// Airport identifier. - let airport: String - - /// Selected runway designator. - let plannedRunway: String - - /// Outside air temperature. - let plannedOAT: Measurement - - /// Wind conditions. - let plannedWind: WindInfo - - /// Altimeter setting. - let plannedQNH: Measurement - - /// Planned takeoff weight. - let plannedTOW: Measurement -} - /// Calculated takeoff performance for a single runway. /// /// Contains ground run, total distance (to 50'), climb gradient, and @@ -212,30 +182,6 @@ struct TakeoffPerformanceScenario { // MARK: - Landing Data Structures -/// Planned landing conditions for display in the TLR header. -struct LandingData { - /// Airport identifier. - let airport: String - - /// Selected runway designator. - let plannedRunway: String - - /// Outside air temperature. - let plannedOAT: Measurement - - /// Wind conditions. - let plannedWind: WindInfo - - /// Altimeter setting. - let plannedQNH: Measurement - - /// Planned landing weight. - let plannedLW: Measurement - - /// Flap configuration description. - let configuration: String -} - /// Calculated landing performance for a single runway. /// /// Contains Vref, landing run, landing distance (to 50'), go-around compliance, @@ -279,9 +225,3 @@ struct ReportOutput { /// Performance calculations for each scenario. let scenarios: [ScenarioType] } - -/// Report output specialized for takeoff scenarios. -typealias TakeoffReportOutput = ReportOutput - -/// Report output specialized for landing scenarios. -typealias LandingReportOutput = ReportOutput diff --git a/SF50 TOLD/Views/Helpers/View Functions.swift b/SF50 TOLD/Views/Helpers/View Functions.swift index 4b2e242..83bf901 100644 --- a/SF50 TOLD/Views/Helpers/View Functions.swift +++ b/SF50 TOLD/Views/Helpers/View Functions.swift @@ -4,25 +4,12 @@ import SwiftUI import UIKit extension View { - func hideKeyboard() { - UIApplication.shared.sendAction( - #selector(UIResponder.resignFirstResponder), - to: nil, - from: nil, - for: nil - ) - } - func localizedModel() -> String { UIDevice.current.localizedModel } } #else extension View { - func hideKeyboard() { - // noop - } - func localizedModel() -> String { "device" } diff --git a/SF50 TOLD/Views/Overlays/ErrorSheet.swift b/SF50 TOLD/Views/Overlays/ErrorSheet.swift index bd2d2ef..53792ca 100644 --- a/SF50 TOLD/Views/Overlays/ErrorSheet.swift +++ b/SF50 TOLD/Views/Overlays/ErrorSheet.swift @@ -1,5 +1,4 @@ import Foundation -import SF50_Shared import SwiftUI struct ErrorSheet: View { diff --git a/SF50 TOLD/Views/Performance/Map/CoordinateCalculations.swift b/SF50 TOLD/Views/Performance/Map/CoordinateCalculations.swift index 38cfd62..d2ffdf1 100644 --- a/SF50 TOLD/Views/Performance/Map/CoordinateCalculations.swift +++ b/SF50 TOLD/Views/Performance/Map/CoordinateCalculations.swift @@ -5,7 +5,7 @@ import SF50_Shared /// Calculates the initial bearing from one coordinate to another. /// /// Delegates to `GeoCalculations.bearing(from:to:)`. -public func bearing( +func bearing( from start: CLLocationCoordinate2D, to end: CLLocationCoordinate2D ) -> Measurement { @@ -15,7 +15,7 @@ public func bearing( /// Calculates a destination coordinate given a starting point, distance, and bearing. /// /// Delegates to `GeoCalculations.destination(from:distance:bearing:)`. -public func destination( +func destination( from start: CLLocationCoordinate2D, distance: Measurement, bearing: Measurement @@ -34,7 +34,7 @@ public func destination( /// - thresholdCrossingHeight: TCH for approach (nil to use fallback). /// - glidepathAngle: Glidepath angle from ILS or PAPI/VASI (nil to use fallback). /// - Returns: Distance from threshold to touchdown zone. -public func touchdownZoneOffset( +func touchdownZoneOffset( runwayLength: Measurement, thresholdCrossingHeight: Measurement? = nil, glidepathAngle: Measurement? = nil @@ -69,7 +69,7 @@ public func touchdownZoneOffset( /// - width: Runway width (defaults to 100 feet). /// - Returns: Array of four coordinates representing the runway corners in clockwise order /// starting from the left side of the threshold. -public func runwayCorners( +func runwayCorners( threshold: CLLocationCoordinate2D, heading: Measurement, length: Measurement, @@ -101,30 +101,13 @@ public func runwayCorners( return [thresholdLeft, thresholdRight, farEndRight, farEndLeft] } -/// Calculates the four corner coordinates for a ground run overlay on the runway. -/// -/// - Parameters: -/// - startPoint: Starting coordinate of the ground run. -/// - heading: True heading of the runway in degrees. -/// - distance: Ground run distance. -/// - width: Runway width (defaults to 100 feet). -/// - Returns: Array of four coordinates representing the ground run rectangle in clockwise order. -public func groundRunCorners( - startPoint: CLLocationCoordinate2D, - heading: Measurement, - distance: Measurement, - width: Measurement = .init(value: 100, unit: .feet) -) -> [CLLocationCoordinate2D] { - return runwayCorners(threshold: startPoint, heading: heading, length: distance, width: width) -} - /// A single chevron polygon with its coordinates and whether it uses primary or secondary opacity. -public struct ChevronData { +struct ChevronData { /// The polygon coordinates for this chevron. - public let coordinates: [CLLocationCoordinate2D] + let coordinates: [CLLocationCoordinate2D] /// Whether this chevron uses primary (true) or secondary (false) opacity. - public let isPrimary: Bool + let isPrimary: Bool } /// Generates tessellated chevron polygons along a path to indicate direction of travel. @@ -140,7 +123,7 @@ public struct ChevronData { /// - width: Width of the chevron band. /// - depth: How far back each chevron extends (defaults to 60 feet). Also determines spacing. /// - Returns: Array of ChevronData with coordinates and alternating primary/secondary flag. -public func generateChevrons( +func generateChevrons( startPoint: CLLocationCoordinate2D, heading: Measurement, distance: Measurement, diff --git a/SF50 TOLD/Views/Performance/Takeoff/Config/TakeoffConfigurationView.swift b/SF50 TOLD/Views/Performance/Takeoff/Config/TakeoffConfigurationView.swift index f69084d..dbf70ce 100644 --- a/SF50 TOLD/Views/Performance/Takeoff/Config/TakeoffConfigurationView.swift +++ b/SF50 TOLD/Views/Performance/Takeoff/Config/TakeoffConfigurationView.swift @@ -1,4 +1,3 @@ -import SF50_Shared import SwiftUI struct TakeoffConfigurationView: View { diff --git a/SF50 TOLDUITests/Extensions.swift b/SF50 TOLDUITests/Extensions.swift index ff1ab62..89d58c1 100644 --- a/SF50 TOLDUITests/Extensions.swift +++ b/SF50 TOLDUITests/Extensions.swift @@ -1,5 +1,20 @@ +// swiftlint:disable prefer_nimble import XCTest +/// Centralized timeouts for UI tests. CI sets a multiplier via the +/// `SF50_UI_TEST_TIMEOUT_MULTIPLIER` env var in the test plan; local runs +/// default to 1.0. +enum UITestTimeouts { + static let multiplier: TimeInterval = { + ProcessInfo.processInfo.environment["SF50_UI_TEST_TIMEOUT_MULTIPLIER"] + .flatMap(TimeInterval.init) ?? 1 + }() + + static var element: TimeInterval { 5 * multiplier } + static var launch: TimeInterval { 30 * multiplier } + static var slowElement: TimeInterval { 15 * multiplier } +} + extension XCUIElement { var isVisible: Bool { guard self.exists && !self.frame.isEmpty else { return false } @@ -38,33 +53,107 @@ extension XCUIElement { return self.swipe(to: element) ? element : nil } - // Use the collection view's scrollToItem method via coordinate-based scrolling private func scroll(to element: XCUIElement) -> Bool { var attempts = 0 - while !element.isVisible && attempts < 10 { let startCoordinate = self.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)) let endCoordinate = self.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)) startCoordinate.press(forDuration: 0.01, thenDragTo: endCoordinate) attempts += 1 } - return element.isVisible } - // Fallback to swipe-based scrolling with limits private func swipe(to element: XCUIElement) -> Bool { var attempts = 0 - while !element.isVisible && attempts < 10 { swipeUp() attempts += 1 } - return element.isVisible } } +// MARK: - Wait helpers + +extension XCUIElement { + /// Waits for the element to exist within the project-wide default timeout. + @discardableResult + func wait() -> Bool { + waitForExistence(timeout: UITestTimeouts.element) + } + + /// Short-window probe scaled by the timeout multiplier. Pass the *base* + /// seconds; the multiplier is applied automatically. + @discardableResult + func wait(scaled seconds: TimeInterval) -> Bool { + waitForExistence(timeout: seconds * UITestTimeouts.multiplier) + } + + /// Waits for the element to satisfy a predicate within the default timeout. + @discardableResult + func waitFor(_ predicate: NSPredicate) -> Bool { + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self) + return + XCTWaiter().wait(for: [expectation], timeout: UITestTimeouts.element) == .completed + } +} + +// MARK: - Stable tap + +extension XCUIElement { + /// Tap that waits for frame stability, then taps via center-coordinate. Use + /// after `scrollToElement` for buttons/switches whose frame can briefly + /// invalidate during SwiftUI relayout. Sidesteps "Activation point invalid" + /// errors that plague iPad SwiftUI Form/List cells. + func tapStable(file: StaticString = #filePath, line: UInt = #line) { + waitForStableFrame(requireHittable: true, file: file, line: line) + coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + } + + /// Coordinate-tap variant that waits only for frame stability — does NOT + /// require `isHittable`. For SwiftUI .pickerStyle(.navigationLink) cells + /// whose underlying Button reports not-hittable even after the frame + /// stabilizes. + func coordinateTapWhenFrameStable(file: StaticString = #filePath, line: UInt = #line) { + waitForStableFrame(requireHittable: false, file: file, line: line) + coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + } + + private func waitForStableFrame( + requireHittable: Bool, + file: StaticString, + line: UInt + ) { + let deadline = Date().addingTimeInterval(UITestTimeouts.element) + var lastFrame: CGRect = .null + var stableHits = 0 + while Date() < deadline { + let frameOK = frame.width > 0 && frame.height > 0 + let hittableOK = !requireHittable || isHittable + if !frameOK || !hittableOK { + Thread.sleep(forTimeInterval: 0.1) + continue + } + if frame == lastFrame { + stableHits += 1 + if stableHits >= 2 { break } + } else { + stableHits = 0 + lastFrame = frame + } + Thread.sleep(forTimeInterval: 0.1) + } + let hittableOK = !requireHittable || isHittable + XCTAssertTrue( + hittableOK && frame.width > 0 && frame.height > 0, + "Element not stable for tap (frame=\(frame), hittable=\(isHittable))", + file: file, + line: line + ) + } +} + extension XCUIApplication { func scrollToTop() { // Tap status bar to scroll to top, falling back to coordinate tap @@ -95,6 +184,28 @@ extension XCUIApplication { button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() } } + + /// Resigns first responder, then waits for the keyboard window to leave the + /// hierarchy. Prefers tapping a navbar element to dismiss; falls back to a + /// gentle upward swipe (SwiftUI Form auto-dismisses keyboard on scroll). + func dismissKeyboardStable() { + let keyboard = keyboards.firstMatch + guard keyboard.exists else { return } + + let navBar = navigationBars.firstMatch + if navBar.exists, navBar.isHittable { + navBar.tap() + if keyboard.waitForNonExistence(timeout: UITestTimeouts.element) { return } + } + + let deadline = Date().addingTimeInterval(UITestTimeouts.element) + while keyboard.exists && Date() < deadline { + let start = coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.35)) + let end = coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15)) + start.press(forDuration: 0.05, thenDragTo: end) + _ = keyboard.waitForNonExistence(timeout: 1) + } + } } // Helper function for clearing and typing text in fields @@ -153,7 +264,7 @@ func tapAndEnsureNavigation( for strategy in strategies { guard element.exists else { return } strategy(element) - if expectedElement.waitForExistence(timeout: timeout) { return } + if expectedElement.waitForExistence(timeout: timeout * UITestTimeouts.multiplier) { return } } } @@ -165,3 +276,4 @@ extension XCUIApplication { return descendants(matching: .any).matching(predicate).firstMatch } } +// swiftlint:enable prefer_nimble diff --git a/SF50 TOLDUITests/Pages/ClimbPage.swift b/SF50 TOLDUITests/Pages/ClimbPage.swift index 3d08795..c7e4c18 100644 --- a/SF50 TOLDUITests/Pages/ClimbPage.swift +++ b/SF50 TOLDUITests/Pages/ClimbPage.swift @@ -44,24 +44,44 @@ final class ClimbPage: BasePage { let valueBefore = toggle!.value as? String ?? "unknown" - // Try multiple tap strategies to handle platform differences: - // - iOS 18 Form cells have delaysContentTouches, requiring longer presses - // - iOS 26 iPad Liquid Glass can intercept taps at certain positions - let strategies: [(XCUIElement) -> Void] = [ - { $0.switches.firstMatch.tap() }, - { $0.press(forDuration: 0.2) }, - { $0.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5)).press(forDuration: 0.2) }, - { $0.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5)).tap() } + // SwiftUI `Toggle("Engine IPS", isOn:)` in a Form renders differently per + // platform/iOS version. Cast a wide net so at least one strategy lands a + // touch event that actually flips the switch. + let label = app.staticTexts["Engine IPS"] + let labelCell = app.cells.containing(.staticText, identifier: "Engine IPS").firstMatch + let strategies: [() -> Void] = [ + // Tap a child .switch element — works on iOS 26 where the accessibility + // ID is on a cell wrapping a switch widget + { toggle!.switches.firstMatch.tap() }, + // Tap the row label — SwiftUI Form Toggle forwards row taps to the switch + { if label.exists { label.tap() } }, + { if labelCell.exists { labelCell.tap() } }, + // Sweep tap coordinates across the toggle's frame (handles wide iPad rows) + { toggle!.coordinate(withNormalizedOffset: CGVector(dx: 0.95, dy: 0.5)).tap() }, + { toggle!.coordinate(withNormalizedOffset: CGVector(dx: 0.85, dy: 0.5)).tap() }, + { toggle!.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() }, + { toggle!.coordinate(withNormalizedOffset: CGVector(dx: 0.15, dy: 0.5)).tap() }, + // iOS 18 Form cells have delaysContentTouches — a long press defeats it + { toggle!.press(forDuration: 0.25) }, + { + toggle!.coordinate(withNormalizedOffset: CGVector(dx: 0.85, dy: 0.5)).press( + forDuration: 0.25 + ) + } ] for strategy in strategies { - strategy(toggle!) - Thread.sleep(forTimeInterval: 0.5) - if (toggle!.value as? String ?? "unknown") != valueBefore { return } + strategy() + // Poll up to 1s for the value to flip; tap dispatch can be async on iPad + let deadline = Date().addingTimeInterval(1.0) + while Date() < deadline { + if (toggle!.value as? String ?? "unknown") != valueBefore { return } + Thread.sleep(forTimeInterval: 0.1) + } } XCTFail( - "Failed to toggle ice protection (value stayed \(valueBefore))" + "Failed to toggle ice protection (value stayed \(valueBefore), frame=\(toggle!.frame))" ) } } diff --git a/SF50 TOLDUITests/Pages/ScenarioDetailPage.swift b/SF50 TOLDUITests/Pages/ScenarioDetailPage.swift index 60536d2..2fc7583 100644 --- a/SF50 TOLDUITests/Pages/ScenarioDetailPage.swift +++ b/SF50 TOLDUITests/Pages/ScenarioDetailPage.swift @@ -12,8 +12,13 @@ final class ScenarioDetailPage: BasePage { // MARK: - Actions func setName(_ name: String) { - XCTAssertTrue(nameField.waitForExistence(timeout: 2), "Name field should exist") + XCTAssertTrue(nameField.wait(), "Name field should exist") nameField.clearAndType(name, app: app) + // Ends editing so the name binding commits before any subsequent action + // (other field tap, back-nav, etc.). On iPad iOS 18.4 the first-responder + // transfer from a plain TextField can drop the latest keystroke if we + // don't explicitly resign first responder here. + dismissKeyboard() } func setOATDelta(_ value: String) { diff --git a/SF50 TOLDUITests/Pages/ScenariosSettingsPage.swift b/SF50 TOLDUITests/Pages/ScenariosSettingsPage.swift index afeb296..7875daf 100644 --- a/SF50 TOLDUITests/Pages/ScenariosSettingsPage.swift +++ b/SF50 TOLDUITests/Pages/ScenariosSettingsPage.swift @@ -74,8 +74,11 @@ final class ScenariosSettingsPage: BasePage { } func openScenario(_ name: String) -> ScenarioDetailPage { + // Mirror the search strategy from `scenarioExists`: wait briefly for the + // row to appear in case the SwiftData write + List refresh after goBack + // is still settling, then scroll to find it if it landed below the fold. + XCTAssertTrue(scenarioExists(name), "Scenario \(name) should exist") let text = app.staticTexts[name] - XCTAssertTrue(text.exists, "Scenario \(name) should exist") forceTap(text) return ScenarioDetailPage(app: app) } diff --git a/SF50 TOLDUITests/SF50 TOLD UI Tests.xctestplan b/SF50 TOLDUITests/SF50 TOLD UI Tests.xctestplan index 23b0431..b953692 100644 --- a/SF50 TOLDUITests/SF50 TOLD UI Tests.xctestplan +++ b/SF50 TOLDUITests/SF50 TOLD UI Tests.xctestplan @@ -9,7 +9,12 @@ } ], "defaultOptions" : { - + "environmentVariableEntries" : [ + { + "key" : "SF50_UI_TEST_TIMEOUT_MULTIPLIER", + "value" : "3" + } + ] }, "testTargets" : [ {