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=["", "'])
+ 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),
+ ('', 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