Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 7 additions & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
15 changes: 9 additions & 6 deletions iosApp/MathAlarmUITests/NativeFlowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 11 additions & 3 deletions iosApp/iosApp/NativeAlarmViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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?
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 7 additions & 5 deletions iosApp/iosApp/NativePresentationVerification.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down
11 changes: 7 additions & 4 deletions iosApp/iosApp/NativeSettingsVerification.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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?()
Expand Down
4 changes: 3 additions & 1 deletion iosApp/iosApp/SharedBridgeVerification.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
34 changes: 32 additions & 2 deletions scripts/alarm_delivery_runner_test.py
Original file line number Diff line number Diff line change
@@ -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=["", "<map><string", '<map><string name="events">'
'[{"alarmId":900001,"event":"audio_failed"},'
'{"alarmId":12,"event":"received"}]</string></map>'])
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),
("<map/>", ValueError),
('<map><string name="events">bad json</string></map>', 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):
Expand Down
20 changes: 18 additions & 2 deletions scripts/ci_native_ios.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions scripts/maestro/ios/native-m6-disabled-crud.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -53,7 +54,7 @@ appId: com.timilehinaregbesola.mathalarm
- swipe:
from:
text: ".*, ${FIXTURE_TITLE}, .*"
id: "alarm-row-.*"
id: "edit-alarm-.*"
direction: LEFT
- tapOn: Delete
- assertNotVisible:
Expand Down
5 changes: 5 additions & 0 deletions scripts/maestro/ios/native-m6-keyboard.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions scripts/maestro/ios/native-m6-permission-denial.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading