commit b1267fb6c240f6211a07f08b2ddb37d4dc0fc5fe
parent 0f8bffd1cd4ac1d237d5d44fd9c68da70bf74a7a
Author: triesap <tyson@radroots.org>
Date: Thu, 18 Jun 2026 12:50:50 -0700
runtime: split UI test host-service harness
- remove the production RadrootsKitTesting target dependency
- add app-local Debug harness services for deterministic UI tests
- gate hidden probes and launch-environment hooks behind Debug code
- regenerate the Xcode project after the dependency split
Diffstat:
10 files changed, 369 insertions(+), 139 deletions(-)
diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj
@@ -28,7 +28,6 @@
505A5731ACDBBB0296134340 /* TradeListingCreateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947ADA6D32E42ED2B40A5351 /* TradeListingCreateView.swift */; };
5AECD474FB2F91855BDD79C0 /* PostFeedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F21554DA87EEC1E5C5F38365 /* PostFeedViewModel.swift */; };
657BEA5AAFF129E10177FE63 /* Nostr.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63189EB90A86A9929BECD9ED /* Nostr.swift */; };
- 6E11DAC51E648F1E4EDFBE68 /* RadrootsKitTesting in Frameworks */ = {isa = PBXBuildFile; productRef = F2A002A084F2E04B096C93C7 /* RadrootsKitTesting */; };
6E15D30653861F26AC45B501 /* FieldLocalState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EB0A2CFCBBFA9D204C6992B /* FieldLocalState.swift */; };
7C8DD424F3E3E0AB1B133863 /* RadrootsKitBindings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC881872F750120A184F45E6 /* RadrootsKitBindings.swift */; };
7E650F6DA30931E310F842E2 /* FieldFileAccessUITestProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491D57E540BAB04F24619737 /* FieldFileAccessUITestProbe.swift */; };
@@ -50,6 +49,7 @@
D3834AF9A4E1327B7DA557F3 /* CopyRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A4289F43625DD65E6C4B25 /* CopyRow.swift */; };
D3E08BD0EB07C4E687BDAEF0 /* FieldBackgroundURLSessionEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DA5C6DE4731C46D53B757E3 /* FieldBackgroundURLSessionEvents.swift */; };
D4CFDE54747B6D6957977025 /* FieldIdentityPublicMetadataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA942715DB13FFD494FD35A0 /* FieldIdentityPublicMetadataStore.swift */; };
+ D57452A5B550E4832913AF02 /* FieldUITestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901B3694434DC803B37651E8 /* FieldUITestSupport.swift */; };
D5C58A98C950D45AD027962A /* TradeListing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D69D200DB1F5FA7AA561CD7 /* TradeListing.swift */; };
D62E9461833A0AA5E622A1E6 /* ToastModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 227028B4EBDC6703999FB9DA /* ToastModifier.swift */; };
D9BF5BE7E4AB5EACBF342539 /* FieldSecureIdentityStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8246B707FA9D218414EC4038 /* FieldSecureIdentityStore.swift */; };
@@ -95,6 +95,7 @@
8DA5C6DE4731C46D53B757E3 /* FieldBackgroundURLSessionEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldBackgroundURLSessionEvents.swift; sourceTree = "<group>"; };
8E6A1827AB4419F806CD848F /* FieldTelemetryUITestProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldTelemetryUITestProbe.swift; sourceTree = "<group>"; };
8F0F21496E7A8490EB14AC5B /* Radroots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Radroots.swift; sourceTree = "<group>"; };
+ 901B3694434DC803B37651E8 /* FieldUITestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldUITestSupport.swift; sourceTree = "<group>"; };
93AA285819DD1269C3EAD80A /* Radroots.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Radroots.app; sourceTree = BUILT_PRODUCTS_DIR; };
93D729E070C32490545FA837 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
947ADA6D32E42ED2B40A5351 /* TradeListingCreateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TradeListingCreateView.swift; sourceTree = "<group>"; };
@@ -134,7 +135,6 @@
files = (
04AA409CFECBA11BFC175C5C /* RadrootsFFI.xcframework in Frameworks */,
F3E40E5A76B4EA19AC7603D2 /* RadrootsKit in Frameworks */,
- 6E11DAC51E648F1E4EDFBE68 /* RadrootsKitTesting in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -216,6 +216,7 @@
8246B707FA9D218414EC4038 /* FieldSecureIdentityStore.swift */,
3B4E53FD4C4AADF63855888A /* FieldTelemetry.swift */,
8E6A1827AB4419F806CD848F /* FieldTelemetryUITestProbe.swift */,
+ 901B3694434DC803B37651E8 /* FieldUITestSupport.swift */,
D65835E1C633C7B946C64D11 /* FieldUserPresenceGate.swift */,
D4DE3DD8C3BB2F63676F463E /* LoggingSettings.swift */,
63189EB90A86A9929BECD9ED /* Nostr.swift */,
@@ -355,7 +356,6 @@
name = Radroots;
packageProductDependencies = (
2DAD90EBF8EB00ACDD7611CD /* RadrootsKit */,
- F2A002A084F2E04B096C93C7 /* RadrootsKitTesting */,
);
productName = Radroots;
productReference = 93AA285819DD1269C3EAD80A /* Radroots.app */;
@@ -455,6 +455,7 @@
D9BF5BE7E4AB5EACBF342539 /* FieldSecureIdentityStore.swift in Sources */,
9346DA48630668A65D37E14A /* FieldTelemetry.swift in Sources */,
CC5561169A29B5B2B6423959 /* FieldTelemetryUITestProbe.swift in Sources */,
+ D57452A5B550E4832913AF02 /* FieldUITestSupport.swift in Sources */,
25654E50F9519809A237759D /* FieldUserPresenceGate.swift in Sources */,
1E5B41A3E1F9A7D68F63B079 /* HomeView.swift in Sources */,
B8A3BBDE3A1FC0248512BF76 /* LoggingSettings.swift in Sources */,
@@ -697,10 +698,6 @@
isa = XCSwiftPackageProductDependency;
productName = RadrootsKit;
};
- F2A002A084F2E04B096C93C7 /* RadrootsKitTesting */ = {
- isa = XCSwiftPackageProductDependency;
- productName = RadrootsKitTesting;
- };
/* End XCSwiftPackageProductDependency section */
};
rootObject = 572C41532B5066EC7641561C /* Project object */;
diff --git a/Radroots/App/AppEntry.swift b/Radroots/App/AppEntry.swift
@@ -28,6 +28,7 @@ public struct AppEntry<Main: View>: View {
}
}
.accessibilityIdentifier("field_ios.app_entry")
+ #if DEBUG
.overlay(alignment: .topLeading) {
if let probeValue = appState.fileAccessProbeValue {
Color.clear
@@ -58,6 +59,7 @@ public struct AppEntry<Main: View>: View {
.accessibilityValue(probeValue)
}
}
+ #endif
}
}
diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift
@@ -543,21 +543,26 @@ public final class AppState: ObservableObject {
}
private var uiTestWasRequested: Bool {
- let arguments = ProcessInfo.processInfo.arguments
- let environment = ProcessInfo.processInfo.environment
- return environment["RADROOTS_FIELD_IOS_UI_TEST"] == "true" ||
- arguments.contains("--radroots-field-ios-ui-test")
+ #if DEBUG
+ return FieldUITestHarness.isRequested
+ #else
+ return false
+ #endif
}
private var uiTestBootstrapSplashHoldNanoseconds: UInt64? {
+ #if DEBUG
guard uiTestWasRequested else { return nil }
- guard let raw = ProcessInfo.processInfo.environment["RADROOTS_FIELD_IOS_UI_TEST_BOOTSTRAP_SPLASH_HOLD_SECONDS"],
+ guard let raw = FieldUITestHarness.string("RADROOTS_FIELD_IOS_UI_TEST_BOOTSTRAP_SPLASH_HOLD_SECONDS"),
let seconds = Double(raw),
seconds.isFinite,
seconds > 0 else {
return nil
}
return UInt64(seconds * 1_000_000_000)
+ #else
+ return nil
+ #endif
}
private func holdBootstrapSplashForUITestIfRequested() async throws {
@@ -566,18 +571,21 @@ public final class AppState: ObservableObject {
}
private var startupFailureWasRequested: Bool {
+ #if DEBUG
guard uiTestWasRequested else {
return false
}
let arguments = ProcessInfo.processInfo.arguments
- let environment = ProcessInfo.processInfo.environment
if BuildConfig.string(.runtimeMode) == "ui-test-startup-failure" {
return true
}
- if environment["RADROOTS_FIELD_IOS_FORCE_STARTUP_FAILURE"] == "true" {
+ if FieldUITestHarness.bool("RADROOTS_FIELD_IOS_FORCE_STARTUP_FAILURE", default: false) {
return true
}
return arguments.contains("--radroots-field-ios-force-startup-failure")
+ #else
+ return false
+ #endif
}
private func configureRelays(using service: FieldRuntimeService) async throws {
diff --git a/Radroots/Runtime/FieldBackgroundExecution.swift b/Radroots/Runtime/FieldBackgroundExecution.swift
@@ -1,6 +1,5 @@
import Foundation
import RadrootsKit
-import RadrootsKitTesting
struct FieldBackgroundTaskIdentifiers: Equatable, Sendable {
let refresh: RadrootsBackgroundTaskIdentifier
@@ -68,9 +67,10 @@ actor FieldBackgroundExecution {
) throws -> FieldBackgroundExecution {
let identifiers = try FieldBackgroundTaskIdentifiers(bundleIdentifier: bundleIdentifier)
let roots = try FieldLocalState.roots(bundleIdentifier: bundleIdentifier)
- if uiTestWasRequested {
- let scheduler = RadrootsFakeBackgroundTaskScheduler()
- let transfer = RadrootsFakeBackgroundTransfer()
+ #if DEBUG
+ if FieldUITestHarness.isRequested {
+ let scheduler = FieldUITestBackgroundTaskScheduler()
+ let transfer = FieldUITestBackgroundTransfer()
let now: @Sendable () -> Date
if FieldBackgroundExecutionUITestProbe.isRequested {
now = { Date.distantFuture }
@@ -87,6 +87,7 @@ actor FieldBackgroundExecution {
registerHandlers: { _ in }
)
}
+ #endif
let scheduler = RadrootsAppleBackgroundTaskScheduler()
let transfer = try RadrootsAppleBackgroundTransfer(
roots: roots,
@@ -263,7 +264,8 @@ actor FieldBackgroundExecution {
}
private func seedUITestTransferSnapshot() async throws -> Int {
- guard let fakeTransfer = transfer as? RadrootsFakeBackgroundTransfer else {
+ #if DEBUG
+ guard let fakeTransfer = transfer as? FieldUITestBackgroundTransfer else {
return try await transfer.snapshots().count
}
let request = try RadrootsBackgroundTransferRequest(
@@ -281,6 +283,9 @@ actor FieldBackgroundExecution {
)
_ = try await fakeTransfer.enqueue(request)
return try await fakeTransfer.snapshots().count
+ #else
+ return try await transfer.snapshots().count
+ #endif
}
private func seedUITestStagedBlobAndRunMaintenance() async throws -> Bool {
@@ -302,17 +307,25 @@ actor FieldBackgroundExecution {
}
private func fakeSubmittedRequestCount() async -> Int {
- guard let fakeScheduler = scheduler as? RadrootsFakeBackgroundTaskScheduler else {
+ #if DEBUG
+ guard let fakeScheduler = scheduler as? FieldUITestBackgroundTaskScheduler else {
return (try? await scheduler.pendingTasks().count) ?? 0
}
return await fakeScheduler.submittedRequestCount
+ #else
+ return (try? await scheduler.pendingTasks().count) ?? 0
+ #endif
}
private func fakeCancelAllCount() async -> Int {
- guard let fakeScheduler = scheduler as? RadrootsFakeBackgroundTaskScheduler else {
+ #if DEBUG
+ guard let fakeScheduler = scheduler as? FieldUITestBackgroundTaskScheduler else {
return 0
}
return await fakeScheduler.cancelAllCount
+ #else
+ return 0
+ #endif
}
private func inspectTransferSnapshots(reason: String) async -> Int? {
@@ -401,10 +414,4 @@ actor FieldBackgroundExecution {
}
}
- private static var uiTestWasRequested: Bool {
- let environment = ProcessInfo.processInfo.environment
- let arguments = ProcessInfo.processInfo.arguments
- return environment["RADROOTS_FIELD_IOS_UI_TEST"] == "true" ||
- arguments.contains("--radroots-field-ios-ui-test")
- }
}
diff --git a/Radroots/Runtime/FieldCaptureIntake.swift b/Radroots/Runtime/FieldCaptureIntake.swift
@@ -1,6 +1,5 @@
import Foundation
import RadrootsKit
-import RadrootsKitTesting
enum FieldCaptureIntakeError: LocalizedError {
case serviceNotReady
@@ -170,9 +169,11 @@ final class FieldCaptureIntake: @unchecked Sendable {
static func configured(bundleIdentifier: String) throws -> FieldCaptureIntake {
let fileAccess = try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier)
- if uiTestWasRequested {
+ #if DEBUG
+ if FieldUITestHarness.isRequested {
return try uiTestConfigured(fileAccess: fileAccess)
}
+ #endif
return FieldCaptureIntake(
fileAccess: fileAccess,
mediaPicker: RadrootsAppleMediaPicker(fileAccess: fileAccess),
@@ -274,12 +275,7 @@ final class FieldCaptureIntake: @unchecked Sendable {
)
}
- private static var uiTestWasRequested: Bool {
- let environment = ProcessInfo.processInfo.environment
- return environment["RADROOTS_FIELD_IOS_UI_TEST"] == "true" ||
- ProcessInfo.processInfo.arguments.contains("--radroots-field-ios-ui-test")
- }
-
+ #if DEBUG
private static func uiTestConfigured(fileAccess: RadrootsAppleFileAccess) throws -> FieldCaptureIntake {
let importedAsset = try uiTestMediaAsset(
fileAccess: fileAccess,
@@ -315,12 +311,12 @@ final class FieldCaptureIntake: @unchecked Sendable {
)
return FieldCaptureIntake(
fileAccess: fileAccess,
- mediaPicker: RadrootsFakeMediaPicker(
+ mediaPicker: FieldUITestMediaPicker(
support: mediaSupport,
importOutcome: importOutcome,
captureOutcome: captureOutcome
),
- documentScanner: RadrootsFakeDocumentScanner(
+ documentScanner: FieldUITestDocumentScanner(
support: scannerSupport,
scanOutcome: scannerOutcome
)
@@ -424,11 +420,13 @@ final class FieldCaptureIntake: @unchecked Sendable {
private static func uiTestOutcome(_ key: String) -> FieldCaptureUITestOutcome {
FieldCaptureUITestOutcome(
- rawValue: ProcessInfo.processInfo.environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ rawValue: FieldUITestHarness.string(key) ?? ""
) ?? .success
}
+ #endif
}
+#if DEBUG
private enum FieldCaptureUITestOutcome: String {
case success
case cancelled
@@ -440,3 +438,4 @@ private enum FieldCaptureUITestOutcome: String {
self == .unavailable
}
}
+#endif
diff --git a/Radroots/Runtime/FieldExternalActions.swift b/Radroots/Runtime/FieldExternalActions.swift
@@ -1,6 +1,5 @@
import Foundation
import RadrootsKit
-import RadrootsKitTesting
public enum FieldExternalActionRecovery: String, Equatable, Sendable {
case appSettings
@@ -37,10 +36,12 @@ final class FieldExternalActions: Sendable {
}
static func configured() -> FieldExternalActions {
- guard uiTestWasRequested else {
- return FieldExternalActions(actions: RadrootsAppleExternalActions())
+ #if DEBUG
+ if FieldUITestHarness.isRequested {
+ return FieldExternalActions(actions: uiTestExternalActions())
}
- return FieldExternalActions(actions: uiTestExternalActions())
+ #endif
+ return FieldExternalActions(actions: RadrootsAppleExternalActions())
}
func canOpenPublicNostrProfile(npub: String) async -> Bool {
@@ -66,35 +67,26 @@ final class FieldExternalActions: Sendable {
try RadrootsExternalActionDestination.nostr("nostr:\(npub)")
}
- private static var uiTestWasRequested: Bool {
- let arguments = ProcessInfo.processInfo.arguments
- let environment = ProcessInfo.processInfo.environment
- return environment["RADROOTS_FIELD_IOS_UI_TEST"] == "true" ||
- arguments.contains("--radroots-field-ios-ui-test")
- }
-
- private static func uiTestExternalActions() -> RadrootsFakeExternalActions {
- RadrootsFakeExternalActions(
+ #if DEBUG
+ private static func uiTestExternalActions() -> FieldUITestExternalActions {
+ FieldUITestExternalActions(
defaultCanOpen: uiTestCanOpen,
openOutcome: uiTestOpenOutcome
)
}
private static var uiTestCanOpen: Bool {
- let environment = ProcessInfo.processInfo.environment
- if let raw = environment["RADROOTS_FIELD_IOS_UI_TEST_EXTERNAL_ACTIONS_NOSTR_CAN_OPEN"] {
- return parseBool(raw) ?? true
+ if FieldUITestHarness.string("RADROOTS_FIELD_IOS_UI_TEST_EXTERNAL_ACTIONS_NOSTR_CAN_OPEN") != nil {
+ return FieldUITestHarness.bool("RADROOTS_FIELD_IOS_UI_TEST_EXTERNAL_ACTIONS_NOSTR_CAN_OPEN", default: true)
}
- if let raw = environment["RADROOTS_FIELD_IOS_UI_TEST_EXTERNAL_ACTIONS_CAN_OPEN"] {
- return parseBool(raw) ?? true
+ if FieldUITestHarness.string("RADROOTS_FIELD_IOS_UI_TEST_EXTERNAL_ACTIONS_CAN_OPEN") != nil {
+ return FieldUITestHarness.bool("RADROOTS_FIELD_IOS_UI_TEST_EXTERNAL_ACTIONS_CAN_OPEN", default: true)
}
return true
}
private static var uiTestOpenOutcome: Result<Void, RadrootsExternalActionError> {
- let raw = ProcessInfo.processInfo.environment["RADROOTS_FIELD_IOS_UI_TEST_EXTERNAL_ACTIONS_OPEN_OUTCOME"]?
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .lowercased()
+ let raw = FieldUITestHarness.string("RADROOTS_FIELD_IOS_UI_TEST_EXTERNAL_ACTIONS_OPEN_OUTCOME")?.lowercased()
switch raw {
case nil, "", "success":
return .success(())
@@ -106,15 +98,5 @@ final class FieldExternalActions: Sendable {
return .failure(.blockedByPolicy("unsupported UI-test external action outcome"))
}
}
-
- private static func parseBool(_ raw: String) -> Bool? {
- switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
- case "1", "true", "yes":
- true
- case "0", "false", "no":
- false
- default:
- nil
- }
- }
+ #endif
}
diff --git a/Radroots/Runtime/FieldTelemetry.swift b/Radroots/Runtime/FieldTelemetry.swift
@@ -1,22 +1,21 @@
import Foundation
import RadrootsKit
-import RadrootsKitTesting
final class FieldTelemetry: @unchecked Sendable {
static let shared = FieldTelemetry.configured()
private let sink: any RadrootsTelemetry
private let minimumLevel: RadrootsTelemetryLevel
- private let recordingTelemetry: RadrootsRecordingTelemetry?
+ private let recordedEventsProvider: (@Sendable () async -> [RadrootsTelemetryEvent])?
init(
sink: any RadrootsTelemetry,
minimumLevel: RadrootsTelemetryLevel = .info,
- recordingTelemetry: RadrootsRecordingTelemetry? = nil
+ recordedEventsProvider: (@Sendable () async -> [RadrootsTelemetryEvent])? = nil
) {
self.sink = sink
self.minimumLevel = minimumLevel
- self.recordingTelemetry = recordingTelemetry
+ self.recordedEventsProvider = recordedEventsProvider
}
static func configured(
@@ -25,14 +24,16 @@ final class FieldTelemetry: @unchecked Sendable {
) -> FieldTelemetry {
let minimumLevel = telemetryMinimumLevel(from: loggingSettings.level)
let appleTelemetry = RadrootsAppleLoggerTelemetry(subsystem: bundleIdentifier)
- if uiTestWasRequested {
- let recorder = RadrootsRecordingTelemetry()
+ #if DEBUG
+ if FieldUITestHarness.isRequested {
+ let recorder = FieldUITestRecordingTelemetry()
return FieldTelemetry(
sink: RadrootsMultiplexTelemetry([appleTelemetry, recorder]),
minimumLevel: minimumLevel,
- recordingTelemetry: recorder
+ recordedEventsProvider: { await recorder.recordedEvents }
)
}
+ #endif
return FieldTelemetry(sink: appleTelemetry, minimumLevel: minimumLevel)
}
@@ -273,17 +274,10 @@ final class FieldTelemetry: @unchecked Sendable {
}
func recordedEventsForUITest() async -> [RadrootsTelemetryEvent] {
- guard let recordingTelemetry else {
+ guard let recordedEventsProvider else {
return []
}
- return await recordingTelemetry.recordedEvents
- }
-
- private static var uiTestWasRequested: Bool {
- let environment = ProcessInfo.processInfo.environment
- let arguments = ProcessInfo.processInfo.arguments
- return environment["RADROOTS_FIELD_IOS_UI_TEST"] == "true" ||
- arguments.contains("--radroots-field-ios-ui-test")
+ return await recordedEventsProvider()
}
private static func telemetryMinimumLevel(from filter: String?) -> RadrootsTelemetryLevel {
diff --git a/Radroots/Runtime/FieldUITestSupport.swift b/Radroots/Runtime/FieldUITestSupport.swift
@@ -0,0 +1,284 @@
+import Foundation
+import RadrootsKit
+
+#if DEBUG
+enum FieldUITestHarness {
+ static var isRequested: Bool {
+ let environment = ProcessInfo.processInfo.environment
+ let arguments = ProcessInfo.processInfo.arguments
+ return environment["RADROOTS_FIELD_IOS_UI_TEST"] == "true" ||
+ arguments.contains("--radroots-field-ios-ui-test")
+ }
+
+ static func bool(_ key: String, default defaultValue: Bool) -> Bool {
+ guard let raw = ProcessInfo.processInfo.environment[key] else {
+ return defaultValue
+ }
+ switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
+ case "1", "true", "yes":
+ return true
+ case "0", "false", "no":
+ return false
+ default:
+ return defaultValue
+ }
+ }
+
+ static func string(_ key: String) -> String? {
+ ProcessInfo.processInfo.environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+}
+
+actor FieldUITestBackgroundTaskScheduler: RadrootsBackgroundTaskScheduler {
+ private var pendingTaskSnapshots: [RadrootsBackgroundTaskIdentifier: RadrootsBackgroundTaskSnapshot]
+ private var submittedRequestsValue: [RadrootsBackgroundTaskRequest]
+ private var cancelAllCountValue: Int
+ private let submittedAt: Date
+
+ init(submittedAt: Date = Date(timeIntervalSince1970: 0)) {
+ self.pendingTaskSnapshots = [:]
+ self.submittedRequestsValue = []
+ self.cancelAllCountValue = 0
+ self.submittedAt = submittedAt
+ }
+
+ func submit(_ request: RadrootsBackgroundTaskRequest) async throws -> RadrootsBackgroundTaskSnapshot {
+ submittedRequestsValue.append(request)
+ let snapshot = try RadrootsBackgroundTaskSnapshot(request: request, submittedAt: submittedAt)
+ pendingTaskSnapshots[request.identifier] = snapshot
+ return snapshot
+ }
+
+ func cancel(_ identifier: RadrootsBackgroundTaskIdentifier) async throws {
+ pendingTaskSnapshots.removeValue(forKey: identifier)
+ }
+
+ func cancelAll() async throws {
+ cancelAllCountValue += 1
+ pendingTaskSnapshots.removeAll()
+ }
+
+ func pendingTasks() async throws -> [RadrootsBackgroundTaskSnapshot] {
+ pendingTaskSnapshots.values.sorted { lhs, rhs in
+ lhs.identifier < rhs.identifier
+ }
+ }
+
+ var submittedRequestCount: Int {
+ submittedRequestsValue.count
+ }
+
+ var cancelAllCount: Int {
+ cancelAllCountValue
+ }
+}
+
+actor FieldUITestBackgroundTransferStore: RadrootsBackgroundTransferStore {
+ private var snapshotsByIdentifier: [RadrootsBackgroundTransferIdentifier: RadrootsBackgroundTransferSnapshot] = [:]
+
+ func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
+ snapshotsByIdentifier.values.sorted { left, right in
+ left.identifier < right.identifier
+ }
+ }
+
+ func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws {
+ snapshotsByIdentifier[snapshot.identifier] = snapshot
+ }
+
+ func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws {
+ snapshotsByIdentifier.removeValue(forKey: identifier)
+ }
+
+ func removeAllSnapshots() async throws {
+ snapshotsByIdentifier.removeAll()
+ }
+}
+
+actor FieldUITestBackgroundTransfer: RadrootsBackgroundTransfer {
+ private let store: any RadrootsBackgroundTransferStore
+ private let updatedAt: Date
+
+ init(
+ store: any RadrootsBackgroundTransferStore = FieldUITestBackgroundTransferStore(),
+ updatedAt: Date = Date(timeIntervalSince1970: 0)
+ ) {
+ self.store = store
+ self.updatedAt = updatedAt
+ }
+
+ func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws -> RadrootsBackgroundTransferHandle {
+ let snapshot = try RadrootsBackgroundTransferSnapshot(
+ request: request,
+ state: .queued,
+ updatedAt: updatedAt
+ )
+ try await store.saveSnapshot(snapshot)
+ return RadrootsBackgroundTransferHandle(request: request)
+ }
+
+ func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws {
+ if let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) {
+ let snapshot = try RadrootsBackgroundTransferSnapshot(
+ request: existing.request,
+ state: .cancelled,
+ progress: existing.progress,
+ updatedAt: updatedAt
+ )
+ try await store.saveSnapshot(snapshot)
+ }
+ }
+
+ func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot? {
+ try await store.loadSnapshots().first { $0.identifier == identifier }
+ }
+
+ func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
+ try await store.loadSnapshots()
+ }
+
+ func handleEventsForBackgroundURLSession(
+ identifier: String,
+ completionHandler: @escaping @Sendable () -> Void
+ ) async {
+ completionHandler()
+ }
+}
+
+actor FieldUITestMediaPicker: RadrootsMediaPicker {
+ private let support: RadrootsMediaPickerSupport
+ private let importOutcome: Result<RadrootsMediaImportResult, RadrootsCaptureIntakeError>
+ private let captureOutcome: Result<RadrootsMediaCaptureResult, RadrootsCaptureIntakeError>
+
+ init(
+ support: RadrootsMediaPickerSupport,
+ importOutcome: Result<RadrootsMediaImportResult, RadrootsCaptureIntakeError>,
+ captureOutcome: Result<RadrootsMediaCaptureResult, RadrootsCaptureIntakeError>
+ ) {
+ self.support = support
+ self.importOutcome = importOutcome
+ self.captureOutcome = captureOutcome
+ }
+
+ func currentSupport() async throws -> RadrootsMediaPickerSupport {
+ support
+ }
+
+ func importMedia(_ request: RadrootsMediaImportRequest) async throws -> RadrootsMediaImportResult {
+ switch importOutcome {
+ case .success(let result):
+ return result
+ case .failure(let error):
+ throw error
+ }
+ }
+
+ func captureMedia(_ request: RadrootsMediaCaptureRequest) async throws -> RadrootsMediaCaptureResult {
+ switch captureOutcome {
+ case .success(let result):
+ return result
+ case .failure(let error):
+ throw error
+ }
+ }
+}
+
+actor FieldUITestDocumentScanner: RadrootsDocumentScanner {
+ private let support: RadrootsDocumentScannerSupport
+ private let scanOutcome: Result<RadrootsScannedDocument, RadrootsCaptureIntakeError>
+
+ init(
+ support: RadrootsDocumentScannerSupport,
+ scanOutcome: Result<RadrootsScannedDocument, RadrootsCaptureIntakeError>
+ ) {
+ self.support = support
+ self.scanOutcome = scanOutcome
+ }
+
+ func currentSupport() async throws -> RadrootsDocumentScannerSupport {
+ support
+ }
+
+ func scanDocument(_ request: RadrootsDocumentScanRequest) async throws -> RadrootsScannedDocument {
+ switch scanOutcome {
+ case .success(let result):
+ return result
+ case .failure(let error):
+ throw error
+ }
+ }
+}
+
+actor FieldUITestExternalActions: RadrootsExternalActions {
+ private let defaultCanOpen: Bool
+ private let openOutcome: Result<Void, RadrootsExternalActionError>
+
+ init(
+ defaultCanOpen: Bool = true,
+ openOutcome: Result<Void, RadrootsExternalActionError> = .success(())
+ ) {
+ self.defaultCanOpen = defaultCanOpen
+ self.openOutcome = openOutcome
+ }
+
+ func canOpen(_ destination: RadrootsExternalActionDestination) async -> RadrootsExternalActionCapability {
+ RadrootsExternalActionCapability(destination: destination, canOpen: defaultCanOpen)
+ }
+
+ func open(_ request: RadrootsExternalActionRequest) async throws {
+ switch openOutcome {
+ case .success:
+ return
+ case .failure(let error):
+ throw error
+ }
+ }
+}
+
+actor FieldUITestUserPresence: RadrootsUserPresence {
+ private let statusValue: RadrootsUserPresenceStatus
+ private let outcomes: [Result<Bool, RadrootsUserPresenceError>]
+ private var requestCount: Int
+
+ init(
+ status: RadrootsUserPresenceStatus = RadrootsUserPresenceStatus(
+ support: .biometricsOrDeviceCredential,
+ biometryKind: .faceID,
+ canEvaluateDeviceCredential: true,
+ canEvaluateBiometrics: true
+ ),
+ outcomes: [Result<Bool, RadrootsUserPresenceError>] = [.success(true)]
+ ) {
+ self.statusValue = status
+ self.outcomes = outcomes.isEmpty ? [.success(true)] : outcomes
+ self.requestCount = 0
+ }
+
+ func currentStatus() async throws -> RadrootsUserPresenceStatus {
+ statusValue
+ }
+
+ func verify(_ request: RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult {
+ let outcome = outcomes[min(requestCount, outcomes.count - 1)]
+ requestCount += 1
+ switch outcome {
+ case .success(let verified):
+ return RadrootsUserPresenceResult(policy: request.policy, verified: verified)
+ case .failure(let error):
+ throw error
+ }
+ }
+}
+
+actor FieldUITestRecordingTelemetry: RadrootsTelemetry {
+ private var recordedEventsValue: [RadrootsTelemetryEvent] = []
+
+ func record(_ event: RadrootsTelemetryEvent) async {
+ recordedEventsValue.append(event)
+ }
+
+ var recordedEvents: [RadrootsTelemetryEvent] {
+ recordedEventsValue
+ }
+}
+#endif
diff --git a/Radroots/Runtime/FieldUserPresenceGate.swift b/Radroots/Runtime/FieldUserPresenceGate.swift
@@ -1,6 +1,5 @@
import Foundation
import RadrootsKit
-import RadrootsKitTesting
enum FieldUserPresenceAction: Equatable, Sendable {
case unlockIdentity
@@ -54,9 +53,11 @@ final class FieldUserPresenceGate: Sendable {
}
static func configured() -> FieldUserPresenceGate {
- if uiTestWasRequested {
+ #if DEBUG
+ if FieldUITestHarness.isRequested {
return FieldUserPresenceGate(userPresence: uiTestUserPresence())
}
+ #endif
return FieldUserPresenceGate(userPresence: RadrootsAppleUserPresence())
}
@@ -69,29 +70,15 @@ final class FieldUserPresenceGate: Sendable {
return FieldUserPresenceRequestRecord(action: action, statusText: action.verifiedStatusText)
}
- private static var uiTestWasRequested: Bool {
- let arguments = ProcessInfo.processInfo.arguments
- let environment = ProcessInfo.processInfo.environment
- return environment["RADROOTS_FIELD_IOS_UI_TEST"] == "true" ||
- arguments.contains("--radroots-field-ios-ui-test")
- }
-
+ #if DEBUG
private static func uiTestUserPresence() -> any RadrootsUserPresence {
let outcomes = uiTestOutcomes()
let status = uiTestStatus()
- if outcomes.count <= 1 {
- return RadrootsFakeUserPresence(
- status: status,
- verificationOutcome: outcomes.first?.result ?? .success(true)
- )
- }
- return FieldSequentialUserPresence(status: status, outcomes: outcomes.map(\.result))
+ return FieldUITestUserPresence(status: status, outcomes: outcomes.map(\.result))
}
private static func uiTestStatus() -> RadrootsUserPresenceStatus {
- let raw = ProcessInfo.processInfo.environment["RADROOTS_FIELD_IOS_UI_TEST_USER_PRESENCE_STATUS"]?
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .lowercased()
+ let raw = FieldUITestHarness.string("RADROOTS_FIELD_IOS_UI_TEST_USER_PRESENCE_STATUS")?.lowercased()
switch raw {
case "unavailable":
return .unavailable
@@ -115,8 +102,7 @@ final class FieldUserPresenceGate: Sendable {
}
private static func uiTestOutcomes() -> [FieldUserPresenceUITestOutcome] {
- let raw = ProcessInfo.processInfo.environment["RADROOTS_FIELD_IOS_UI_TEST_USER_PRESENCE_OUTCOME"]?
- .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ let raw = FieldUITestHarness.string("RADROOTS_FIELD_IOS_UI_TEST_USER_PRESENCE_OUTCOME") ?? ""
let parts = raw
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
@@ -126,38 +112,10 @@ final class FieldUserPresenceGate: Sendable {
}
return parts.map { FieldUserPresenceUITestOutcome(rawValue: $0) ?? .denied }
}
+ #endif
}
-private actor FieldSequentialUserPresence: RadrootsUserPresence {
- private let statusValue: RadrootsUserPresenceStatus
- private let outcomes: [Result<Bool, RadrootsUserPresenceError>]
- private var requestCount: Int
-
- init(
- status: RadrootsUserPresenceStatus,
- outcomes: [Result<Bool, RadrootsUserPresenceError>]
- ) {
- self.statusValue = status
- self.outcomes = outcomes
- self.requestCount = 0
- }
-
- func currentStatus() async throws -> RadrootsUserPresenceStatus {
- statusValue
- }
-
- func verify(_ request: RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult {
- let outcome = outcomes[min(requestCount, outcomes.count - 1)]
- requestCount += 1
- switch outcome {
- case .success(let verified):
- return RadrootsUserPresenceResult(policy: request.policy, verified: verified)
- case .failure(let error):
- throw error
- }
- }
-}
-
+#if DEBUG
private enum FieldUserPresenceUITestOutcome: String {
case success
case unverified
@@ -189,3 +147,4 @@ private enum FieldUserPresenceUITestOutcome: String {
}
}
}
+#endif
diff --git a/project.yml b/project.yml
@@ -34,8 +34,6 @@ targets:
- framework: Radroots/Frameworks/RadrootsFFI.xcframework
embed: false
- package: RadrootsKit
- - package: RadrootsKit
- product: RadrootsKitTesting
preBuildScripts:
- name: Generate git SHA xcconfig
basedOnDependencyAnalysis: false