
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.
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.
CLAuthorizationStatus | How you reach it in test | UI behaviour you assert |
|---|---|---|
.notDetermined | xcrun simctl privacy ... reset location before launch | System alert fires on the first requestWhenInUseAuthorization() call |
.authorizedWhenInUse | xcrun simctl privacy ... grant location before launch, or user taps Allow While Using App | Location updates deliver fixes while foregrounded |
.denied | xcrun simctl privacy ... revoke location before launch, or user taps Don't Allow | startUpdatingLocation() delivers no fixes; your fallback UI shows |
.restricted | Not reachable from simctl in a normal test run; MDM or parental controls only | Same 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.
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.
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.
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.
Run through this once per iOS runtime you support, then let the driver script reproduce it in CI.
xcrun simctl list runtimes — record the exact iOS runtime identifier under test.xcrun simctl help privacy and xcrun simctl help location — confirm the subcommands and flags your installed Xcode accepts.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.xcrun simctl shutdown <udid> && xcrun simctl delete <udid>. Do not run simctl erase all.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.
| Need | Figment plan | Monthly price |
|---|---|---|
| Local experiments against an Intel-compatible Xcode and iOS runtime | Virtual Intel macOS (2 vCPU, 8 GiB RAM, 80 GiB disk) | $29 |
| CI on each push, ARM execution, Xcode 27 toolchains | M-series Mac | from $95 |
| Multi-repo team, CI setup, backups, monitoring, 30 min monthly support | Managed 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.