@clerk/expo-native-components 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -2
  3. package/android/build.gradle +152 -0
  4. package/android/src/main/AndroidManifest.xml +4 -0
  5. package/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt +241 -0
  6. package/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt +148 -0
  7. package/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +1077 -0
  8. package/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt +156 -0
  9. package/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt +120 -0
  10. package/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt +315 -0
  11. package/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt +326 -0
  12. package/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt +163 -0
  13. package/app.plugin.d.ts +5 -0
  14. package/app.plugin.js +333 -0
  15. package/dist/AuthView.d.ts +35 -0
  16. package/dist/AuthView.d.ts.map +1 -0
  17. package/dist/AuthView.js +79 -0
  18. package/dist/AuthView.js.map +1 -0
  19. package/dist/AuthView.types.d.ts +62 -0
  20. package/dist/AuthView.types.d.ts.map +1 -0
  21. package/dist/AuthView.types.js +0 -0
  22. package/dist/EmbeddedNavigation.types.d.ts +24 -0
  23. package/dist/EmbeddedNavigation.types.d.ts.map +1 -0
  24. package/dist/EmbeddedNavigation.types.js +0 -0
  25. package/dist/UserButton.d.ts +38 -0
  26. package/dist/UserButton.d.ts.map +1 -0
  27. package/dist/UserButton.js +68 -0
  28. package/dist/UserButton.js.map +1 -0
  29. package/dist/UserProfileCustomPages.d.ts +81 -0
  30. package/dist/UserProfileCustomPages.d.ts.map +1 -0
  31. package/dist/UserProfileCustomPages.js +140 -0
  32. package/dist/UserProfileCustomPages.js.map +1 -0
  33. package/dist/UserProfileView.d.ts +76 -0
  34. package/dist/UserProfileView.d.ts.map +1 -0
  35. package/dist/UserProfileView.js +101 -0
  36. package/dist/UserProfileView.js.map +1 -0
  37. package/dist/_virtual/_rolldown/runtime.js +29 -0
  38. package/dist/index.d.ts +41 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +37 -0
  41. package/dist/specs/NativeClerkAuthView.android.d.ts +15 -0
  42. package/dist/specs/NativeClerkAuthView.android.d.ts.map +1 -0
  43. package/dist/specs/NativeClerkAuthView.android.js +8 -0
  44. package/dist/specs/NativeClerkAuthView.android.js.map +1 -0
  45. package/dist/specs/NativeClerkAuthView.d.ts +15 -0
  46. package/dist/specs/NativeClerkAuthView.d.ts.map +1 -0
  47. package/dist/specs/NativeClerkAuthView.js +9 -0
  48. package/dist/specs/NativeClerkAuthView.js.map +1 -0
  49. package/dist/specs/NativeClerkModule.d.ts +4 -0
  50. package/dist/specs/NativeClerkModule.d.ts.map +1 -0
  51. package/dist/specs/NativeClerkModule.js +8 -0
  52. package/dist/specs/NativeClerkModule.js.map +1 -0
  53. package/dist/specs/NativeClerkModule.types.d.ts +11 -0
  54. package/dist/specs/NativeClerkModule.types.d.ts.map +1 -0
  55. package/dist/specs/NativeClerkModule.types.js +0 -0
  56. package/dist/specs/NativeClerkModule.web.d.ts +4 -0
  57. package/dist/specs/NativeClerkModule.web.d.ts.map +1 -0
  58. package/dist/specs/NativeClerkModule.web.js +7 -0
  59. package/dist/specs/NativeClerkModule.web.js.map +1 -0
  60. package/dist/specs/NativeClerkUserButtonView.android.d.ts +6 -0
  61. package/dist/specs/NativeClerkUserButtonView.android.d.ts.map +1 -0
  62. package/dist/specs/NativeClerkUserButtonView.android.js +8 -0
  63. package/dist/specs/NativeClerkUserButtonView.android.js.map +1 -0
  64. package/dist/specs/NativeClerkUserButtonView.d.ts +6 -0
  65. package/dist/specs/NativeClerkUserButtonView.d.ts.map +1 -0
  66. package/dist/specs/NativeClerkUserButtonView.js +9 -0
  67. package/dist/specs/NativeClerkUserButtonView.js.map +1 -0
  68. package/dist/specs/NativeClerkUserProfileView.android.d.ts +13 -0
  69. package/dist/specs/NativeClerkUserProfileView.android.d.ts.map +1 -0
  70. package/dist/specs/NativeClerkUserProfileView.android.js +8 -0
  71. package/dist/specs/NativeClerkUserProfileView.android.js.map +1 -0
  72. package/dist/specs/NativeClerkUserProfileView.d.ts +13 -0
  73. package/dist/specs/NativeClerkUserProfileView.d.ts.map +1 -0
  74. package/dist/specs/NativeClerkUserProfileView.js +9 -0
  75. package/dist/specs/NativeClerkUserProfileView.js.map +1 -0
  76. package/dist/useAuthViewState.d.ts +12 -0
  77. package/dist/useAuthViewState.d.ts.map +1 -0
  78. package/dist/useAuthViewState.js +76 -0
  79. package/dist/useAuthViewState.js.map +1 -0
  80. package/dist/utils/native-module.d.ts +3 -0
  81. package/dist/utils/native-module.d.ts.map +1 -0
  82. package/dist/utils/native-module.js +14 -0
  83. package/dist/utils/native-module.js.map +1 -0
  84. package/expo-module.config.json +15 -0
  85. package/ios/ClerkAppDelegateSubscriber.swift +16 -0
  86. package/ios/ClerkAuthNativeView.swift +157 -0
  87. package/ios/ClerkExpo.podspec +67 -0
  88. package/ios/ClerkExpoModule.swift +315 -0
  89. package/ios/ClerkNativeBridge.swift +1575 -0
  90. package/ios/ClerkNativeViewHost.swift +204 -0
  91. package/ios/ClerkUserButtonNativeView.swift +34 -0
  92. package/ios/ClerkUserProfileNativeView.swift +92 -0
  93. package/ios/Tests/ClerkAuthNativeViewPaperTests.swift +30 -0
  94. package/ios/Tests/ClerkNativeBridgeTests.swift +185 -0
  95. package/ios/Tests/ClerkNativeViewHostTests.swift +66 -0
  96. package/ios/Tests/ClerkUserProfileCustomPageStateTests.swift +311 -0
  97. package/package.json +79 -3
  98. package/react-native.config.js +8 -0
  99. package/src/specs/NativeClerkAuthView.android.ts +15 -0
  100. package/src/specs/NativeClerkAuthView.ts +19 -0
  101. package/src/specs/NativeClerkModule.ts +6 -0
  102. package/src/specs/NativeClerkModule.types.ts +9 -0
  103. package/src/specs/NativeClerkModule.web.ts +3 -0
  104. package/src/specs/NativeClerkUserButtonView.android.ts +9 -0
  105. package/src/specs/NativeClerkUserButtonView.ts +11 -0
  106. package/src/specs/NativeClerkUserProfileView.android.ts +13 -0
  107. package/src/specs/NativeClerkUserProfileView.ts +17 -0
  108. package/src/specs/__tests__/native-view.web.test.ts +43 -0
