Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

iOS Simulator Location Permission Test Flow

Rent an M-series Mac from Figment (from $95/month; exact chip and RAM are confirmed in the quote) to run the iOS Simulator reliably, then pre-seed each location-permission scenario from a host shell script with xcrun simctl privacy before launching a small XCUITest that asserts the UI. The pre-seed belongs on the macOS host, not inside the UI test target: XCUITest code is compiled for iOS and runs inside the Simulator, where /usr/bin/xcrun is not present and Foundation Process spawning is not permitted. Start a configuration at Figment Mac hosting.

Location is one of the most fragile surfaces in an iOS app: a single wrong status at the wrong moment sends users to Settings and they never come back. Covering grant, deny and ask-once as three scheduled runs gives you a named screenshot per scenario on every push, without flakiness from racing the system alert.

CLLocationManager statuses you need to cover

Apple's authorization guide is the reference for the enum (Requesting authorization to use location services). For simulator UI coverage you care about these four values and three run scenarios.

CLAuthorizationStatusHow you reach it in testUI behaviour you assert
.notDeterminedxcrun simctl privacy ... reset location before launchSystem alert fires on the first requestWhenInUseAuthorization() call
.authorizedWhenInUsexcrun simctl privacy ... grant location before launch, or user taps Allow While Using AppLocation updates deliver fixes while foregrounded
.deniedxcrun simctl privacy ... revoke location before launch, or user taps Don't AllowstartUpdatingLocation() delivers no fixes; your fallback UI shows
.restrictedNot reachable from simctl in a normal test run; MDM or parental controls onlySame observable UI as denied, with no user override

Scenarios to run: grant (pre-seed .authorizedWhenInUse, no alert expected), deny (pre-seed .denied, no alert expected) and ask-once (pre-seed .notDetermined, alert must appear, tap Allow Once). .restricted is worth a logic-level unit test but is out of scope for a simctl-driven flow.

Pre-seed each scenario from the host shell

xcrun simctl privacy grants, revokes or resets any privacy permission on a booted simulator without a user gesture. Because this executable lives on the macOS host, run it from the driver script that invokes xcodebuild test, not from inside the UI test target. The driver creates a dedicated simulator per run so matrix jobs do not share permission state, and cleans up only the device it created.

#!/usr/bin/env bash
# run-location-tests.sh - host-side driver. Requires Xcode command line tools.
set -euo pipefail

BUNDLE_ID="${BUNDLE_ID:?set BUNDLE_ID to your app bundle identifier}"
SCHEME="${SCHEME:?set SCHEME to your UITest scheme}"
DEVICE_TYPE="${DEVICE_TYPE:-com.apple.CoreSimulator.SimDeviceType.iPhone-15}"
RUNTIME="${RUNTIME:?set RUNTIME e.g. com.apple.CoreSimulator.SimRuntime.iOS-17-5}"
OUT_DIR="$(pwd)/TestResults/$(date -u +%Y%m%dT%H%M%SZ)-$$"
mkdir -p "$OUT_DIR"

# Verify the subcommands your installed Xcode exposes before relying on them.
xcrun simctl help privacy  >/dev/null
xcrun simctl help location >/dev/null

UDID="$(xcrun simctl create "loc-tests-$$" "$DEVICE_TYPE" "$RUNTIME")"
cleanup() {
  xcrun simctl shutdown "$UDID" >/dev/null 2>&1 || true
  xcrun simctl delete   "$UDID" >/dev/null 2>&1 || true
}
trap cleanup EXIT
xcrun simctl boot "$UDID"
xcrun simctl bootstatus "$UDID" -b

# Pin a deterministic coordinate (Apple Park, Cupertino).
xcrun simctl location "$UDID" set 37.3348,-122.0090

FAILED=0
for SCENARIO in grant deny ask-once; do
  case "$SCENARIO" in
    grant)    xcrun simctl privacy "$UDID" grant  location "$BUNDLE_ID" ;;
    deny)     xcrun simctl privacy "$UDID" revoke location "$BUNDLE_ID" ;;
    ask-once) xcrun simctl privacy "$UDID" reset  location "$BUNDLE_ID" ;;
  esac

  if ! xcodebuild \
        -scheme "$SCHEME" \
        -destination "platform=iOS Simulator,id=$UDID" \
        -only-testing:UITests/LocationPermissionTests/testLocationPermissionScenario \
        -resultBundlePath "$OUT_DIR/$SCENARIO.xcresult" \
        LOCATION_SCENARIO="$SCENARIO" \
        test; then
    echo "scenario '$SCENARIO' failed" >&2
    FAILED=1
    break
  fi
done

exit "$FAILED"

The script stops on the first failure, so a broken scenario is never reported as a passing baseline. On process termination the trap runs, but it cannot promise cleanup after a host power loss; a separate operator script should garbage-collect stale loc-tests-* devices. Never run simctl erase all: it destroys every simulator the user owns, including unrelated projects.

Pre-seeding before xcodebuild test is more reliable than intercepting the alert after launch: the system alert does not always appear on the first view-controller presentation cycle on slower CI boots.

Intercept the SpringBoard alert for ask-once

For ask-once you deliberately do not pre-seed to a granted state. You reset and let the alert fire, so the test can assert pre-alert UI, drive the tap and assert post-alert UI. Reach the alert through the SpringBoard application; the alert is hosted by SpringBoard and its buttons are not inside your app's element tree.

// LocationAlertHelper.swift - XCUITest target
import XCTest

