diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1cd1bde..7fe3e8e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,8 @@ concurrency: jobs: python: name: Python tooling ${{ matrix.python }} - runs-on: ubuntu-latest + # Python 3.9 has no setup-python distribution for Ubuntu 26.04. + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: diff --git a/docs/testing.md b/docs/testing.md index e93a019..8a6d383 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -585,7 +585,7 @@ Preview regressions check readiness before navigation, wrong-answer feedback, re entry/exit and literal label preservation. These checks do not establish physical delivery or audible output. See the [reliability review](native-ui-reliability-review-2026-10-03.md). -The full local/CI command creates and deletes its own simulator, builds normally, +The full local/CI command creates and deletes its own disposable simulators, builds normally, runs native KMP and XCTest/UI suites (including all retained-harness groups), nine locales on actual views, Maestro 2.10.0 client flows, all four Debug/Release SDK links, resource checks and an unsigned Release archive: @@ -596,6 +596,12 @@ M7_DEVICE=com.apple.CoreSimulator.SimDeviceType.iPad-Pro-11-inch-M5-12GB \ M7_OUTPUT=build/native-ui-m7-ipad bash scripts/ci_native_ios.sh ``` +The client flows use a fresh simulator, separate from XCTest and locale captures. +The live AlarmKit XCTest grants authorization, and Maestro's generic permission +reset does not clear AlarmKit's grant. Reusing that simulator would let the denial +fixture save an enabled alarm instead of retaining an unsaved draft. Both simulator +identities are recorded (`simulator.txt` and `client/simulator.txt`) and cleaned up. + Set `MAESTRO_BIN` to a pinned 2.10.0 executable if it is not on PATH. The workflow verifies the release ZIP checksum. Use a new output directory per run (xcresult creation deliberately refuses to overwrite prior evidence). CI runs both families diff --git a/iosApp/MathAlarmUITests/NativeFlowTests.swift b/iosApp/MathAlarmUITests/NativeFlowTests.swift index 88ffe3a..a941b8e 100644 --- a/iosApp/MathAlarmUITests/NativeFlowTests.swift +++ b/iosApp/MathAlarmUITests/NativeFlowTests.swift @@ -106,12 +106,14 @@ final class NativeFlowTests: XCTestCase { let enabled = app.switches["editor-enabled"] XCTAssertTrue(enabled.exists) if enabled.value as? String == "1" { - // Toggle accessibility frames include the label; target the trailing - // switch itself independently of the editor column's width. - enabled.coordinate(withNormalizedOffset: CGVector(dx: 1, dy: 0.5)) - .withOffset(CGVector(dx: -25, dy: 0)).tap() + // The outer SwiftUI Toggle includes the row's label and padding. + // Tap the actual native switch instead of estimating its inset. + let control = enabled.switches.firstMatch + XCTAssertTrue(control.waitForExistence(timeout: 5), enabled.debugDescription) + control.tap() } - XCTAssertEqual(enabled.value as? String, "0") + let disabled = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == %@", "0"), object: enabled) + XCTAssertEqual(XCTWaiter.wait(for: [disabled], timeout: 5), .completed, enabled.debugDescription) app.buttons["save-alarm"].tap() XCTAssertTrue(alarmButton(name, in: app).waitForExistence(timeout: 10)) XCTAssertTrue(app.buttons["save-alarm"].waitForNonExistence(timeout: 10)) @@ -429,7 +431,8 @@ final class NativeFlowTests: XCTestCase { XCTAssertTrue(sound.isHittable, app.debugDescription) sound.tap() - let navigation = app.navigationBars.firstMatch + // iPad also exposes the sidebar navigation bar before the detail bar. + let navigation = app.navigationBars["Alarm sound"] let back = navigation.buttons["BackButton"].firstMatch XCTAssertTrue(back.waitForExistence(timeout: 5)) XCTAssertEqual(navigation.buttons.count, 1, navigation.debugDescription) diff --git a/iosApp/iosApp/NativeAlarmViews.swift b/iosApp/iosApp/NativeAlarmViews.swift index decf6f3..35ab7c0 100644 --- a/iosApp/iosApp/NativeAlarmViews.swift +++ b/iosApp/iosApp/NativeAlarmViews.swift @@ -8,12 +8,13 @@ import UIKit private struct NativeNextAlarmSubtitle: ViewModifier { let alarms: [app.Alarm] let loading: Bool + let referenceDate: Date? @Environment(\.scenePhase) private var scenePhase @State private var now = Date() @ScaledMetric(relativeTo: .body) private var subtitleSize = 17 func body(content: Content) -> some View { - let subtitle = loading ? "" : NativeAlarmPresentation.nextAlarmSubtitle(alarms, now: now) + let subtitle = loading ? "" : NativeAlarmPresentation.nextAlarmSubtitle(alarms, now: referenceDate ?? now) content .navigationSubtitle(Text(verbatim: subtitle)) .background(NativeNextAlarmSubtitleStyle(subtitle: subtitle, pointSize: subtitleSize) @@ -145,6 +146,9 @@ struct NativeAlarmList: View { @ObservedObject var sessions: NativeWindowSessions let openEditor: (app.Alarm?) -> Void let openSettings: () -> Void + // Rendered verification can share a fixed clock with its assertions. + // Production callers keep the live, minute-updated clock. + var referenceDate: Date? = nil @State private var clearConfirmation = false @State private var errorResult: AlarmListResult? @State private var notice: NativeAlarmListNotice? @@ -164,7 +168,7 @@ struct NativeAlarmList: View { } .matAlarmContent() .navigationTitle("Math Alarm") - .modifier(NativeNextAlarmSubtitle(alarms: model.state.alarms, loading: model.state.loading)) + .modifier(NativeNextAlarmSubtitle(alarms: model.state.alarms, loading: model.state.loading, referenceDate: referenceDate)) .toolbar { listToolbar } @@ -498,7 +502,11 @@ struct NativeEditorStack: View { isPresented: Binding(get: { errorResult != nil && sessions.selectedEditorID == errorSessionID && !sessions.deliveryPresented && !permissionNeedsSettings && !sessions.permissionRequests.contains(errorSessionID ?? "") }, - set: { if !$0 && !permissionNeedsSettings { errorResult = nil } })) { + // SwiftUI can send a delayed dismissal while delivery + // yields this alert. Only its explicit actions consume + // the retained result; a framework callback must not + // clear a result that has just become presentable again. + set: { _ in })) { let presentedSessionID = errorSessionID let presentedResultID = errorResult?.id if errorResult?.event is AlarmSettingsViewModel.UiEventRequestExactAlarmPermission { diff --git a/iosApp/iosApp/NativePresentationVerification.swift b/iosApp/iosApp/NativePresentationVerification.swift index 97d08ea..545a9c8 100644 --- a/iosApp/iosApp/NativePresentationVerification.swift +++ b/iosApp/iosApp/NativePresentationVerification.swift @@ -53,6 +53,8 @@ enum NativePresentationVerification { } private final class Mount: ObservableObject { + // The view and assertions must sample the same instant at minute boundaries. + let referenceDate = Date() @Published var screen = Screen.editor @Published var largeText = false var expectedIncreasedContrast = false @@ -77,7 +79,7 @@ enum NativePresentationVerification { case .list: NavigationStack { NativeAlarmList(model: list, sessions: sessions, - openEditor: { _ in }, openSettings: {}) + openEditor: { _ in }, openSettings: {}, referenceDate: mount.referenceDate) } case .settings: NavigationStack { @@ -383,10 +385,10 @@ enum NativePresentationVerification { } let nearestRequest = scheduler.registrations.values.min { $0.timeInMillis < $1.timeInMillis }! let nearestScheduledTime = nearestRequest.timeInMillis - let next = NativeAlarmPresentation.nextAlarmDate(list.state.alarms, now: Date())! + let next = NativeAlarmPresentation.nextAlarmDate(list.state.alarms, now: mount.referenceDate)! precondition(abs(next.timeIntervalSince1970 * 1_000 - Double(nearestScheduledTime)) < 1, "next-alarm date=\(next) registered=\(nearestScheduledTime) rows=\(list.state.alarms)") - let subtitle = NativeAlarmPresentation.nextAlarmSubtitle(list.state.alarms, now: Date()) + let subtitle = NativeAlarmPresentation.nextAlarmSubtitle(list.state.alarms, now: mount.referenceDate) await wait("native next-alarm subtitle \(subtitle)") { visibleNavigation(in: host)?.navigationItem.subtitle == subtitle } @@ -409,9 +411,9 @@ enum NativePresentationVerification { } if index == 0 { let remainingScheduledTime = scheduler.registrations.values.map(\.timeInMillis).min()! - let next = NativeAlarmPresentation.nextAlarmDate(list.state.alarms, now: Date())! + let next = NativeAlarmPresentation.nextAlarmDate(list.state.alarms, now: mount.referenceDate)! precondition(abs(next.timeIntervalSince1970 * 1_000 - Double(remainingScheduledTime)) < 1) - let changedSubtitle = NativeAlarmPresentation.nextAlarmSubtitle(list.state.alarms, now: Date()) + let changedSubtitle = NativeAlarmPresentation.nextAlarmSubtitle(list.state.alarms, now: mount.referenceDate) precondition(changedSubtitle != subtitle) await wait("next-alarm subtitle updates after nearest disabled") { visibleNavigation(in: host)?.navigationItem.subtitle == changedSubtitle diff --git a/iosApp/iosApp/NativeSettingsVerification.swift b/iosApp/iosApp/NativeSettingsVerification.swift index 9ad84b6..bf86b73 100644 --- a/iosApp/iosApp/NativeSettingsVerification.swift +++ b/iosApp/iosApp/NativeSettingsVerification.swift @@ -187,7 +187,9 @@ enum NativeSettingsVerification { verificationCheck(anchor.view.window?.windowScene === window.windowScene) verificationCheck(NativeExternalPresentation.shareText == "MathAlarm Clock\nSolve math problems to wake up! https://github.com/t-regbs/MathAlarm") external.presentShare() - await wait("share controller=\(String(describing: external.presentedController)) anchor presentation=\(String(describing: anchor.presentedViewController)) scene=\(String(describing: anchor.view.window?.windowScene?.activationState))") { + // The first activity sheet starts system share/image-provider services. + // Cold hosted simulators can exceed the ordinary in-process UI budget. + await wait("share presentation settled: \(presentationEvidence(external: external, anchor: anchor, settings: settingsController))", timeout: .seconds(30)) { guard let share = external.presentedController as? UIActivityViewController else { return false } return anchor.presentedViewController === share && share.viewIfLoaded?.window === window && !share.isBeingPresented && share.transitionCoordinator == nil @@ -201,7 +203,7 @@ enum NativeSettingsVerification { } verificationCheck(external.failure == nil && requestedURLs.isEmpty) external.presentShare() - await wait("second share presentation settled: \(presentationEvidence(external: external, anchor: anchor, settings: settingsController))") { + await wait("second share presentation settled: \(presentationEvidence(external: external, anchor: anchor, settings: settingsController))", timeout: .seconds(30)) { guard let share = external.presentedController as? UIActivityViewController else { return false } return anchor.presentedViewController === share && share.viewIfLoaded?.window === window && !share.isBeingPresented && share.transitionCoordinator == nil @@ -263,8 +265,9 @@ enum NativeSettingsVerification { VerificationResults.shared.pass("fresh process restores theme/sort and announcement acknowledgement while latest batch remains reopenable") } - private static func wait(_ context: @autoclosure () -> String = "condition", _ condition: () -> Bool) async { - let deadline = ContinuousClock.now + .seconds(8) + private static func wait(_ context: @autoclosure () -> String = "condition", timeout: Duration = .seconds(8), + _ condition: () -> Bool) async { + let deadline = ContinuousClock.now + timeout while !condition() { if ContinuousClock.now >= deadline { failureCapture?() diff --git a/iosApp/iosApp/SharedBridgeVerification.swift b/iosApp/iosApp/SharedBridgeVerification.swift index 53e5995..ea3b553 100644 --- a/iosApp/iosApp/SharedBridgeVerification.swift +++ b/iosApp/iosApp/SharedBridgeVerification.swift @@ -1316,7 +1316,9 @@ enum SharedBridgeVerification { private static func waitForDestination(_ destination: NativeEditorDestination, editorID: String, sessions: NativeWindowSessions, in controller: UIViewController, window: UIWindow, file: StaticString = #fileID, line: UInt = #line) async { - let deadline = ContinuousClock.now + .seconds(5) + // Use the same CI rendering budget as wait(_:). Preview initialization + // and the native transition can exceed five seconds on hosted runners. + let deadline = ContinuousClock.now + .seconds(15) while !(sessions.selectedEditorID == editorID && sessions.selectedEditor?.id == editorID && sessions.editorPaths[editorID]?.last == destination && navigationSettled(in: controller) && visibleDestination(in: controller, destination: destination)) { diff --git a/scripts/alarm_delivery_runner_test.py b/scripts/alarm_delivery_runner_test.py index d6b5135..f918dba 100644 --- a/scripts/alarm_delivery_runner_test.py +++ b/scripts/alarm_delivery_runner_test.py @@ -1,7 +1,37 @@ """Failure-path checks for emulator state restoration; no device required.""" import unittest - -from test_alarm_delivery import run_cleanup, apply_idle_test_overrides, idle_test_overrides_applied +import xml.etree.ElementTree as ET +from unittest.mock import Mock + +from test_alarm_delivery import run_cleanup, apply_idle_test_overrides, idle_test_overrides_applied, read_delivery_events + + +class DeliveryEventsTest(unittest.TestCase): + def test_torn_xml_is_retried_and_failure_events_are_preserved(self): + read = Mock(side_effect=["", "' + '[{"alarmId":900001,"event":"audio_failed"},' + '{"alarmId":12,"event":"received"}]']) + pause = Mock() + self.assertEqual(read_delivery_events(read, pause=pause), + [{"alarmId": 900001, "event": "audio_failed"}]) + self.assertEqual(read.call_count, 3) + self.assertEqual(pause.call_count, 2) + + def test_persistent_corruption_fails_after_bounded_retries(self): + read, pause = Mock(return_value=""), Mock() + with self.assertRaises(ET.ParseError): + read_delivery_events(read, pause=pause) + self.assertEqual(read.call_count, 5) + self.assertEqual(pause.call_count, 4) + + def test_adb_and_telemetry_schema_errors_are_not_hidden(self): + for value, error in [(RuntimeError("adb failed"), RuntimeError), + ("", ValueError), + ('bad json', ValueError)]: + read = Mock(side_effect=value) if isinstance(value, Exception) else Mock(return_value=value) + with self.assertRaises(error): + read_delivery_events(read, pause=Mock()) + self.assertEqual(read.call_count, 1) class CleanupTest(unittest.TestCase): diff --git a/scripts/ci_native_ios.sh b/scripts/ci_native_ios.sh index 043d22b..5aa5f5b 100644 --- a/scripts/ci_native_ios.sh +++ b/scripts/ci_native_ios.sh @@ -19,7 +19,14 @@ swiftc iosApp/iosApp/PendingDeeplinkStore.swift iosApp/tests/PendingDeeplinkStor runtime="${M7_RUNTIME:-com.apple.CoreSimulator.SimRuntime.iOS-27-0}" device="${M7_DEVICE:-com.apple.CoreSimulator.SimDeviceType.iPhone-17}" udid=$(xcrun simctl create 'MathAlarm M7 CI disposable' "$device" "$runtime") -cleanup() { xcrun simctl shutdown "$udid" >/dev/null 2>&1 || true; xcrun simctl delete "$udid"; } +client_udid="" +cleanup() { + for simulator in "$udid" "$client_udid"; do + [[ -z "$simulator" ]] && continue + xcrun simctl shutdown "$simulator" >/dev/null 2>&1 || true + xcrun simctl delete "$simulator" || true + done +} trap cleanup EXIT printf '%s\n%s\n%s\n' "$udid" "$runtime" "$device" > "$out/simulator.txt" xcrun simctl boot "$udid" @@ -33,8 +40,17 @@ python3 -B scripts/verify_ios_release.py --app "$app" | tee "$out/package-Debug- # Keep verify_ios_shared_bridge.py as a standalone diagnostic, not a duplicate CI run. python3 -B scripts/verify_ios_native_presentation.py --udid "$udid" --app "$app" --output "$out/presentation" python3 -B scripts/native_screenshot_review.py "$out/presentation" +# The live AlarmKit XCTest grants authorization. Maestro's generic privacy reset +# does not reset AlarmKit, so denial flows need a fresh OS permission store. +xcrun simctl shutdown "$udid" +client_udid=$(xcrun simctl create 'MathAlarm M7 client disposable' "$device" "$runtime") +mkdir -p "$out/client" +printf '%s\n%s\n%s\n' "$client_udid" "$runtime" "$device" > "$out/client/simulator.txt" +xcrun simctl boot "$client_udid" +xcrun simctl bootstatus "$client_udid" -b +xcrun simctl install "$client_udid" "$app" # Maestro 2.10.0 must be provisioned by CI, never silently use an older driver. -M7_UDID="$udid" M7_OUTPUT="$out/client" bash scripts/ci_native_client.sh +M7_UDID="$client_udid" M7_OUTPUT="$out/client" bash scripts/ci_native_client.sh for configuration in Debug Release; do for platform in 'iOS' 'iOS Simulator'; do xcodebuild "${project[@]}" -configuration "$configuration" -destination "generic/platform=$platform" build > "$out/link-$configuration-${platform// /_}.log" 2>&1 diff --git a/scripts/maestro/ios/native-m6-disabled-crud.yaml b/scripts/maestro/ios/native-m6-disabled-crud.yaml index 20bb325..e84c675 100644 --- a/scripts/maestro/ios/native-m6-disabled-crud.yaml +++ b/scripts/maestro/ios/native-m6-disabled-crud.yaml @@ -38,10 +38,11 @@ appId: com.timilehinaregbesola.mathalarm - assertNotVisible: id: save-alarm - takeScreenshot: native-disabled-crud-saved +# The combined alarm label belongs to the edit button inside the row. - swipe: from: text: ".*, ${FIXTURE_TITLE}, .*" - id: "alarm-row-.*" + id: "edit-alarm-.*" direction: LEFT - tapOn: Delete - assertNotVisible: @@ -53,7 +54,7 @@ appId: com.timilehinaregbesola.mathalarm - swipe: from: text: ".*, ${FIXTURE_TITLE}, .*" - id: "alarm-row-.*" + id: "edit-alarm-.*" direction: LEFT - tapOn: Delete - assertNotVisible: diff --git a/scripts/maestro/ios/native-m6-keyboard.yaml b/scripts/maestro/ios/native-m6-keyboard.yaml index 062008b..fdde31b 100644 --- a/scripts/maestro/ios/native-m6-keyboard.yaml +++ b/scripts/maestro/ios/native-m6-keyboard.yaml @@ -41,6 +41,11 @@ appId: com.timilehinaregbesola.mathalarm id: challenge-answer - inputText: "-123" - assertVisible: "-123" +# Text injection restores the software keyboard asynchronously. Wait before +# resolving Clear so Maestro does not tap its pre-keyboard coordinates. +- assertVisible: + id: challenge-keyboard-done +- waitForAnimationToEnd - assertVisible: id: challenge-clear - tapOn: diff --git a/scripts/maestro/ios/native-m6-permission-denial.yaml b/scripts/maestro/ios/native-m6-permission-denial.yaml index 32c06c9..45dc0a7 100644 --- a/scripts/maestro/ios/native-m6-permission-denial.yaml +++ b/scripts/maestro/ios/native-m6-permission-denial.yaml @@ -51,6 +51,11 @@ appId: com.timilehinaregbesola.mathalarm commands: - takeScreenshot: native-alarmkit-permission-retry - tapOn: "Don’t Allow|Don't Allow" +# A matching label in the saved alarm list is not a retained editor draft. +- assertVisible: + id: editor-title +- assertVisible: + id: save-alarm - assertVisible: "Native M6 permission denial" - assertNotVisible: "Permission Required" - assertNotVisible: "Enable alarms in Settings" @@ -79,6 +84,11 @@ appId: com.timilehinaregbesola.mathalarm - assertVisible: "Allow alarms" - assertNotVisible: "Go to Settings" - tapOn: "Keep editing" +# A matching label in the saved alarm list is not a retained editor draft. +- assertVisible: + id: editor-title +- assertVisible: + id: save-alarm - assertVisible: "Native M6 permission denial" - tapOn: id: discard-draft diff --git a/scripts/test_alarm_delivery.py b/scripts/test_alarm_delivery.py index 4edde32..c2ed883 100644 --- a/scripts/test_alarm_delivery.py +++ b/scripts/test_alarm_delivery.py @@ -62,6 +62,23 @@ def idle_test_overrides_applied(dump): re.search(r"^\s*idle_to=\+?30s0ms\s*$", dump, re.M)) +def read_delivery_events(read, attempts=5, pause=time.sleep): + """Retry torn SharedPreferences XML reads, but fail if no valid snapshot arrives.""" + for attempt in range(attempts): + raw = read() # adb/permission errors are not parsing races. + try: + root = ET.fromstring(raw) + except ET.ParseError: + if attempt == attempts - 1: + raise + pause(0.1) + continue + entry = root.find("string[@name='events']") + if entry is None or entry.text is None: + raise ValueError("Delivery telemetry is missing its events entry") + return [event for event in json.loads(entry.text) if event["alarmId"] == ALARM_ID] + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--serial", required=True) @@ -88,9 +105,8 @@ def instrument(method, delay=45): raise AssertionError(output) def events(): - raw = adb("shell", "run-as", PACKAGE, "cat", "shared_prefs/alarm_delivery_log.xml") - return [e for e in json.loads(ET.fromstring(raw).find("string[@name='events']").text) - if e["alarmId"] == ALARM_ID] + return read_delivery_events(lambda: adb( + "shell", "run-as", PACKAGE, "cat", "shared_prefs/alarm_delivery_log.xml")) def wait_for(description, predicate, timeout): deadline = time.monotonic() + timeout