Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Example/HCaptcha.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
9D974807F9A78F46BA2DF507 /* Pods_HCaptcha_UIKit_Example.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4D10E8098B5018907486C310 /* Pods_HCaptcha_UIKit_Example.framework */; };
B239CA7190DC61887942610E /* Pods_HCaptcha_Objc_Example.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8352086EA5EE39AFD3CDA308 /* Pods_HCaptcha_Objc_Example.framework */; };
BDFAD6E83D15B582D138D6BC /* Pods_HCaptcha_UITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF5369BD0117A5DA463EACA9 /* Pods_HCaptcha_UITests.framework */; };
D78A8C5AF5E892324AA9F5E0 /* DispatchQueue+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 306D97A7E014C9E6042E02CB /* DispatchQueue+Helpers.swift */; };
E626A8002890568A0069D449 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E626A7FF2890568A0069D449 /* ContentView.swift */; };
E626A80A289056E00069D449 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = E626A809289056E00069D449 /* App.swift */; };
E62A74212771290A00973B5E /* HCaptcha__Config__Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62A74202771290A00973B5E /* HCaptcha__Config__Tests.swift */; };
Expand Down Expand Up @@ -100,6 +101,7 @@
15CF87684AC2F129E32CC71E /* Pods-HCaptcha_Example.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HCaptcha_Example.debug.xcconfig"; path = "Target Support Files/Pods-HCaptcha_Example/Pods-HCaptcha_Example.debug.xcconfig"; sourceTree = "<group>"; };
1CF662E463D56A0421D8A84A /* Pods-HCaptcha_RxSwift_Example.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HCaptcha_RxSwift_Example.debug.xcconfig"; path = "Target Support Files/Pods-HCaptcha_RxSwift_Example/Pods-HCaptcha_RxSwift_Example.debug.xcconfig"; sourceTree = "<group>"; };
252CF3F651CD53804881A8EC /* Pods-HCaptcha_RxSwift_Example-HCaptcha_Tests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HCaptcha_RxSwift_Example-HCaptcha_Tests.debug.xcconfig"; path = "Target Support Files/Pods-HCaptcha_RxSwift_Example-HCaptcha_Tests/Pods-HCaptcha_RxSwift_Example-HCaptcha_Tests.debug.xcconfig"; sourceTree = "<group>"; };
306D97A7E014C9E6042E02CB /* DispatchQueue+Helpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "DispatchQueue+Helpers.swift"; sourceTree = "<group>"; };
3089D1286B04A4610A7EF299 /* Pods_HCaptcha_RxSwift_Example_HCaptcha_Tests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_HCaptcha_RxSwift_Example_HCaptcha_Tests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
436BDD0C2E813A8F0009557B /* InMemorySink__Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InMemorySink__Tests.swift; sourceTree = "<group>"; };
436BDD0D2E813A8F0009557B /* JourneyliticsHooks__Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JourneyliticsHooks__Tests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -448,6 +450,7 @@
9C766C7826CB814A00E0A592 /* HCaptchaConfig+Helpers.swift */,
F288E9441F9537760018688D /* HCaptchaError+Equatable.swift */,
F231B39E1FED4A8C00F82943 /* HCaptchaDecoder+Helper.swift */,
306D97A7E014C9E6042E02CB /* DispatchQueue+Helpers.swift */,
);
path = Helpers;
sourceTree = "<group>";
Expand Down Expand Up @@ -1036,6 +1039,7 @@
E62A74212771290A00973B5E /* HCaptcha__Config__Tests.swift in Sources */,
E68790E12DF8C60900F2A70F /* TestURLOpener.swift in Sources */,
9C5125BE26CB9A3A0094B023 /* HCaptchaWebViewManager__HTML__Tests.swift in Sources */,
D78A8C5AF5E892324AA9F5E0 /* DispatchQueue+Helpers.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,16 @@ class HCaptchaWebViewManager__HTML__Tests: XCTestCase {
override func setUpWithError() throws {
try super.setUpWithError()

DispatchQueue.resetOnceTokens()
HCaptchaWebViewManager.clearWebViewData()
webViewContentIsAvailable = expectation(description: "get webview content")
_ = HCaptchaDebugInfo.json
}

override func tearDownWithError() throws {
UIApplication.shared.keyWindow?.subviews
.filter { $0 is WKWebView }
.forEach { $0.removeFromSuperview() }
webViewContentIsAvailable = nil

try super.tearDownWithError()
Expand Down
90 changes: 89 additions & 1 deletion Example/HCaptcha_Tests/Core/HCaptchaWebViewManager__Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,21 @@ class HCaptchaWebViewManager__Tests: XCTestCase {
override func setUp() {
super.setUp()

DispatchQueue.resetOnceTokens()
HCaptchaWebViewManager.clearWebViewData()
presenterView = UIApplication.shared.keyWindow?.rootViewController?.view
apiKey = String(arc4random())
}

override func tearDown() {
presenterView?.subviews
.filter { $0 is WKWebView }
.forEach { $0.removeFromSuperview() }

UIApplication.shared.keyWindow?.subviews
.filter { $0 is WKWebView }
.forEach { $0.removeFromSuperview() }

presenterView = nil
apiKey = nil

Expand Down Expand Up @@ -579,7 +589,7 @@ class HCaptchaWebViewManager__Tests: XCTestCase {
manager.configureWebView { _ in
exp0.fulfill()
}
wait(for: [exp0], timeout: TestTimeouts.short)
wait(for: [exp0], timeout: TestTimeouts.standard)
manager.validate(on: presenterView)

wait(for: [exp1, exp2], timeout: TestTimeouts.standard)
Expand Down Expand Up @@ -831,4 +841,82 @@ class HCaptchaWebViewManager__Tests: XCTestCase {
// Then
XCTAssertEqual(rawValue, "reset();")
}

// MARK: - Pre-Validate Error Handling

func test__Handle_Error_PreValidate__Stores_Failed_State() {
let manager = HCaptchaWebViewManager(onLoad: .networkError)

let pred = NSPredicate { _, _ in manager.loadingState.error == .networkError }
let exp = XCTNSPredicateExpectation(predicate: pred, object: nil)
wait(for: [exp], timeout: TestTimeouts.standard)

XCTAssertNil(manager.completion)
}

func test__Validate_After_PreValidate_Network_Error__Silent_Retry_Reports_Error() {
let manager = HCaptchaWebViewManager(onLoad: .networkError)

let pred = NSPredicate { _, _ in manager.loadingState.error == .networkError }
let errExp = XCTNSPredicateExpectation(predicate: pred, object: nil)
wait(for: [errExp], timeout: TestTimeouts.standard)

let exp1 = expectation(description: "silent retry fails, then completion receives networkError")
manager.validate(on: presenterView, resetOnError: false) { result in
XCTAssertEqual(result.error, .networkError)
exp1.fulfill()
}

waitForExpectations(timeout: TestTimeouts.standard)

manager.stop()
}

func test__Validate_After_PreValidate_Network_Error__Silent_Retry_Recovers() {
let manager = HCaptchaWebViewManager(messageBody: "{token: key}",
apiKey: apiKey,
onLoad: .networkOnce)

let pred = NSPredicate { _, _ in manager.loadingState.error == .networkError }
let errExp = XCTNSPredicateExpectation(predicate: pred, object: nil)
wait(for: [errExp], timeout: TestTimeouts.standard)

let exp1 = expectation(description: "silent retry succeeds, validate returns token")
manager.validate(on: presenterView, resetOnError: false) { result in
XCTAssertNil(result.error)
XCTAssertEqual(result.token, self.apiKey)
exp1.fulfill()
}

waitForExpectations(timeout: TestTimeouts.standard)
}

func test__Validate_After_PreValidate_Network_Error__No_Double_Callback() {
let manager = HCaptchaWebViewManager(onLoad: .networkError)

let pred = NSPredicate { _, _ in manager.loadingState.error == .networkError }
let errExp = XCTNSPredicateExpectation(predicate: pred, object: nil)
wait(for: [errExp], timeout: TestTimeouts.standard)

var completionCallCount = 0
let exp1 = expectation(description: "completion called exactly once")

manager.validate(on: presenterView, resetOnError: false) { result in
completionCallCount += 1
XCTAssertEqual(result.error, .networkError)
exp1.fulfill()
}

waitForExpectations(timeout: TestTimeouts.standard)

let exp2 = expectation(description: "no extra callbacks")
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
XCTAssertEqual(completionCallCount, 1, "completion should be called exactly once")
exp2.fulfill()
}

waitForExpectations(timeout: TestTimeouts.standard)

manager.stop()
}
}
14 changes: 14 additions & 0 deletions Example/HCaptcha_Tests/Core/HCaptcha__Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import AppSwizzle
@testable import HCaptcha
import RxSwift
import WebKit
import XCTest


Expand All @@ -20,6 +21,19 @@ class HCaptcha__Tests: XCTestCase {
}
}

override func setUp() {
super.setUp()
DispatchQueue.resetOnceTokens()
HCaptchaWebViewManager.clearWebViewData()
}

override func tearDown() {
UIApplication.shared.keyWindow?.subviews
.filter { $0 is WKWebView }
.forEach { $0.removeFromSuperview() }
super.tearDown()
}

func test__valid_js_customTheme() {
let customTheme = """
{
Expand Down
2 changes: 0 additions & 2 deletions Example/HCaptcha_Tests/HCaptcha_Tests.xctestplan
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@
{
"parallelizable" : false,
"skippedTests" : [
"HCaptchaWebViewManager__HTML__Tests",
"HCaptcha_Rx__Tests",
"HCaptcha__Tests\/test__userJourney_enabled_without_impl_throws()"
],
"target" : {
Expand Down
47 changes: 47 additions & 0 deletions Example/HCaptcha_Tests/Helpers/DispatchQueue+Helpers.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// DispatchQueue+Helpers.swift
// HCaptcha_Tests
//
// Copyright © 2018 HCaptcha. All rights reserved.
//

@testable import HCaptcha
import Foundation

extension DispatchQueue {
private static var workItems = [AnyHashable: DispatchWorkItem]()
private static var lastDebounceCallTimes = [AnyHashable: DispatchTime]()
private static let nilContext = UUID()

func throttle(deadline: DispatchTime, context: AnyHashable = nilContext, action: @escaping () -> Void) {
let worker = DispatchWorkItem {
defer { DispatchQueue.workItems.removeValue(forKey: context) }
action()
}

asyncAfter(deadline: deadline, execute: worker)

DispatchQueue.workItems[context]?.cancel()
DispatchQueue.workItems[context] = worker
}

func debounce(interval: Double, context: AnyHashable = nilContext, action: @escaping () -> Void) {
let now = DispatchTime.now()
if let last = DispatchQueue.lastDebounceCallTimes[context], last + interval > now {
return
}

DispatchQueue.lastDebounceCallTimes[context] = now + interval
async(execute: action)

throttle(deadline: now + interval) {
DispatchQueue.lastDebounceCallTimes.removeValue(forKey: context)
}
}

static func resetOnceTokens() {
defer { objc_sync_exit(self) }
objc_sync_enter(self)
onceTokenStorage.removeAll()
}
}
16 changes: 16 additions & 0 deletions Example/HCaptcha_Tests/Helpers/HCaptcha+Helpers.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//
// HCaptcha+Helpers.swift
// HCaptcha_Tests
//
// Copyright © 2026 HCaptcha. All rights reserved.
//

import Foundation
@testable import HCaptcha

extension HCaptcha {
/// Test-only convenience init that defaults `userJourney` to false.
convenience init(manager: HCaptchaWebViewManager) {
self.init(manager: manager, userJourney: false)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,32 @@ extension HCaptchaWebViewManager {
.flatMap { try? String(contentsOfFile: $0) }
}()

enum MockOnLoad: String {
case didLoad
case networkError
case networkOnce
}

convenience init(
messageBody: String = "undefined",
apiKey: String? = nil,
passiveApiKey: Bool = false,
endpoint: URL? = nil,
shouldFail: Bool = false, // will fail with retriable sessionTimeout
onLoad: MockOnLoad = .didLoad,
shouldFail: Bool = false,
size: HCaptchaSize = .invisible,
rqdata: String? = nil,
theme: String = "light",
customTheme: String? = nil,
userJourney: Bool = false,
urlOpener: HCaptchaURLOpener = HCapchaAppURLOpener()
) {
let onExecute = shouldFail ? "sessionTimeout" : "normal"
let html = String(format: HCaptchaWebViewManager.unformattedHTML,
arguments: [
"message": messageBody,
"shouldFail": shouldFail.description
"onLoad": onLoad.rawValue,
"onExecute": onExecute
])

self.init(
Expand Down Expand Up @@ -98,4 +107,23 @@ extension HCaptchaWebViewManager {

validate(on: view)
}

/// Removes all session/local storage from the default data store so
/// tests start with a clean `WKWebView` environment.
static func clearWebViewData() {
let types: Set<String> = [
WKWebsiteDataTypeDiskCache,
WKWebsiteDataTypeMemoryCache,
WKWebsiteDataTypeSessionStorage,
WKWebsiteDataTypeLocalStorage
]
var done = false
WKWebsiteDataStore.default().removeData(
ofTypes: types,
modifiedSince: .distantPast
) { done = true }
while !done {
RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.01))
}
}
}
6 changes: 3 additions & 3 deletions Example/HCaptcha_Tests/Helpers/TestTimeouts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ enum TestTimeouts {
ProcessInfo.processInfo.environment["CI"] != nil
}()

static let standard: TimeInterval = isCI ? 60 : 10
static let long: TimeInterval = isCI ? 120 : 30
static let short: TimeInterval = isCI ? 5 : 1
static let standard: TimeInterval = isCI ? 30 : 5
static let long: TimeInterval = isCI ? 60 : 15
static let short: TimeInterval = isCI ? 10 : 2
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,23 @@
//

@testable import HCaptcha
import WebKit
import XCTest

class HCaptcha__Journey__Tests: XCTestCase {
override func setUp() {
super.setUp()
DispatchQueue.resetOnceTokens()
HCaptchaWebViewManager.clearWebViewData()
}

override func tearDown() {
UIApplication.shared.keyWindow?.subviews
.filter { $0 is WKWebView }
.forEach { $0.removeFromSuperview() }
super.tearDown()
}

func test__setData_called_injects_journeys() {
let exp = expectation(description: "journeys setData -> token posted")
let manager = HCaptchaWebViewManager(messageBody: "{action: \"setData\", token: \"journeys:setData\"}")
Expand Down
Loading
Loading