enum LocationAlertAction: String {
    case allowOnce       = "Allow Once"
    case allowWhileUsing = "Allow While Using App"
    case dontAllow       = "Don't Allow"
}

func handleLocationAlert(
    _ action: LocationAlertAction,
    timeout: TimeInterval = 10,
    file: StaticString = #file,
    line: UInt = #line
) throws {
    let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
    let button = springboard.buttons[action.rawValue]
    guard button.waitForExistence(timeout: timeout) else {
        XCTFail("Location alert button '\(action.rawValue)' did not appear within \(timeout)s",
                file: file, line: line)
        return
    }
    button.tap()
}

The helper fails loudly when the alert never shows. That is the only reliable way to stop an ask-once run from passing silently when the alert simply did not render. Apple has reworded these labels across iOS releases, so verify the exact strings in your current runtime and update the enum rather than catching every locale by hand.

XCUITest, scenario via launch environment, and manual acceptance

The test target reads the scenario from the LOCATION_SCENARIO environment variable the driver script passed to xcodebuild. No Process, no shelling out from the test runner — the UI test only asserts UI and captures a named screenshot with the Apple-documented API (XCTAttachment).

// LocationPermissionTests.swift - XCUITest target
import XCTest

final class LocationPermissionTests: XCTestCase {
    func testLocationPermissionScenario() throws {
        guard let scenario = ProcessInfo.processInfo.environment["LOCATION_SCENARIO"] else {
            throw XCTSkip("LOCATION_SCENARIO not set; run via run-location-tests.sh")
        }

        let app = XCUIApplication()
        app.launchArguments += ["-LocationPermissionScenario", scenario]
        app.launch()

        switch scenario {
        case "grant":
            try assertMapAnnotation(in: app, scenario: scenario)
        case "deny":
            try assertFallbackBanner(in: app, scenario: scenario)
        case "ask-once":
            try handleLocationAlert(.allowOnce)
            try assertMapAnnotation(in: app, scenario: scenario)
        default:
            XCTFail("Unknown scenario '\(scenario)'")
            return
        }

        let shot = XCTAttachment(screenshot: app.screenshot())
        shot.name = "location-\(scenario)-result"
        shot.lifetime = .keepAlways
        add(shot)
    }

    private func assertMapAnnotation(in app: XCUIApplication, scenario: String) throws {
        let annotation = app.otherElements["CurrentLocationAnnotation"]
        XCTAssertTrue(annotation.waitForExistence(timeout: 10),
                      "[\(scenario)] expected location annotation to appear")
    }

    private func assertFallbackBanner(in app: XCUIApplication, scenario: String) throws {
        let banner = app.staticTexts["LocationUnavailableBanner"]
        XCTAssertTrue(banner.waitForExistence(timeout: 10),
                      "[\(scenario)] expected fallback banner when location is denied")
    }
}

Three accessibility identifiers you must own in the app under test: CurrentLocationAnnotation on the map annotation, LocationUnavailableBanner on the fallback view and whatever identifier you wire for the "request permission" button. The snippet above is a worked example to port into a real project — treat its first green run on your scheme as the baseline, not evidence that it already passes.

Manual acceptance worksheet

Run through this once per iOS runtime you support, then let the driver script reproduce it in CI.

  1. xcrun simctl list runtimes — record the exact iOS runtime identifier under test.
  2. xcrun simctl help privacy and xcrun simctl help location — confirm the subcommands and flags your installed Xcode accepts.
  3. Install your app on the simulator and open Settings → Privacy & Security → Location Services → YourApp. Toggle each state manually and verify the in-app UI matches what the automated assertions check.
  4. Trigger requestWhenInUseAuthorization() manually after xcrun simctl privacy <udid> reset location <bundle>. Note the exact button labels the alert shows and update LocationAlertAction if Apple has reworded them.
  5. Shut down and delete only the test device: xcrun simctl shutdown <udid> && xcrun simctl delete <udid>. Do not run simctl erase all.
  6. Keep signing keys and secrets in your CI secret manager, not in the driver script or shell history. The driver only needs the bundle identifier.

Sizing the macOS host for this test

The iOS Simulator runs on the macOS host, and for these three scenarios the dominant cost is Simulator boot time plus app launch time, not raw compute. Pick the smallest plan that boots the Simulator cleanly for your target iOS runtime.

NeedFigment planMonthly price
Local experiments against an Intel-compatible Xcode and iOS runtimeVirtual Intel macOS (2 vCPU, 8 GiB RAM, 80 GiB disk)$29
CI on each push, ARM execution, Xcode 27 toolchainsM-series Macfrom $95
Multi-repo team, CI setup, backups, monitoring, 30 min monthly supportManaged Build Mac (one M-series Mac)$249

The $29 tier is a virtual Intel macOS environment. It runs macOS and Intel-compatible Xcode toolchains; use Xcode 26 or earlier on Intel per Apple's porting notes, and Xcode 27 requires Apple silicon. Supported Intel Xcode versions can still drive many iOS simulator runtimes, but the VM has no GPU acceleration and no audio, so a map-heavy UI may render more slowly than on bare metal — confirm Simulator viability for your exact Xcode/iOS pair in the quote. Access is via the browser console, and outbound CI agents can connect from the VM to your CI service; inbound SSH is not currently implemented. The from-$95 M-series tier is the recommended path for routine XCUITest CI; exact chip, RAM and disk are confirmed when Figment quotes capacity. Physical Intel ($49) and dedicated M-series plans collect requests until supply is confirmed. Tax, setup and any annual terms are as stated in the actual offer you receive.

Request your configuration at Figment Mac hosting and then get started.


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms