
Recommendation. CoreBluetooth needs a real radio and a real TCC database, so run this test on a physical Mac. The $29 Figment virtual Intel macOS plan is useful for cross‑compiling supported Intel toolchains (Xcode 26 or earlier) but does not expose a Bluetooth controller to the guest, so CBCentralManager never reaches allowedAlways there. Put in a request for a physical i3 Mac ($49/month, listed) or an M‑series Mac (from $95/month; the exact chip, RAM and disk are confirmed in the quote) at /mac-hosting/start. Confirm the specific unit has a working Bluetooth radio before you sign off. Primary API references used below: CBCentralManager and NSBluetoothAlwaysUsageDescription.
macOS shows the "Allow [App] to use Bluetooth?" alert the first time your app instantiates CBCentralManager(delegate:queue:). The prompt is not fired at import time or at app launch; it is fired by the initializer, and only if NSBluetoothAlwaysUsageDescription is present in the app target's Info.plist. Without the key the system suppresses the dialog and the manager stays in an unauthorized state.
<key>NSBluetoothAlwaysUsageDescription</key>
<string>MyApp needs Bluetooth to discover your peripheral device.</string>
The string you set is the text the user reads. Keep it concrete — a vague "for a better experience" string confuses both testers and App Review.
CBCentralManager reports its state asynchronously. After init, centralManagerDidUpdateState(_:) fires on the designated queue once the manager has consulted TCC. Calling XCTAssertEqual(CBCentralManager.authorization, .allowedAlways) immediately after init almost always fails: the value is .notDetermined at that instant.
XCTNSPredicateExpectation is the right primitive — it polls until the predicate is true or a timeout fires:
let authPredicate = NSPredicate { _, _ in
CBCentralManager.authorization == .allowedAlways
}
let authExpectation = XCTNSPredicateExpectation(
predicate: authPredicate,
object: nil
)
let result = XCTWaiter().wait(for: [authExpectation], timeout: 10)
XCTAssertEqual(result, .completed, "Timed out waiting for Bluetooth authorization")
Ten seconds is a comfortable starting point. Measure your own machine before tightening it; CI hosts under load can be noticeably slower than a developer laptop.
On macOS the Bluetooth consent dialog is a separate window owned by the user session, not an in‑app alert. addUIInterruptionMonitor is documented primarily for iOS XCUITest; on macOS, verify in your installed Xcode's XCTest help (xcrun xctest --help and the generated -Help output in Xcode) that the monitor fires for the consent window on your target OS version. If it does not, query the alert directly through the running app's windows and tap Allow by label.
Register the monitor before CBCentralManager is initialised:
let allowed = addUIInterruptionMonitor(withDescription: "Bluetooth permission") { alert in
let allowButton = alert.buttons["Allow"]
guard allowButton.waitForExistence(timeout: 2) else { return false }
allowButton.tap()
return true
}
defer { removeUIInterruptionMonitor(allowed) }
// This call triggers the alert on first run after a TCC reset.
let manager = CBCentralManager(delegate: delegate, queue: .main)
// XCTest only drains interruption monitors when there is a pending UI interaction.
app.activate()
app.tap()
If the permission was already granted in a previous run and no alert appears, the monitor simply never fires. That is not a failure.
CBManagerAuthorization.allowedAlwaysOnce the predicate completes, assert the state through the class property on CBCentralManager. See the CBCentralManager reference for the precise availability row for your deployment target.
XCTAssertEqual(
CBCentralManager.authorization,
.allowedAlways,
"Manager should be allowedAlways after the user tapped Allow"
)
Without a reset, the second run finds the permission already granted and never sees the alert — which means the alert path is effectively untested. Run tccutil reset in setUpWithError():
override func setUpWithError() throws {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/tccutil")
proc.arguments = ["reset", "Bluetooth", "com.example.MyApp"]
try proc.run()
proc.waitUntilExit()
guard proc.terminationStatus == 0 else {
throw XCTSkip("tccutil reset failed; check the bundle ID")
}
}
tccutil reset Bluetooth <bundleID> removes only that app's Bluetooth TCC entry and does not require SIP to be disabled. If you forget the bundle ID argument, tccutil resets all Bluetooth entries on the machine — fine on a dedicated CI Mac, less fine on a shared one.
The unique artifact for this test is a machine‑readable record of the final authorization, stamped so it survives merging across parallel runs:
func attachAuthStatus(_ status: CBManagerAuthorization, to test: XCTestCase) {
let stateNames: [CBManagerAuthorization: String] = [
.notDetermined: "notDetermined",
.restricted: "restricted",
.denied: "denied",
.allowedAlways: "allowedAlways",
]
let payload: [String: Any] = [
"authorization": stateNames[status] ?? "unknown(\(status.rawValue))",
"timestamp": ISO8601DateFormatter().string(from: Date()),
"bundleID": Bundle.main.bundleIdentifier ?? "unset",
]
guard let data = try? JSONSerialization.data(
withJSONObject: payload,
options: .prettyPrinted
) else { return }
let attachment = XCTAttachment(
data: data,
uniformTypeIdentifier: "public.json"
)
attachment.name = "bluetooth-auth-status.json"
attachment.lifetime = .keepAlways
test.add(attachment)
}
Call attachAuthStatus(CBCentralManager.authorization, to: self) at the end of the test. The file lands inside the .xcresult bundle under Attachments in Xcode's Test navigator and exports as plain JSON for downstream reporting.
The listed monthly headlines below are entry prices, not equal hardware. Figment's physical i3 and M‑series tiers are request‑based while supply is confirmed, so ask for availability in the quote rather than assuming an instant allocation.
| Plan | Listed monthly | Notes for a Bluetooth TCC test |
|---|---|---|
| Figment virtual Intel macOS (2 vCPU / 8 GiB / 80 GiB, Linux/KVM) | $29 | No Bluetooth radio exposed to the guest — use it for cross‑compile and unit tests, not this UI test. |
| Figment physical i3 Mac | $49 (request) | Intel hardware with a real radio; confirm the specific unit in the quote. |
| Figment physical M‑series | from $95 (request) | ARM execution plus Bluetooth; exact chip/RAM/disk stated in the quote. |
| CloudMac M4 / 16 GB / 256 GB | $99.99/month, or $999/year ($83.25 effective/month) | Annual term is cheaper than 12 × $95; 512 GB and 1 TB rows are listed as fully reserved, 256 GB shows Reserve now. |
| MacStadium M4.S (16 GB / 256 GB) | $149/month | Catalog includes support and a published 99.99% uptime claim; multi‑year via sales. |
If your decision is purely about M4/16 GB entry price, CloudMac's $999/year is $141/year under 12 × $95. Figment's advantage on the $249 Managed Build Mac and $999 Managed Mac Team tiers is the specific managed CI work — one or two M‑series Macs plus CI setup, backups, monitoring and 30 minutes or one hour of monthly support. Pick the tier against the actual task: a one‑off TCC prompt test only needs a physical hour or two; an always‑on signing/notarisation runner wants the Managed Build tier.
Start a configuration request at /mac-hosting/start or read the overview at /mac-hosting.
Related guides: