diff --git a/.gitignore b/.gitignore index 419f575..45985c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ build node_modules *.log - *.xcuserstate - *.xcbkptlist +.swiftpm +xcuserdata diff --git a/Source/Bridge.swift b/Source/Bridge.swift index 3d1d5ad..23ccd73 100644 --- a/Source/Bridge.swift +++ b/Source/Bridge.swift @@ -8,21 +8,20 @@ public enum BridgeError: Error { protocol Bridgable: AnyObject { var delegate: BridgeDelegate? { get set } var webView: WKWebView? { get } - - func register(component: String) - func register(components: [String]) - func unregister(component: String) - func reply(with message: Message) + + func register(component: String) async throws + func register(components: [String]) async throws + func unregister(component: String) async throws + func reply(with message: Message) async throws } /// `Bridge` is the object for configuring a web view and /// the channel for sending/receiving messages public final class Bridge: Bridgable { - typealias CompletionHandler = (_ result: Any?, _ error: Error?) -> Void - + public typealias InitializationCompletionHandler = () -> Void weak var delegate: BridgeDelegate? weak var webView: WKWebView? - + public static func initialize(_ webView: WKWebView) { if getBridgeFor(webView) == nil { initialize(Bridge(webView: webView)) @@ -33,39 +32,39 @@ public final class Bridge: Bridgable { self.webView = webView loadIntoWebView() } - - deinit { - webView?.configuration.userContentController.removeScriptMessageHandler(forName: scriptHandlerName) - } - + // MARK: - Internal API - + /// Register a single component /// - Parameter component: Name of a component to register support for - func register(component: String) { - callBridgeFunction(.register, arguments: [component]) + @MainActor + func register(component: String) async throws { + try await callBridgeFunction(.register, arguments: [component]) } - + /// Register multiple components /// - Parameter components: Array of component names to register - func register(components: [String]) { - callBridgeFunction(.register, arguments: [components]) + @MainActor + func register(components: [String]) async throws { + try await callBridgeFunction(.register, arguments: [components]) } - + /// Unregister support for a single component /// - Parameter component: Component name - func unregister(component: String) { - callBridgeFunction(.unregister, arguments: [component]) + @MainActor + func unregister(component: String) async throws { + try await callBridgeFunction(.unregister, arguments: [component]) } - + /// Send a message through the bridge to the web application /// - Parameter message: Message to send - func reply(with message: Message) { + @MainActor + func reply(with message: Message) async throws { logger.debug("bridgeWillReplyWithMessage: \(String(describing: message))") let internalMessage = InternalMessage(from: message) - callBridgeFunction(.replyWith, arguments: [internalMessage.toJSON()]) + try await callBridgeFunction(.replyWith, arguments: [internalMessage.toJSON()]) } - + // /// Convenience method to reply to a previously received message. Data will be replaced, // /// while id, component, and event will remain the same // /// - Parameter message: Message to reply to @@ -74,28 +73,27 @@ public final class Bridge: Bridgable { // let replyMessage = message.replacing(data: data) // callBridgeFunction("send", arguments: [replyMessage.toJSON()]) // } - - /// Evaluates javaScript string directly as passed in sending through the web view - func evaluate(javaScript: String, completion: CompletionHandler? = nil) { - guard let webView = webView else { - completion?(nil, BridgeError.missingWebView) - return + @discardableResult + @MainActor + func evaluate(javaScript: String) async throws -> Any? { + guard let webView else { + throw BridgeError.missingWebView } - - webView.evaluateJavaScript(javaScript) { result, error in - if let error = error { - logger.error("Error evaluating JavaScript: \(error)") - } - - completion?(result, error) + + do { + return try await webView.evaluateJavaScriptAsync(javaScript) + } catch { + logger.error("Error evaluating JavaScript: \(error)") + throw error } } - + /// Evaluates a JavaScript function with optional arguments by encoding the arguments /// Function should not include the parens /// Usage: evaluate(function: "console.log", arguments: ["test"]) - func evaluate(function: String, arguments: [Any] = [], completion: CompletionHandler? = nil) { - evaluate(javaScript: JavaScript(functionName: function, arguments: arguments), completion: completion) + @MainActor + func evaluate(function: String, arguments: [Any] = []) async throws -> Any? { + try await evaluate(javaScript: JavaScript(functionName: function, arguments: arguments).toString()) } static func initialize(_ bridge: Bridge) { @@ -106,23 +104,24 @@ public final class Bridge: Bridgable { static func getBridgeFor(_ webView: WKWebView) -> Bridge? { return instances.first { $0.webView == webView } } - + // MARK: Private private static var instances: [Bridge] = [] /// This needs to match whatever the JavaScript file uses private let bridgeGlobal = "window.nativeBridge" - + /// The webkit.messageHandlers name private let scriptHandlerName = "strada" - - private func callBridgeFunction(_ function: JavaScriptBridgeFunction, arguments: [Any]) { + + @MainActor + private func callBridgeFunction(_ function: JavaScriptBridgeFunction, arguments: [Any]) async throws { let js = JavaScript(object: bridgeGlobal, functionName: function.rawValue, arguments: arguments) - evaluate(javaScript: js) + try await evaluate(javaScript: js) } // MARK: - Configuration - + /// Configure the bridge in the provided web view private func loadIntoWebView() { guard let configuration = webView?.configuration else { return } @@ -131,17 +130,18 @@ public final class Bridge: Bridgable { if let userScript = makeUserScript() { configuration.userContentController.addUserScript(userScript) } - + let scriptMessageHandler = ScriptMessageHandler(delegate: self) configuration.userContentController.add(scriptMessageHandler, name: scriptHandlerName) } private func makeUserScript() -> WKUserScript? { guard - let path = PathLoader().pathFor(name: "strada", fileType: "js") else { - return nil + let path = PathLoader().pathFor(name: "strada", fileType: "js") + else { + return nil } - + do { let source = try String(contentsOfFile: path) return WKUserScript(source: source, injectionTime: .atDocumentStart, forMainFrameOnly: true) @@ -150,18 +150,20 @@ public final class Bridge: Bridgable { return nil } } - + // MARK: - JavaScript Evaluation - - private func evaluate(javaScript: JavaScript, completion: CompletionHandler? = nil) { + + @discardableResult + @MainActor + private func evaluate(javaScript: JavaScript) async throws -> Any? { do { - evaluate(javaScript: try javaScript.toString(), completion: completion) + return try await evaluate(javaScript: javaScript.toString()) } catch { logger.error("Error evaluating JavaScript: \(String(describing: javaScript)), error: \(error)") - completion?(nil, error) + throw error } } - + private enum JavaScriptBridgeFunction: String { case register case unregister @@ -170,18 +172,37 @@ public final class Bridge: Bridgable { } extension Bridge: ScriptMessageHandlerDelegate { + @MainActor func scriptMessageHandlerDidReceiveMessage(_ scriptMessage: WKScriptMessage) { - if let event = scriptMessage.body as? String, - event == "ready" { + if let event = scriptMessage.body as? String, event == "ready" { delegate?.bridgeDidInitialize() return } - + if let message = InternalMessage(scriptMessage: scriptMessage) { delegate?.bridgeDidReceiveMessage(message.toMessage()) return } - + logger.warning("Unhandled message received: \(String(describing: scriptMessage.body))") } } + +private extension WKWebView { + /// NOTE: The async version crashes the app with `Fatal error: Unexpectedly found nil while implicitly unwrapping an Optional value` + /// in case the function doesn't return anything. + /// This is a workaround. See https://forums.developer.apple.com/forums/thread/701553 for more details. + @discardableResult + @MainActor + func evaluateJavaScriptAsync(_ javaScriptString: String) async throws -> Any? { + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + evaluateJavaScript(javaScriptString) { data, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: data) + } + } + } + } +} diff --git a/Source/BridgeComponent.swift b/Source/BridgeComponent.swift index de9343e..1348add 100644 --- a/Source/BridgeComponent.swift +++ b/Source/BridgeComponent.swift @@ -1,5 +1,6 @@ import Foundation +@MainActor protocol BridgingComponent: AnyObject { static var name: String { get } var delegate: BridgingDelegate { get } @@ -22,13 +23,16 @@ protocol BridgingComponent: AnyObject { func viewDidDisappear() } +@MainActor open class BridgeComponent: BridgingComponent { + public typealias ReplyCompletionHandler = (Result) -> Void + /// A unique name representing the `BridgeComponent` type. /// /// Subclasses must provide their own implementation of this property. /// /// - Note: This property is used for identifying the component. - open class var name: String { + nonisolated open class var name: String { fatalError("BridgeComponent subclass must provide a unique 'name'") } @@ -50,10 +54,27 @@ open class BridgeComponent: BridgingComponent { /// /// - Parameter message: The message to be replied with. /// - Returns: `true` if the reply was successful, `false` if the bridge is not available. - public func reply(with message: Message) -> Bool { - return delegate.reply(with: message) + public func reply(with message: Message) async throws -> Bool { + try await delegate.reply(with: message) } - + + /// Replies to the web with a received message, optionally replacing its `event` or `jsonData`. + /// + /// - Parameters: + /// - message: The message to be replied with. + /// - completion: An optional completion handler to be called when the reply attempt completes. + /// It includes a result indicating whether the reply was successful or not. + public func reply(with message: Message, completion: ReplyCompletionHandler? = nil) { + Task { + do { + let result = try await delegate.reply(with: message) + completion?(.success((result))) + } catch { + completion?(.failure(error)) + } + } + } + @discardableResult /// Replies to the web with the last received message for a given `event` with its original `jsonData`. /// @@ -61,15 +82,34 @@ open class BridgeComponent: BridgingComponent { /// /// - Parameter event: The `event` for which a reply should be sent. /// - Returns: `true` if the reply was successful, `false` if the event message was not received. - public func reply(to event: String) -> Bool { + public func reply(to event: String) async throws -> Bool { guard let message = receivedMessage(for: event) else { logger.warning("bridgeMessageFailedToReply: message for event \(event) was not received") return false } - return reply(with: message) + return try await reply(with: message) } - + + /// Replies to the web with the last received message for a given `event` with its original `jsonData`. + /// + /// NOTE: If a message has not been received for the given `event`, the reply will be ignored. + /// + /// - Parameters: + /// - event: The `event` for which a reply should be sent. + /// - completion: An optional completion handler to be called when the reply attempt completes. + /// It includes a result indicating whether the reply was successful or not. + public func reply(to event: String, completion: ReplyCompletionHandler? = nil) { + Task { + do { + let result = try await reply(to: event) + completion?(.success((result))) + } catch { + completion?(.failure(error)) + } + } + } + @discardableResult /// Replies to the web with the last received message for a given `event`, replacing its `jsonData`. /// @@ -79,20 +119,39 @@ open class BridgeComponent: BridgingComponent { /// - event: The `event` for which a reply should be sent. /// - jsonData: The `jsonData` to be included in the reply message. /// - Returns: `true` if the reply was successful, `false` if the event message was not received. - public func reply(to event: String, with jsonData: String) -> Bool { + public func reply(to event: String, with jsonData: String) async throws -> Bool { guard let message = receivedMessage(for: event) else { logger.warning("bridgeMessageFailedToReply: message for event \(event) was not received") return false } let messageReply = message.replacing(jsonData: jsonData) - - return reply(with: messageReply) + return try await reply(with: messageReply) + } + + /// Replies to the web with the last received message for a given `event`, replacing its `jsonData`. + /// + /// NOTE: If a message has not been received for the given `event`, the reply will be ignored. + /// + /// - Parameters: + /// - event: The `event` for which a reply should be sent. + /// - jsonData: The `jsonData` to be included in the reply message. + /// - completion: An optional completion handler to be called when the reply attempt completes. + /// It includes a result indicating whether the reply was successful or not. + public func reply(to event: String, with jsonData: String, completion: ReplyCompletionHandler? = nil) { + Task { + do { + let result = try await reply(to: event, with: jsonData) + completion?(.success((result))) + } catch { + completion?(.failure(error)) + } + } } @discardableResult /// Replies to the web with the last received message for a given `event`, replacing its `jsonData` - /// with the provided `Encodable` object. + /// with the provided `Encodable` object. /// /// NOTE: If a message has not been received for the given `event`, the reply will be ignored. /// @@ -100,15 +159,35 @@ open class BridgeComponent: BridgingComponent { /// - event: The `event` for which a reply should be sent. /// - data: An instance conforming to `Encodable` to be included as `jsonData` in the reply message. /// - Returns: `true` if the reply was successful, `false` if the event message was not received. - public func reply(to event: String, with data: T) -> Bool { + public func reply(to event: String, with data: T) async throws -> Bool { guard let message = receivedMessage(for: event) else { logger.warning("bridgeMessageFailedToReply: message for event \(event) was not received") return false } let messageReply = message.replacing(data: data) - - return reply(with: messageReply) + return try await reply(with: messageReply) + } + + /// Replies to the web with the last received message for a given `event`, replacing its `jsonData` + /// with the provided `Encodable` object. + /// + /// NOTE: If a message has not been received for the given `event`, the reply will be ignored. + /// + /// - Parameters: + /// - event: The `event` for which a reply should be sent. + /// - data: An instance conforming to `Encodable` to be included as `jsonData` in the reply message. + /// - completion: An optional completion handler to be called when the reply attempt completes. + /// It includes a result indicating whether the reply was successful or not. + public func reply(to event: String, with data: T, completion: ReplyCompletionHandler? = nil) { + Task { + do { + let result = try await reply(to: event, with: data) + completion?(.success((result))) + } catch { + completion?(.failure(error)) + } + } } /// Returns the last received message for a given `event`, if available. @@ -198,3 +277,4 @@ open class BridgeComponent: BridgingComponent { private var receivedMessages = [String: Message]() } + diff --git a/Source/BridgeDelegate.swift b/Source/BridgeDelegate.swift index 97c861e..2743a86 100644 --- a/Source/BridgeDelegate.swift +++ b/Source/BridgeDelegate.swift @@ -3,6 +3,7 @@ import WebKit public protocol BridgeDestination: AnyObject {} +@MainActor public protocol BridgingDelegate: AnyObject { var location: String { get } var destination: BridgeDestination { get } @@ -10,8 +11,8 @@ public protocol BridgingDelegate: AnyObject { func webViewDidBecomeActive(_ webView: WKWebView) func webViewDidBecomeDeactivated() - func reply(with message: Message) -> Bool - + func reply(with message: Message) async throws -> Bool + func onViewDidLoad() func onViewWillAppear() func onViewDidAppear() @@ -24,6 +25,7 @@ public protocol BridgingDelegate: AnyObject { func bridgeDidReceiveMessage(_ message: Message) -> Bool } +@MainActor public final class BridgeDelegate: BridgingDelegate { public let location: String public unowned let destination: BridgeDestination @@ -60,13 +62,13 @@ public final class BridgeDelegate: BridgingDelegate { /// /// - Parameter message: The message to be replied with. /// - Returns: `true` if the reply was successful, `false` if the bridge is not available. - public func reply(with message: Message) -> Bool { + public func reply(with message: Message) async throws -> Bool { guard let bridge else { logger.warning("bridgeMessageFailedToReply: bridge is not available") return false } - bridge.reply(with: message) + try await bridge.reply(with: message) return true } @@ -111,7 +113,13 @@ public final class BridgeDelegate: BridgingDelegate { public func bridgeDidInitialize() { let componentNames = componentTypes.map { $0.name } - bridge?.register(components: componentNames) + Task { + do { + try await bridge?.register(components: componentNames) + } catch { + logger.error("bridgeDidFailToRegisterComponents: \(error)") + } + } } @discardableResult @@ -153,4 +161,3 @@ public final class BridgeDelegate: BridgingDelegate { return component } } - diff --git a/Source/Message.swift b/Source/Message.swift index 104eaf1..9e14e76 100644 --- a/Source/Message.swift +++ b/Source/Message.swift @@ -94,3 +94,28 @@ extension Message { } } } + +extension Message { + /// Using `Equatable`'s default implementation is bound to give us false positives + /// since two `Message`s may have semantically equal, but textually different, `jsonData`. + /// + /// For example, the following `jsonData` should be considered equal. + /// + /// ``` + /// lhs.jsonData = "{\"title\":\"Page-title\",\"subtitle\":\"Page-subtitle\",\"action_name\":\"go\"}")" + /// + /// rhs.jsonData = "{\"action_name\":\"go\",\"title\":\"Page-title\",\"subtitle\":\"Page-subtitle\"}")" + /// ``` + /// + /// - Parameters: + /// - lhs: a message + /// - rhs: another message + /// - Returns: true if they're semantically equal + public static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.id == rhs.id && + lhs.component == rhs.component && + lhs.event == rhs.event && + lhs.metadata == rhs.metadata && + lhs.jsonData.jsonObject() as? [String: AnyHashable] == rhs.jsonData.jsonObject() as? [String: AnyHashable] + } +} diff --git a/Strada.xcodeproj/project.pbxproj b/Strada.xcodeproj/project.pbxproj index 12ca970..5c3ab56 100644 --- a/Strada.xcodeproj/project.pbxproj +++ b/Strada.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 53; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -33,6 +33,7 @@ E2DB15912A7163B0001EE08C /* BridgeDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DB15902A7163B0001EE08C /* BridgeDelegate.swift */; }; E2DB15932A7282CF001EE08C /* BridgeComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DB15922A7282CF001EE08C /* BridgeComponent.swift */; }; E2DB15952A72B0A8001EE08C /* BridgeDelegateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DB15942A72B0A8001EE08C /* BridgeDelegateTests.swift */; }; + E2F4E06B2B9095BC000A3A24 /* TimeInterval+ExpectationTimeout.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F4E06A2B9095BC000A3A24 /* TimeInterval+ExpectationTimeout.swift */; }; E2FDCF982A8297DA003D27AE /* BridgeComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDCF972A8297DA003D27AE /* BridgeComponentTests.swift */; }; E2FDCF9B2A829AEE003D27AE /* BridgeSpy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDCF9A2A829AEE003D27AE /* BridgeSpy.swift */; }; E2FDCF9D2A829C6F003D27AE /* TestData.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDCF9C2A829C6F003D27AE /* TestData.swift */; }; @@ -79,6 +80,7 @@ E2DB15902A7163B0001EE08C /* BridgeDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgeDelegate.swift; sourceTree = ""; }; E2DB15922A7282CF001EE08C /* BridgeComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgeComponent.swift; sourceTree = ""; }; E2DB15942A72B0A8001EE08C /* BridgeDelegateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgeDelegateTests.swift; sourceTree = ""; }; + E2F4E06A2B9095BC000A3A24 /* TimeInterval+ExpectationTimeout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+ExpectationTimeout.swift"; sourceTree = ""; }; E2FDCF972A8297DA003D27AE /* BridgeComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgeComponentTests.swift; sourceTree = ""; }; E2FDCF9A2A829AEE003D27AE /* BridgeSpy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgeSpy.swift; sourceTree = ""; }; E2FDCF9C2A829C6F003D27AE /* TestData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestData.swift; sourceTree = ""; }; @@ -146,6 +148,7 @@ 9274F1F22229963B003E85F4 /* Tests */ = { isa = PBXGroup; children = ( + E2F4E0692B9095A5000A3A24 /* Extensions */, E227FAF12A94D48C00A645E4 /* ComponentTestExample */, E2FDCF9C2A829C6F003D27AE /* TestData.swift */, E2FDCF992A829AD5003D27AE /* Spies */, @@ -181,6 +184,14 @@ path = ComponentTestExample; sourceTree = ""; }; + E2F4E0692B9095A5000A3A24 /* Extensions */ = { + isa = PBXGroup; + children = ( + E2F4E06A2B9095BC000A3A24 /* TimeInterval+ExpectationTimeout.swift */, + ); + path = Extensions; + sourceTree = ""; + }; E2FDCF992A829AD5003D27AE /* Spies */ = { isa = PBXGroup; children = ( @@ -248,7 +259,7 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1010; - LastUpgradeCheck = 1430; + LastUpgradeCheck = 1520; ORGANIZATIONNAME = Basecamp; TargetAttributes = { 9274F1E42229963B003E85F4 = { @@ -327,6 +338,7 @@ files = ( E2DB15952A72B0A8001EE08C /* BridgeDelegateTests.swift in Sources */, E227FAF02A94D34E00A645E4 /* ComposerComponent.swift in Sources */, + E2F4E06B2B9095BC000A3A24 /* TimeInterval+ExpectationTimeout.swift in Sources */, E227FAEE2A94B35900A645E4 /* BridgeDelegateSpy.swift in Sources */, C11349C2258801F6000A6E56 /* JavaScriptTests.swift in Sources */, E227FAF32A94D57300A645E4 /* ComposerComponentTests.swift in Sources */, @@ -356,6 +368,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -391,6 +404,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -421,6 +435,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -456,6 +471,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -539,8 +555,8 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 2WNYUYRS7G; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Tests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -549,6 +565,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = dev.hotwire.strada.tests; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -558,8 +575,8 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 2WNYUYRS7G; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Tests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -568,6 +585,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = dev.hotwire.strada.tests; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; diff --git a/Strada.xcodeproj/xcshareddata/xcschemes/Strada.xcscheme b/Strada.xcodeproj/xcshareddata/xcschemes/Strada.xcscheme index a43b636..61c2ae8 100644 --- a/Strada.xcodeproj/xcshareddata/xcschemes/Strada.xcscheme +++ b/Strada.xcodeproj/xcshareddata/xcschemes/Strada.xcscheme @@ -1,6 +1,6 @@ Void)? = nil) { + + override func evaluateJavaScript(_ javaScriptString: String) async throws -> Any { lastEvaluatedJavaScript = javaScriptString - super.evaluateJavaScript(javaScriptString, completionHandler: completionHandler) + return try await super.evaluateJavaScript(javaScriptString) } } diff --git a/Tests/ComponentTestExample/ComposerComponent.swift b/Tests/ComponentTestExample/ComposerComponent.swift index a58901f..dd8165b 100644 --- a/Tests/ComponentTestExample/ComposerComponent.swift +++ b/Tests/ComponentTestExample/ComposerComponent.swift @@ -1,6 +1,6 @@ import Foundation import XCTest -@testable import Strada +import Strada final class ComposerComponent: BridgeComponent { static override var name: String { "composer" } @@ -17,7 +17,7 @@ final class ComposerComponent: BridgeComponent { } } - func selectSender(emailAddress: String) { + func selectSender(emailAddress: String) async throws { guard let message = receivedMessage(for: InboundEvent.connect.rawValue), let senders: [Sender] = message.data() else { return @@ -29,7 +29,7 @@ final class ComposerComponent: BridgeComponent { let newMessage = message.replacing(event: OutboundEvent.selectSender.rawValue, data: SelectSenderMessageData(selectedIndex: sender.index)) - reply(with: newMessage) + try await reply(with: newMessage) } func selectedSender() -> String? { diff --git a/Tests/ComponentTestExample/ComposerComponentTests.swift b/Tests/ComponentTestExample/ComposerComponentTests.swift index 4a81d09..f25289c 100644 --- a/Tests/ComponentTestExample/ComposerComponentTests.swift +++ b/Tests/ComponentTestExample/ComposerComponentTests.swift @@ -1,7 +1,8 @@ import XCTest import WebKit -@testable import Strada +import Strada +@MainActor final class ComposerComponentTests: XCTestCase { private var delegate: BridgeDelegateSpy! private var destination: AppBridgeDestination! @@ -47,29 +48,29 @@ final class ComposerComponentTests: XCTestCase { // MARK: Select sender tests - func test_selectSender_emailFound_sendsTheCorrectMessageReply() { + func test_selectSender_emailFound_sendsTheCorrectMessageReply() async throws { component.didReceive(message: connectMessage) - component.selectSender(emailAddress: "user1@37signals.com") - + try await component.selectSender(emailAddress: "user1@37signals.com") + let expectedMessage = connectMessage.replacing(event: "select-sender", jsonData: "{\"selectedIndex\":1}") XCTAssertTrue(delegate.replyWithMessageWasCalled) XCTAssertEqual(delegate.replyWithMessageArg, expectedMessage) } - func test_selectSender_emailNotFound_doesNotSendAnyMessage() { + func test_selectSender_emailNotFound_doesNotSendAnyMessage() async throws { component.didReceive(message: connectMessage) - component.selectSender(emailAddress: "test@37signals.com") - + try await component.selectSender(emailAddress: "test@37signals.com") + XCTAssertFalse(delegate.replyWithMessageWasCalled) XCTAssertNil(delegate.replyWithMessageArg) } - func test_selectSender_beforeConnectMessage_doesNotSendAnyMessage() { - component.selectSender(emailAddress: "user1@37signals.com") - + func test_selectSender_beforeConnectMessage_doesNotSendAnyMessage() async throws { + try await component.selectSender(emailAddress: "user1@37signals.com") + XCTAssertFalse(delegate.replyWithMessageWasCalled) XCTAssertNil(delegate.replyWithMessageArg) } diff --git a/Tests/Extensions/TimeInterval+ExpectationTimeout.swift b/Tests/Extensions/TimeInterval+ExpectationTimeout.swift new file mode 100644 index 0000000..101e7ea --- /dev/null +++ b/Tests/Extensions/TimeInterval+ExpectationTimeout.swift @@ -0,0 +1,5 @@ +import Foundation + +extension TimeInterval { + static let expectationTimeout: TimeInterval = 5 +} diff --git a/Tests/MessageTests.swift b/Tests/MessageTests.swift index 54fb786..06dc965 100644 --- a/Tests/MessageTests.swift +++ b/Tests/MessageTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import Strada +import Strada class MessageTests: XCTestCase { diff --git a/Tests/Spies/BridgeComponentSpy.swift b/Tests/Spies/BridgeComponentSpy.swift index bf3b379..bf65282 100644 --- a/Tests/Spies/BridgeComponentSpy.swift +++ b/Tests/Spies/BridgeComponentSpy.swift @@ -1,5 +1,5 @@ import Foundation -@testable import Strada +import Strada final class BridgeComponentSpy: BridgeComponent { static override var name: String { "two" } diff --git a/Tests/Spies/BridgeDelegateSpy.swift b/Tests/Spies/BridgeDelegateSpy.swift index d3c0690..d8029f4 100644 --- a/Tests/Spies/BridgeDelegateSpy.swift +++ b/Tests/Spies/BridgeDelegateSpy.swift @@ -1,6 +1,6 @@ import Foundation import WebKit -@testable import Strada +import Strada final class BridgeDelegateSpy: BridgingDelegate { let location: String = "" @@ -18,7 +18,7 @@ final class BridgeDelegateSpy: BridgingDelegate { } - func reply(with message: Message) -> Bool { + func reply(with message: Message) async throws -> Bool { replyWithMessageWasCalled = true replyWithMessageArg = message diff --git a/Tests/Spies/BridgeSpy.swift b/Tests/Spies/BridgeSpy.swift index 9771e34..a8f4371 100644 --- a/Tests/Spies/BridgeSpy.swift +++ b/Tests/Spies/BridgeSpy.swift @@ -9,7 +9,15 @@ final class BridgeSpy: Bridgable { var registerComponentWasCalled = false var registerComponentArg: String? = nil - var registerComponentsWasCalled = false + var registerComponentsWasCalled = false { + didSet { + if registerComponentsWasCalled { + registerComponentsContinuation?.resume() + registerComponentsContinuation = nil + } + } + } + var registerComponentsContinuation: CheckedContinuation? var registerComponentsArg: [String]? = nil var unregisterComponentWasCalled = false diff --git a/Tests/UserAgentTests.swift b/Tests/UserAgentTests.swift index 6a60016..29696b3 100644 --- a/Tests/UserAgentTests.swift +++ b/Tests/UserAgentTests.swift @@ -1,6 +1,6 @@ import Foundation import XCTest -@testable import Strada +import Strada class UserAgentTests: XCTestCase { func testUserAgentSubstringWithTwoComponents() {