@@ -0,0 +1,204 @@
1
+ import ExpoModulesCore
2
+ import UIKit
3
+
4
+ public class ClerkNativeViewHost: ExpoView {
5
+ private lazy var hostingCoordinator = ClerkNativeHostingCoordinator(containerView: self)
6
+ private var hasInitialized: Bool = false
7
+ private var configuredObserver: NSObjectProtocol?
8
+
9
+ public required init(appContext: AppContext? = nil) {
10
+ super.init(appContext: appContext)
11
+ }
12
+
13
+ @available(*, unavailable)
14
+ public required init?(coder: NSCoder) {
15
+ fatalError("init(coder:) has not been implemented")
16
+ }
17
+
18
+ deinit {
19
+ removeConfiguredObserver()
20
+ }
21
+
22
+ override public func didMoveToWindow() {
23
+ super.didMoveToWindow()
24
+
25
+ guard window != nil else {
26
+ if hasInitialized {
27
+ hostedViewDidDetachFromWindow()
28
+ }
29
+ removeConfiguredObserver()
30
+ hostingCoordinator.detach()
31
+ hasInitialized = false
32
+ return
33
+ }
34
+
35
+ guard !hasInitialized else { return }
36
+ hasInitialized = true
37
+ addConfiguredObserver()
38
+ hostedViewDidAttachToWindow()
39
+ updateHostedView()
40
+ }
41
+
42
+ override public func layoutSubviews() {
43
+ super.layoutSubviews()
44
+ hostingCoordinator.layout()
45
+ }
46
+
47
+ func setNeedsHostedViewUpdate() {
48
+ guard hasInitialized else { return }
49
+ updateHostedView()
50
+ }
51
+
52
+ func makeHostedController() -> UIViewController? {
53
+ nil
54
+ }
55
+
56
+ // Subclasses can observe attach/detach without making this host know about their RN event props.
57
+ func hostedViewDidAttachToWindow() {}
58
+
59
+ func hostedViewDidDetachFromWindow() {}
60
+
61
+ private func addConfiguredObserver() {
62
+ guard configuredObserver == nil else { return }
63
+
64
+ configuredObserver = NotificationCenter.default.addObserver(
65
+ forName: .clerkNativeSDKDidConfigure,
66
+ object: nil,
67
+ queue: .main
68
+ ) { [weak self] _ in
69
+ guard let self, !hostingCoordinator.hasAttachedController else { return }
70
+ setNeedsHostedViewUpdate()
71
+ }
72
+ }
73
+
74
+ private func removeConfiguredObserver() {
75
+ guard let configuredObserver else { return }
76
+ NotificationCenter.default.removeObserver(configuredObserver)
77
+ self.configuredObserver = nil
78
+ }
79
+
80
+ private func updateHostedView() {
81
+ guard let controller = makeHostedController() else { return }
82
+ hostingCoordinator.attach(controller)
83
+ }
84
+ }
85
+
86
+ public class ClerkUserProfileCustomPageHost: ClerkNativeViewHost {
87
+ private var currentCustomPages: String = "[]"
88
+ let customPageState = ClerkUserProfileCustomPageState()
89
+
90
+ // Expo resolves event dispatchers through `Mirror(reflecting:).children`, which skips
91
+ // inherited properties, so subclasses must own the dispatcher and surface it here.
92
+ var customPageEventDispatcher: EventDispatcher? { nil }
93
+
94
+ public required init(appContext: AppContext? = nil) {
95
+ super.init(appContext: appContext)
96
+ customPageState.setPageEventHandler { [weak self] type, path in
97
+ self?.customPageEventDispatcher?(["type": type, "path": path])
98
+ }
99
+ }
100
+
101
+ func setCustomPages(_ customPages: String?) {
102
+ let newCustomPages = customPages ?? "[]"
103
+ guard newCustomPages != currentCustomPages else { return }
104
+ let validPaths = Set(decodeUserProfileCustomPages(newCustomPages).map(\.path))
105
+ customPageState.reconcileCustomPagePaths(validPaths)
106
+ currentCustomPages = newCustomPages
107
+ setNeedsHostedViewUpdate()
108
+ }
109
+
110
+ func navigateCustomPage(action: String, routeKey: String?) {
111
+ customPageState.navigate(action: action, routeKey: routeKey)
112
+ }
113
+
114
+ func customRows() -> [ClerkUserProfileCustomRowConfig] {
115
+ parseUserProfileCustomPages(currentCustomPages, pageCount: customPageState.views.count)
116
+ }
117
+
118
+ #if RCT_NEW_ARCH_ENABLED
119
+ override public func mountChildComponentView(_ childComponentView: UIView, index: Int) {
120
+ customPageState.insertView(childComponentView, at: index)
121
+ setNeedsHostedViewUpdate()
122
+ }
123
+
124
+ override public func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
125
+ customPageState.removeView(childComponentView)
126
+ setNeedsHostedViewUpdate()
127
+ }
128
+ #else
129
+ override public func insertReactSubview(_ subview: UIView!, at atIndex: Int) {
130
+ super.insertReactSubview(subview, at: atIndex)
131
+ customPageState.insertView(subview, at: atIndex)
132
+ setNeedsHostedViewUpdate()
133
+ }
134
+
135
+ override public func removeReactSubview(_ subview: UIView!) {
136
+ customPageState.removeView(subview)
137
+ super.removeReactSubview(subview)
138
+ setNeedsHostedViewUpdate()
139
+ }
140
+
141
+ override public func didUpdateReactSubviews() {}
142
+ #endif
143
+ }
144
+
145
+ private final class ClerkNativeHostingCoordinator {
146
+ private weak var containerView: UIView?
147
+ private var hostingController: UIViewController?
148
+
149
+ var hasAttachedController: Bool {
150
+ hostingController != nil
151
+ }
152
+
153
+ init(containerView: UIView) {
154
+ self.containerView = containerView
155
+ }
156
+
157
+ func attach(_ controller: UIViewController) {
158
+ detach()
159
+
160
+ guard let containerView else { return }
161
+
162
+ controller.view.frame = containerView.bounds
163
+ controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
164
+
165
+ if let parentVC = findViewController(from: containerView) {
166
+ parentVC.addChild(controller)
167
+ containerView.addSubview(controller.view)
168
+ controller.didMove(toParent: parentVC)
169
+ } else {
170
+ containerView.addSubview(controller.view)
171
+ }
172
+
173
+ hostingController = controller
174
+ }
175
+
176
+ func detach() {
177
+ guard let controller = hostingController else { return }
178
+
179
+ if controller.parent != nil {
180
+ controller.willMove(toParent: nil)
181
+ }
182
+ controller.view.removeFromSuperview()
183
+ if controller.parent != nil {
184
+ controller.removeFromParent()
185
+ }
186
+ hostingController = nil
187
+ }
188
+
189
+ func layout() {
190
+ guard let containerView else { return }
191
+ hostingController?.view.frame = containerView.bounds
192
+ }
193
+
194
+ private func findViewController(from view: UIView) -> UIViewController? {
195
+ var responder: UIResponder? = view
196
+ while let nextResponder = responder?.next {
197
+ if let vc = nextResponder as? UIViewController {
198
+ return vc
199
+ }
200
+ responder = nextResponder
201
+ }
202
+ return nil
203
+ }
204
+ }
@@ -0,0 +1,34 @@
1
+ import ExpoModulesCore
2
+ import UIKit
3
+
4
+ public class ClerkUserButtonNativeView: ClerkUserProfileCustomPageHost {
5
+ let onCustomPageEvent = EventDispatcher()
6
+
7
+ override var customPageEventDispatcher: EventDispatcher? { onCustomPageEvent }
8
+
9
+ override func makeHostedController() -> UIViewController? {
10
+ return ClerkNativeBridge.shared.makeUserButtonViewController(
11
+ customRows: customRows(),
12
+ customPageState: customPageState
13
+ )
14
+ }
15
+ }
16
+
17
+ public class ClerkUserButtonViewModule: Module {
18
+ public func definition() -> ModuleDefinition {
19
+ Name("ClerkUserButtonView")
20
+
21
+ View(ClerkUserButtonNativeView.self) {
22
+ Events("onCustomPageEvent")
23
+
24
+ Prop("customPages") { (view: ClerkUserButtonNativeView, customPages: String?) in
25
+ view.setCustomPages(customPages)
26
+ }
27
+
28
+ AsyncFunction("navigateCustomPage") {
29
+ (view: ClerkUserButtonNativeView, action: String, routeKey: String?) in
30
+ view.navigateCustomPage(action: action, routeKey: routeKey)
31
+ }
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,92 @@
1
+ import ExpoModulesCore
2
+ import UIKit
3
+
4
+ public class ClerkUserProfileNativeView: ClerkUserProfileCustomPageHost {
5
+ private var currentDismissible: Bool = true
6
+ private var currentHostBackButton: Bool = false
7
+ private var didSendDismiss = false
8
+
9
+ let onProfileEvent = EventDispatcher()
10
+ let onHostBack = EventDispatcher()
11
+ let onCustomPageEvent = EventDispatcher()
12
+
13
+ override var customPageEventDispatcher: EventDispatcher? { onCustomPageEvent }
14
+
15
+ func setDismissible(_ isDismissible: Bool?) {
16
+ let newDismissible = isDismissible ?? true
17
+ guard newDismissible != currentDismissible else { return }
18
+ currentDismissible = newDismissible
19
+ setNeedsHostedViewUpdate()
20
+ }
21
+
22
+ func setHostBackButton(_ hostBackButton: Bool?) {
23
+ let newHostBackButton = hostBackButton ?? false
24
+ guard newHostBackButton != currentHostBackButton else { return }
25
+ currentHostBackButton = newHostBackButton
26
+ setNeedsHostedViewUpdate()
27
+ }
28
+
29
+ private func sendProfileEvent(type: ClerkNativeViewEvent) {
30
+ onProfileEvent(["type": type.rawValue])
31
+ }
32
+
33
+ private func sendDismissIfNeeded() {
34
+ // SwiftUI dismissals detach the hosted view without calling UIKit dismiss().
35
+ guard currentDismissible, !didSendDismiss else { return }
36
+ didSendDismiss = true
37
+ sendProfileEvent(type: .dismissed)
38
+ }
39
+
40
+ override func hostedViewDidAttachToWindow() {
41
+ didSendDismiss = false
42
+ }
43
+
44
+ override func hostedViewDidDetachFromWindow() {
45
+ sendDismissIfNeeded()
46
+ }
47
+
48
+ override func makeHostedController() -> UIViewController? {
49
+ let hostBackAction: (() -> Void)? = currentHostBackButton
50
+ ? { [weak self] in self?.onHostBack([:]) }
51
+ : nil
52
+
53
+ return ClerkNativeBridge.shared.makeUserProfileViewController(
54
+ dismissible: currentDismissible,
55
+ customRows: customRows(),
56
+ customPageState: customPageState,
57
+ hostBackAction: hostBackAction,
58
+ onEvent: { [weak self] event, _ in
59
+ if event == .dismissed {
60
+ self?.sendDismissIfNeeded()
61
+ }
62
+ }
63
+ )
64
+ }
65
+ }
66
+
67
+ public class ClerkUserProfileViewModule: Module {
68
+ public func definition() -> ModuleDefinition {
69
+ Name("ClerkUserProfileView")
70
+
71
+ View(ClerkUserProfileNativeView.self) {
72
+ Events("onProfileEvent", "onCustomPageEvent", "onHostBack")
73
+
74
+ Prop("isDismissible") { (view: ClerkUserProfileNativeView, isDismissible: Bool?) in
75
+ view.setDismissible(isDismissible)
76
+ }
77
+
78
+ Prop("hostBackButton") { (view: ClerkUserProfileNativeView, hostBackButton: Bool?) in
79
+ view.setHostBackButton(hostBackButton)
80
+ }
81
+
82
+ Prop("customPages") { (view: ClerkUserProfileNativeView, customPages: String?) in
83
+ view.setCustomPages(customPages)
84
+ }
85
+
86
+ AsyncFunction("navigateCustomPage") {
87
+ (view: ClerkUserProfileNativeView, action: String, routeKey: String?) in
88
+ view.navigateCustomPage(action: action, routeKey: routeKey)
89
+ }
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,30 @@
1
+ #if !RCT_NEW_ARCH_ENABLED
2
+ import ExpoModulesCore
3
+ import UIKit
4
+ import XCTest
5
+ import ClerkExpo
6
+
7
+ final class ClerkAuthNativeViewPaperTests: XCTestCase {
8
+ @MainActor
9
+ func testAddingAndRemovingLogoAfterMountKeepsReactChildOutOfHostHierarchy() {
10
+ let window = UIWindow(frame: UIScreen.main.bounds)
11
+ let viewController = UIViewController()
12
+ let authView = ClerkAuthNativeView(appContext: nil)
13
+ window.rootViewController = viewController
14
+ viewController.view.addSubview(authView)
15
+ window.makeKeyAndVisible()
16
+
17
+ let logoView = UIView(frame: CGRect(x: 0, y: 0, width: 120, height: 40))
18
+ authView.insertReactSubview(logoView, at: 0)
19
+ authView.didUpdateReactSubviews()
20
+
21
+ XCTAssertFalse(logoView.superview === authView)
22
+
23
+ authView.removeReactSubview(logoView)
24
+ authView.didUpdateReactSubviews()
25
+
26
+ XCTAssertNil(logoView.superview)
27
+ window.isHidden = true
28
+ }
29
+ }
30
+ #endif
@@ -0,0 +1,185 @@
1
+ import XCTest
2
+ import ClerkKit
3
+ @testable import ClerkExpo
4
+
5
+ final class ClerkNativeBridgeTests: XCTestCase {
6
+ @MainActor
7
+ func testBiometricCredentialAvailabilityIsUnavailableBeforeConfiguration() async throws {
8
+ let availability = try await ClerkNativeBridge.shared.getBiometricCredentialAvailability(
9
+ id: nil,
10
+ identifierHint: nil
11
+ )
12
+
13
+ XCTAssertEqual(availability["isAvailable"] as? Bool, false)
14
+ XCTAssertEqual(availability["unavailableReason"] as? String, "environment_unavailable")
15
+ }
16
+
17
+ @MainActor
18
+ func testBiometricCredentialOperationsRejectBeforeConfiguration() async {
19
+ await assertEnvironmentUnavailable {
20
+ try await ClerkNativeBridge.shared.reverifyWithBiometrics(
21
+ sessionId: "sess_test", level: "multi_factor", reason: nil
22
+ )
23
+ }
24
+ await assertEnvironmentUnavailable {
25
+ try await ClerkNativeBridge.shared.listBiometricCredentials()
26
+ }
27
+ await assertEnvironmentUnavailable {
28
+ try await ClerkNativeBridge.shared.enrollBiometricCredential(
29
+ deviceName: nil,
30
+ identifierHint: nil,
31
+ reason: nil,
32
+ policy: "biometry_or_device_passcode"
33
+ )
34
+ }
35
+ await assertEnvironmentUnavailable {
36
+ try await ClerkNativeBridge.shared.revokeBiometricCredential(id: "td_test")
37
+ }
38
+ await assertEnvironmentUnavailable {
39
+ try await ClerkNativeBridge.shared.signInWithBiometrics(
40
+ id: nil,
41
+ identifierHint: nil,
42
+ reason: nil
43
+ )
44
+ }
45
+ }
46
+
47
+ func testBiometricReverificationLevels() throws {
48
+ XCTAssertEqual(try ClerkNativeBridge.biometricReverificationLevel("first_factor"), .firstFactor)
49
+ XCTAssertEqual(try ClerkNativeBridge.biometricReverificationLevel("second_factor"), .secondFactor)
50
+ XCTAssertEqual(try ClerkNativeBridge.biometricReverificationLevel("multi_factor"), .multiFactor)
51
+ XCTAssertThrowsError(try ClerkNativeBridge.biometricReverificationLevel("unknown"))
52
+ }
53
+
54
+ func testBiometricReverificationPayloadWithoutEmbeddedSession() {
55
+ let verification = SessionVerification(id: "stepup_test", status: .complete, level: .multiFactor)
56
+ let payload = ClerkNativeBridge.biometricReverificationPayload(verification, sessionId: "sess_test")
57
+ XCTAssertEqual(payload["id"] as? String, "stepup_test")
58
+ XCTAssertEqual(payload["status"] as? String, "complete")
59
+ XCTAssertEqual(payload["level"] as? String, "multi_factor")
60
+ XCTAssertEqual(payload["sessionId"] as? String, "sess_test")
61
+ }
62
+
63
+ @MainActor
64
+ func testBiometricReverificationContinuesToSecondFactor() async throws {
65
+ var factors: [Session.BiometricVerificationLevel] = []
66
+ let started = SessionVerification(id: "stepup_test", status: .needsFirstFactor, level: .multiFactor)
67
+ let result = try await ClerkNativeBridge.verifyBiometricReverification(started) { factor in
68
+ factors.append(factor)
69
+ return SessionVerification(id: started.id, status: factor == .firstFactor ? .needsSecondFactor : .complete,
70
+ level: .multiFactor)
71
+ }
72
+ XCTAssertEqual(factors, [.firstFactor, .secondFactor])
73
+ XCTAssertEqual(result.id, started.id)
74
+ XCTAssertEqual(result.status, .complete)
75
+ }
76
+
77
+ @MainActor
78
+ func testBiometricReverificationPromptsOnlyForRequiredFactors() async throws {
79
+ for status in [SessionVerification.Status.needsFirstFactor, .needsSecondFactor, .complete] {
80
+ var factors: [Session.BiometricVerificationLevel] = []
81
+ let started = SessionVerification(id: "stepup_test", status: status, level: .multiFactor)
82
+ let result = try await ClerkNativeBridge.verifyBiometricReverification(started) { factor in
83
+ factors.append(factor)
84
+ return SessionVerification(id: started.id, status: .complete, level: .multiFactor)
85
+ }
86
+ XCTAssertEqual(factors, status == .complete ? [] : [status == .needsFirstFactor ? .firstFactor : .secondFactor])
87
+ XCTAssertEqual(result.status, .complete)
88
+ }
89
+ }
90
+
91
+ @MainActor
92
+ func testBiometricReverificationDoesNotRetryAnIncompleteSecondFactor() async throws {
93
+ var factors: [Session.BiometricVerificationLevel] = []
94
+ let started = SessionVerification(status: .needsFirstFactor, level: .multiFactor)
95
+ let result = try await ClerkNativeBridge.verifyBiometricReverification(started) { factor in
96
+ factors.append(factor)
97
+ return SessionVerification(status: .needsSecondFactor, level: .multiFactor)
98
+ }
99
+ XCTAssertEqual(factors, [.firstFactor, .secondFactor])
100
+ XCTAssertEqual(result.status, .needsSecondFactor)
101
+ }
102
+
103
+ @MainActor
104
+ func testBiometricReverificationPreservesFactorFailures() async {
105
+ for failingFactor in [Session.BiometricVerificationLevel.firstFactor, .secondFactor] {
106
+ var factors: [Session.BiometricVerificationLevel] = []
107
+ let expected = NSError(domain: "biometric_authentication_canceled", code: 1)
108
+ do {
109
+ _ = try await ClerkNativeBridge.verifyBiometricReverification(
110
+ SessionVerification(status: .needsFirstFactor, level: .multiFactor)
111
+ ) { factor in
112
+ factors.append(factor)
113
+ if factor == failingFactor { throw expected }
114
+ return SessionVerification(status: .needsSecondFactor, level: .multiFactor)
115
+ }
116
+ XCTFail("Expected the factor failure to propagate.")
117
+ } catch {
118
+ XCTAssertEqual(error as NSError, expected)
119
+ }
120
+ XCTAssertEqual(factors, failingFactor == .firstFactor ? [.firstFactor] : [.firstFactor, .secondFactor])
121
+ }
122
+ }
123
+
124
+ @MainActor
125
+ func testCancellationBetweenBiometricFactorsStopsContinuation() async {
126
+ var factors: [Session.BiometricVerificationLevel] = []
127
+ let task = Task { @MainActor in
128
+ try await ClerkNativeBridge.verifyBiometricReverification(
129
+ SessionVerification(status: .needsFirstFactor, level: .multiFactor)
130
+ ) { factor in
131
+ factors.append(factor)
132
+ withUnsafeCurrentTask { $0?.cancel() }
133
+ return SessionVerification(status: .needsSecondFactor, level: .multiFactor)
134
+ }
135
+ }
136
+ do {
137
+ _ = try await task.value
138
+ XCTFail("Expected cancellation to stop the second factor.")
139
+ } catch {
140
+ XCTAssertTrue(error is CancellationError)
141
+ }
142
+ XCTAssertEqual(factors, [.firstFactor])
143
+ }
144
+
145
+ @MainActor
146
+ func testUnknownBiometricVerificationStatusDoesNotPrompt() async {
147
+ do {
148
+ _ = try await ClerkNativeBridge.verifyBiometricReverification(
149
+ SessionVerification(status: .unknown("future_status"), level: .multiFactor)
150
+ ) { _ in
151
+ XCTFail("An unknown status must not prompt.")
152
+ return SessionVerification(status: .complete, level: .multiFactor)
153
+ }
154
+ XCTFail("Expected an unsupported status error.")
155
+ } catch {
156
+ XCTAssertEqual(ClerkNativeBridge.biometricCredentialErrorDescriptor(error, fallbackCode: "unexpected").code,
157
+ "E_BIOMETRIC_REVERIFICATION_FAILED")
158
+ }
159
+ }
160
+
161
+ func testBiometricReverificationPreservesNativePolicyError() {
162
+ let error = BiometricCredentialError.policyIncompatible
163
+ let result = ClerkNativeBridge.biometricCredentialErrorDescriptor(error, fallbackCode: "unexpected")
164
+ XCTAssertEqual(result.code, "biometric_credential_policy_incompatible")
165
+ XCTAssertEqual(result.message, error.localizedDescription)
166
+ }
167
+
168
+ @MainActor
169
+ private func assertEnvironmentUnavailable(
170
+ _ operation: @MainActor () async throws -> Any,
171
+ file: StaticString = #filePath,
172
+ line: UInt = #line
173
+ ) async {
174
+ do {
175
+ _ = try await operation()
176
+ XCTFail("Expected biometric-credential operation to reject before configuration.", file: file, line: line)
177
+ } catch {
178
+ let descriptor = ClerkNativeBridge.biometricCredentialErrorDescriptor(
179
+ error,
180
+ fallbackCode: "unexpected_error"
181
+ )
182
+ XCTAssertEqual(descriptor.code, "environment_unavailable", file: file, line: line)
183
+ }
184
+ }
185
+ }
@@ -0,0 +1,66 @@
1
+ import UIKit
2
+ import XCTest
3
+ @testable import ClerkExpo
4
+
5
+ final class ClerkNativeViewHostTests: XCTestCase {
6
+ @MainActor
7
+ func testConfigureNotificationDoesNotReplaceAttachedController() {
8
+ let hostView = TestClerkNativeViewHost(appContext: nil)
9
+ let controller = UIViewController()
10
+ hostView.controller = controller
11
+ let window = mountInWindow(hostView)
12
+ let callsBeforeNotification = hostView.makeHostedControllerCallCount
13
+
14
+ NotificationCenter.default.post(name: .clerkNativeSDKDidConfigure, object: nil)
15
+
16
+ XCTAssertEqual(hostView.makeHostedControllerCallCount, callsBeforeNotification)
17
+ XCTAssertTrue(controller.view.superview === hostView)
18
+ unmount(hostView, from: window)
19
+ }
20
+
21
+ @MainActor
22
+ func testConfigureNotificationAttachesControllerWhenInitiallyUnavailable() {
23
+ let hostView = TestClerkNativeViewHost(appContext: nil)
24
+ let window = mountInWindow(hostView)
25
+ let callsBeforeConfiguration = hostView.makeHostedControllerCallCount
26
+ let controller = UIViewController()
27
+ hostView.controller = controller
28
+
29
+ NotificationCenter.default.post(name: .clerkNativeSDKDidConfigure, object: nil)
30
+
31
+ XCTAssertEqual(hostView.makeHostedControllerCallCount, callsBeforeConfiguration + 1)
32
+ XCTAssertTrue(controller.view.superview === hostView)
33
+
34
+ NotificationCenter.default.post(name: .clerkNativeSDKDidConfigure, object: nil)
35
+
36
+ XCTAssertEqual(hostView.makeHostedControllerCallCount, callsBeforeConfiguration + 1)
37
+ unmount(hostView, from: window)
38
+ }
39
+
40
+ @MainActor
41
+ private func mountInWindow(_ hostView: UIView) -> UIWindow {
42
+ let window = UIWindow(frame: UIScreen.main.bounds)
43
+ let viewController = UIViewController()
44
+ window.rootViewController = viewController
45
+ viewController.view.addSubview(hostView)
46
+ window.makeKeyAndVisible()
47
+ return window
48
+ }
49
+
50
+ @MainActor
51
+ private func unmount(_ hostView: UIView, from window: UIWindow) {
52
+ hostView.removeFromSuperview()
53
+ window.isHidden = true
54
+ }
55
+ }
56
+
57
+ @MainActor
58
+ private final class TestClerkNativeViewHost: ClerkNativeViewHost {
59
+ var controller: UIViewController?
60
+ private(set) var makeHostedControllerCallCount = 0
61
+
62
+ override func makeHostedController() -> UIViewController? {
63
+ makeHostedControllerCallCount += 1
64
+ return controller
65
+ }
66
+ }