field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | LICENSE

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:
MRadroots.xcodeproj/project.pbxproj | 11++++-------
MRadroots/App/AppEntry.swift | 2++
MRadroots/App/AppState.swift | 22+++++++++++++++-------
MRadroots/Runtime/FieldBackgroundExecution.swift | 33++++++++++++++++++++-------------
MRadroots/Runtime/FieldCaptureIntake.swift | 21++++++++++-----------
MRadroots/Runtime/FieldExternalActions.swift | 46++++++++++++++--------------------------------
MRadroots/Runtime/FieldTelemetry.swift | 26++++++++++----------------
ARadroots/Runtime/FieldUITestSupport.swift | 284+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MRadroots/Runtime/FieldUserPresenceGate.swift | 61++++++++++---------------------------------------------------
Mproject.yml | 2--
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