From 52b99add5b49d67e410b6d00dec63189998dbd56 Mon Sep 17 00:00:00 2001 From: Dave Craig Date: Fri, 10 Jul 2026 15:51:22 +0100 Subject: [PATCH 1/3] iOS onboarding flow is different, handle it in maestro test --- maestro/Onboarding.yaml | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/maestro/Onboarding.yaml b/maestro/Onboarding.yaml index ef07b2860..ef63f43d5 100644 --- a/maestro/Onboarding.yaml +++ b/maestro/Onboarding.yaml @@ -3,10 +3,7 @@ appId: org.scottishtecharmy.soundscape - launchApp: clearState: true permissions: - all: deny - notifications: allow - location: allow - microphone: allow + all: allow - runFlow: file: WaitForScreen.yaml @@ -24,6 +21,8 @@ appId: org.scottishtecharmy.soundscape id: "languageScreenContinueButton" - runFlow: + when: + platform: Android file: SwipeAndTap.yaml env: id: "navigatingScreenContinueButton" @@ -33,11 +32,15 @@ appId: org.scottishtecharmy.soundscape # permission, so it is not handled by the launchApp permissions block. We wait # for the system dialog's positive button and tap it, then continue. - runFlow: + when: + platform: Android file: SwipeAndTap.yaml env: id: "batteryOptimizationGrantPermissionButton" - runFlow: + when: + platform: Android file: Wait.yaml env: timeout: 3000 @@ -50,6 +53,8 @@ appId: org.scottishtecharmy.soundscape optional: true - runFlow: + when: + platform: Android file: SwipeAndTap.yaml env: id: "batteryOptimizationContinueButton" @@ -64,6 +69,13 @@ appId: org.scottishtecharmy.soundscape env: id: "hearingScreenContinueButton" +- runFlow: + when: + platform: iOS + file: SwipeAndTap.yaml + env: + id: "permissionsScreenContinueButton" + - runFlow: file: SwipeAndTap.yaml env: @@ -75,6 +87,8 @@ appId: org.scottishtecharmy.soundscape id: "audioBeaconsContinueButton" - runFlow: + when: + platform: Android file: SwipeAndTap.yaml env: id: "offlineStorageOnboardingScreenContinueButton" From 6cab8dab28f2edf03b83eb2d24a411611393ad4f Mon Sep 17 00:00:00 2001 From: Dave Craig Date: Fri, 10 Jul 2026 16:32:44 +0100 Subject: [PATCH 2/3] Fix iOS crash on Add Waypoints Found by Maestro test, adding waypoints to a route was causing a crash due to the non-KMP way in which the view model was being created. A simple fix and some extra debug added to help with future exceptions. --- .../home/placesnearby/PlacesNearbyViewModel.kt | 16 ++++++++++------ .../addandeditroutescreen/AddWaypointsList.kt | 7 +++---- .../soundscape/MainViewController.kt | 1 + .../soundscape/UnhandledExceptionLogger.kt | 17 +++++++++++++++++ 4 files changed, 31 insertions(+), 10 deletions(-) create mode 100644 shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/UnhandledExceptionLogger.kt diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/placesnearby/PlacesNearbyViewModel.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/placesnearby/PlacesNearbyViewModel.kt index 46110877c..9dfe770b5 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/placesnearby/PlacesNearbyViewModel.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/placesnearby/PlacesNearbyViewModel.kt @@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.scottishtecharmy.soundscape.audio.AudioTour import org.scottishtecharmy.soundscape.geoengine.GridState @@ -86,15 +85,20 @@ open class PlacesNearbyViewModel( internalUiState.update { it.copy(userLocation = locationAndGrid.location) } } if (locationAndGrid.gridState != null) { - val pois = runBlocking { - withContext(locationAndGrid.gridState.treeContext) { + val (pois, intersections) = withContext(locationAndGrid.gridState.treeContext) { + val p = runCatching { locationAndGrid.gridState.getFeatureCollection(TreeId.POIS) + }.getOrElse { + println("PlacesNearbyViewModel: POIS lookup failed: ${it.message}") + FeatureCollection() } - } - val intersections = runBlocking { - withContext(locationAndGrid.gridState.treeContext) { + val i = runCatching { locationAndGrid.gridState.getFeatureCollection(TreeId.INTERSECTIONS) + }.getOrElse { + println("PlacesNearbyViewModel: INTERSECTIONS lookup failed: ${it.message}") + FeatureCollection() } + p to i } internalUiState.value = internalUiState.value.copy( nearbyPlaces = pois, diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/markers_routes/screens/addandeditroutescreen/AddWaypointsList.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/markers_routes/screens/addandeditroutescreen/AddWaypointsList.kt index 18f12d324..85b5f849a 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/markers_routes/screens/addandeditroutescreen/AddWaypointsList.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/markers_routes/screens/addandeditroutescreen/AddWaypointsList.kt @@ -71,7 +71,7 @@ fun AddWaypointsList( onSelectLocation: (LocationDescription) -> Unit, onToggleMember: (LocationDescription) -> Unit, getCurrentLocationDescription: () -> LocationDescription, - addWaypointsListViewModel: AddWaypointsListViewModel = viewModel() + addWaypointsListViewModel: AddWaypointsListViewModel = viewModel { AddWaypointsListViewModel() } ) { // Create our list of locations, with those already in the route first val locations = remember(uiState.routeMembers, uiState.markers) { @@ -185,6 +185,7 @@ fun AddWaypointsList( kotlinx.coroutines.delay(1500) announceLoading = true } + val loadingLabel = stringResource(Res.string.general_loading_start) Box( contentAlignment = Alignment.Center, modifier = Modifier @@ -192,9 +193,7 @@ fun AddWaypointsList( .defaultMinSize(minHeight = 48.dp) .then( if (announceLoading) Modifier.semantics { - contentDescription = kotlinx.coroutines.runBlocking { - org.jetbrains.compose.resources.getString(Res.string.general_loading_start) - } + contentDescription = loadingLabel liveRegion = LiveRegionMode.Polite } else Modifier ) diff --git a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/MainViewController.kt b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/MainViewController.kt index 247ddfe67..ba1132824 100644 --- a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/MainViewController.kt +++ b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/MainViewController.kt @@ -40,6 +40,7 @@ import platform.UIKit.UIViewController import platform.UIKit.UIWindow fun MainViewController() = ComposeUIViewController { + installUnhandledExceptionLogger() val service = remember { IosSoundscapeService.getInstance() } val mgr = service.offlineMapManager val prefs = service.preferencesProvider diff --git a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/UnhandledExceptionLogger.kt b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/UnhandledExceptionLogger.kt new file mode 100644 index 000000000..db7750f13 --- /dev/null +++ b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/UnhandledExceptionLogger.kt @@ -0,0 +1,17 @@ +package org.scottishtecharmy.soundscape + +import kotlin.experimental.ExperimentalNativeApi +import kotlin.native.setUnhandledExceptionHook +import platform.Foundation.NSLog + +private val install: Boolean by lazy { + @OptIn(ExperimentalNativeApi::class) + setUnhandledExceptionHook { throwable -> + NSLog("Soundscape uncaught: %@\n%@", throwable.toString(), throwable.stackTraceToString()) + } + true +} + +internal fun installUnhandledExceptionLogger() { + install +} From c38985123e424a5d83cb389a5362df229eb908a1 Mon Sep 17 00:00:00 2001 From: Dave Craig Date: Fri, 10 Jul 2026 16:51:05 +0100 Subject: [PATCH 3/3] Run Maestro tests on iOS as part of run-tests --- .github/workflows/run-tests.yaml | 170 +++++++++++++++++++++++++++++++ maestro/Onboarding.yaml | 11 ++ 2 files changed, 181 insertions(+) diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index f6130759b..6c4ee6d31 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -211,3 +211,173 @@ jobs: - name: Raise error on test fail if: steps.xctest.outcome != 'success' run: exit 1 + + # Run the Maestro UI suite against the iOS simulator build. Mirrors the + # Android `maestro` job: builds the app, boots a sim, installs, pushes the + # Glasgow offline map extract, pins the location, and runs the flows. + ios-maestro: + name: iOS Maestro flows + needs: ios-test + runs-on: [self-hosted, macOS, ARM64] + environment: development + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup iOS toolchain + uses: ./.github/actions/setup-ios-toolchain + + - name: Setup tile and search providers + uses: ./.github/actions/write-provider-config + with: + platform: ios + tile_provider_url: ${{ secrets.TILE_PROVIDER_URL }} + search_provider_url: ${{ secrets.SEARCH_PROVIDER_URL }} + extract_provider_url: ${{ secrets.EXTRACT_PROVIDER_URL }} + + - name: Generate Xcode project + working-directory: iosApp + run: xcodegen generate + + # Maestro 2.x ships its own XCUITest-based iOS driver, so no separate + # idb_companion install is needed. The self-hosted mac already has + # Maestro installed via Homebrew; the installer script refuses to + # overwrite a brew-managed install, so only fetch it if missing. + - name: Install Maestro + run: | + if command -v maestro >/dev/null 2>&1; then + echo "Using existing maestro: $(command -v maestro) ($(maestro --version 2>/dev/null || echo 'version unknown'))" + else + MAESTRO_VERSION=2.6.0 curl -Ls 'https://get.maestro.mobile.dev' | bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + fi + + # Same simulator-picking logic as the ios-test job: the runner image does + # not always pre-register a concrete device, so resolve a UDID at runtime. + - name: Pick iOS simulator + run: | + UDID=$(xcrun simctl list devices available --json \ + | jq -r '[.devices | to_entries[] | select(.key | test("iOS")) | .value[] | select(.isAvailable and (.name | startswith("iPhone")))][0].udid // empty') + if [ -z "$UDID" ]; then + echo "No iPhone simulator pre-registered; creating one." + RUNTIME=$(xcrun simctl list runtimes available -j \ + | jq -r '[.runtimes[] | select(.isAvailable and .platform == "iOS")] | sort_by(.version) | reverse | .[0].identifier') + DEVICETYPE=$(xcrun simctl list devicetypes -j \ + | jq -r '[.devicetypes[] | select(.productFamily == "iPhone")] | sort_by(.name) | reverse | .[0].identifier') + UDID=$(xcrun simctl create "CI iPhone" "$DEVICETYPE" "$RUNTIME") + fi + echo "Using simulator $UDID" + echo "SIMULATOR_UDID=$UDID" >> "$GITHUB_ENV" + echo "SIMULATOR_DESTINATION=platform=iOS Simulator,id=$UDID" >> "$GITHUB_ENV" + + - name: Boot simulator + run: | + xcrun simctl boot "$SIMULATOR_UDID" || true + xcrun simctl bootstatus "$SIMULATOR_UDID" -b + + - name: Build iOS app for simulator + run: | + xcodebuild build \ + -project iosApp/iosApp.xcodeproj \ + -scheme iosApp \ + -configuration Debug \ + -sdk iphonesimulator \ + -destination "$SIMULATOR_DESTINATION" \ + -derivedDataPath iosApp/build/DerivedData \ + CODE_SIGNING_ALLOWED=NO + + - name: Install app on simulator + run: | + # The scheme is `iosApp` but PRODUCT_NAME is `Soundscape`, and this + # runner has Xcode configured with a custom global Derived Data path + # that overrides -derivedDataPath for build products. Ask xcodebuild + # for the resolved BUILT_PRODUCTS_DIR / FULL_PRODUCT_NAME instead of + # guessing. + settings=$(xcodebuild \ + -project iosApp/iosApp.xcodeproj \ + -scheme iosApp \ + -configuration Debug \ + -sdk iphonesimulator \ + -destination "$SIMULATOR_DESTINATION" \ + -derivedDataPath iosApp/build/DerivedData \ + -showBuildSettings 2>/dev/null) + BUILT_PRODUCTS_DIR=$(echo "$settings" | awk -F' = ' '/^ *BUILT_PRODUCTS_DIR = /{print $2; exit}') + FULL_PRODUCT_NAME=$(echo "$settings" | awk -F' = ' '/^ *FULL_PRODUCT_NAME = /{print $2; exit}') + APP_PATH="$BUILT_PRODUCTS_DIR/$FULL_PRODUCT_NAME" + if [ ! -d "$APP_PATH" ]; then + echo "App not found at $APP_PATH" >&2 + exit 1 + fi + echo "Installing $APP_PATH" + xcrun simctl install "$SIMULATOR_UDID" "$APP_PATH" + + # Same extract the Android maestro job pulls, cached alongside the .geojson + # sidecar that ships in the repo under .github/fixtures. + - name: Download offline map extract + run: | + curl -Lsf -o glasgow-gb.pmtiles \ + 'https://pub-0a3501283b024ab3bbfbb6d1e217f5d0.r2.dev/street-metadata/glasgow-gb.pmtiles' + + # Emulator GPS equivalent: pin the simulator to Edinburgh so the + # location-dependent flows run against the Glasgow extract's coverage. + - name: Set simulator location (Edinburgh) + run: xcrun simctl location "$SIMULATOR_UDID" set 55.955360,-3.223538 + + - name: Maestro Tests + id: maestro-tests + continue-on-error: true + run: | + # Onboarding uses "launchApp: clearState: true", which on iOS wipes + # the app sandbox - so we must run Onboarding FIRST and only then + # push the Glasgow extract into the app's Documents dir (that path + # is where IosSoundscapeService reads extracts from). The subsequent + # flows do not clearState, so the pushed files stick around. + # + # rf() retries a flow ONCE when its JUnit report shows the specific + # "Unable to launch app" launch-timeout failure; a blind retry would + # re-apply stateful flows (e.g. LocationDetails' marker creation). + mkdir -p maestro_outputs + status=0 + rf() { + maestro test --format=junit \ + --output="maestro_outputs/report-$1.xml" \ + --test-output-dir=maestro_outputs \ + --no-ansi "maestro/$1.yaml" + r=$? + if [ "$r" -ne 0 ] && grep -q "Unable to launch app" "maestro_outputs/report-$1.xml" 2>/dev/null; then + echo "::warning::launch of $1 failed; retrying once" + maestro test --format=junit \ + --output="maestro_outputs/report-$1.xml" \ + --test-output-dir=maestro_outputs \ + --no-ansi "maestro/$1.yaml" + r=$? + fi + return "$r" + } + + rf Onboarding || status=1 + + # After Onboarding the sandbox exists; copy the extract + geojson into + # Documents where the app looks for them. + DATA_CONTAINER=$(xcrun simctl get_app_container "$SIMULATOR_UDID" org.scottishtecharmy.soundscape data) + echo "App data container: $DATA_CONTAINER" + mkdir -p "$DATA_CONTAINER/Documents" + cp glasgow-gb.pmtiles "$DATA_CONTAINER/Documents/" + cp .github/fixtures/glasgow-gb.pmtiles.geojson "$DATA_CONTAINER/Documents/" + + for flow in HomePage LocationDetails PlacesNearby MarkersAndRoutes RouteCreation; do + rf "$flow" || status=1 + done + exit $status + + - name: Upload maestro reports + uses: actions/upload-artifact@v6 + with: + name: ios-maestro-reports + path: maestro_outputs + + - name: Raise error on test fail + if: steps.maestro-tests.outcome != 'success' + run: exit 1 diff --git a/maestro/Onboarding.yaml b/maestro/Onboarding.yaml index ef63f43d5..68ef7b6f8 100644 --- a/maestro/Onboarding.yaml +++ b/maestro/Onboarding.yaml @@ -5,6 +5,17 @@ appId: org.scottishtecharmy.soundscape permissions: all: allow +# iOS 26 sims show a system-level "Allow widgets from Maps to use your +# location" dialog on fresh boots. It floats above our app and blocks Maestro +# from reaching the Welcome screen. permissions.all: allow on launchApp only +# covers our own app's runtime prompts, not Springboard widget prompts, so we +# dismiss it explicitly here. optional: true means the flow still passes if +# the dialog never appears (Android, warm sims). It should only fire once per +# sim session, so subsequent flows don't need the same guard. +- tapOn: + text: "Don.t Allow" + optional: true + - runFlow: file: WaitForScreen.yaml env: