diff --git a/ios/core/Sources/Types/Hooks/Hook.swift b/ios/core/Sources/Types/Hooks/Hook.swift index 618117620..6510ae7a1 100644 --- a/ios/core/Sources/Types/Hooks/Hook.swift +++ b/ios/core/Sources/Types/Hooks/Hook.swift @@ -1,6 +1,6 @@ // // Hook.swift -// +// // // Created by Borawski, Harris on 2/12/20. // @@ -41,7 +41,7 @@ public class Hook: BaseJSHook where T: CreatedFromJSValue { we receive the event in the native runtime - parameters: - - hook: A function to run when the JS hook is fired + - hook: A function to run when the JS hook is fired */ public func tap(_ hook: @escaping (T) -> Void) { let tapMethod: @convention(block) (JSValue?) -> Void = { value in @@ -66,7 +66,7 @@ public class Hook2: BaseJSHook where T: CreatedFromJSValue, U: CreatedFrom we receive the event in the native runtime - parameters: - - hook: A function to run when the JS hook is fired + - hook: A function to run when the JS hook is fired */ public func tap(_ hook: @escaping (T, U) -> Void) { let tapMethod: @convention(block) (JSValue?, JSValue?) -> Void = { value, value2 in @@ -93,7 +93,7 @@ public class HookDecode: BaseJSHook where T: Decodable { we receive the event in the native runtime - parameters: - - hook: A function to run when the JS hook is fired + - hook: A function to run when the JS hook is fired */ public func tap(_ hook: @escaping (T) -> Void) { let tapMethod: @convention(block) (JSValue?) -> Void = { value in @@ -118,7 +118,7 @@ public class Hook2Decode: BaseJSHook where T: Decodable, U: Decodable { we receive the event in the native runtime - parameters: - - hook: A function to run when the JS hook is fired + - hook: A function to run when the JS hook is fired */ public func tap(_ hook: @escaping (T, U) -> Void) { let tapMethod: @convention(block) (JSValue?, JSValue?) -> Void = { value, value2 in @@ -151,28 +151,73 @@ public class AsyncHook: BaseJSHook where T: CreatedFromJSValue { we receive the event in the native runtime - parameters: - - hook: A function to run when the JS hook is fired + - hook: A function to run when the JS hook is fired */ public func tap(_ hook: @escaping AsyncHookHandler) { let tapMethod: @convention(block) (JSValue?) -> JSValue = { value in - guard - let val = value, - let hookValue = T.createInstance(value: val) as? T - else { return JSValue() } - - let promise = - JSUtilities.createPromise(context: self.context, handler: { (resolve, _) in - Task { - let result = try await hook(hookValue) - DispatchQueue.main.async { - resolve(result as Any) - } - } - }) - - return promise ?? JSValue() - } - - self.hook.invokeMethod("tap", withArguments: [name, JSValue(object: tapMethod, in: context) as Any]) - } + guard + let val = value, + let hookValue = T.createInstance(value: val) as? T + else { return JSValue() } + + let promise = + JSUtilities.createPromise(context: self.context, handler: { (resolve, _) in + Task { + let result = try await hook(hookValue) + DispatchQueue.main.async { + resolve(result as Any) + } + } + }) + + return promise ?? JSValue() + } + + self.hook.invokeMethod("tap", withArguments: [name, JSValue(object: tapMethod, in: context) as Any]) + } +} + +/** + This class represents an object in the JS runtime that can be tapped into + to receive JS events that has 2 parameters and + returns a promise that resolves when the asynchronous task is completed + */ +public class AsyncHook2: BaseJSHook where T: CreatedFromJSValue, U: CreatedFromJSValue { + private var handler: AsyncHookHandler? + + public typealias AsyncHookHandler = (T, U) async throws -> JSValue? + + /** + Attach a closure to the hook, so when the hook is fired in the JS runtime + we receive the event in the native runtime + + - parameters: + - hook: A function to run when the JS hook is fired + */ + public func tap(_ hook: @escaping AsyncHookHandler) { + let tapMethod: @convention(block) (JSValue?,JSValue?) -> JSValue = { value, value2 in + guard + let val = value, + let val2 = value2, + let hookValue = T.createInstance(value: val) as? T, + let hookValue2 = U.createInstance(value: val2) as? U + else { return JSValue() } + + + let promise = + JSUtilities.createPromise(context: self.context, handler: { (resolve, _) in + Task { + let result = try await hook(hookValue, hookValue2) + DispatchQueue.main.async { + resolve(result as Any) + } + } + }) + + return promise ?? JSValue() + } + + self.hook.invokeMethod("tap", withArguments: [name, JSValue(object: tapMethod, in: context) as Any]) + } } + diff --git a/jvm/core/src/main/kotlin/com/intuit/playerui/core/bridge/Promise.kt b/jvm/core/src/main/kotlin/com/intuit/playerui/core/bridge/Promise.kt index b23e2441c..3ad7f659c 100644 --- a/jvm/core/src/main/kotlin/com/intuit/playerui/core/bridge/Promise.kt +++ b/jvm/core/src/main/kotlin/com/intuit/playerui/core/bridge/Promise.kt @@ -178,7 +178,7 @@ public val Runtime<*>.Promise: Promise.Api get() = getObject("Promise")?.let { p } ?: throw PlayerRuntimeException("'Promise' not defined in runtime") /** Helper to bridge complex [Promise] logic with the JS promise constructor */ -public fun Runtime<*>.Promise(block: suspend ((T) -> Unit, (Throwable) -> Unit) -> Unit): Promise { +public fun Runtime<*>.Promise(block: suspend ((T) -> Unit, (Throwable) -> Unit) -> Unit): Promise { val key = "promiseHandler_${UUID.randomUUID().toString().replace("-", "")}" add(key) { resolve: Invokable, reject: Invokable -> runtime.scope.launch { diff --git a/jvm/core/src/main/kotlin/com/intuit/playerui/core/bridge/hooks/NodeAsyncParallelBailHook2.kt b/jvm/core/src/main/kotlin/com/intuit/playerui/core/bridge/hooks/NodeAsyncParallelBailHook2.kt new file mode 100644 index 000000000..59636c555 --- /dev/null +++ b/jvm/core/src/main/kotlin/com/intuit/playerui/core/bridge/hooks/NodeAsyncParallelBailHook2.kt @@ -0,0 +1,40 @@ +package com.intuit.playerui.core.bridge.hooks + +import com.intuit.hooks.AsyncParallelBailHook +import com.intuit.hooks.BailResult +import com.intuit.hooks.HookContext +import com.intuit.playerui.core.bridge.Node +import com.intuit.playerui.core.bridge.serialization.serializers.NodeWrapperSerializer +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable + +@OptIn(ExperimentalCoroutinesApi::class) +@Serializable(with = NodeAsyncParallelBailHook2.Serializer::class) +public class NodeAsyncParallelBailHook2( + override val node: Node, + serializer1: KSerializer, + serializer2: KSerializer, +) : AsyncParallelBailHook BailResult, R>(), AsyncNodeHook { + + init { + init(serializer1, serializer2) + } + + override suspend fun callAsync(context: HookContext, serializedArgs: Array): R { + require(serializedArgs.size == 2) { "Expected exactly two arguments, but got ${serializedArgs.size}" } + val (p1, p2) = serializedArgs + val result = call(10) { f, _ -> + f(context, p1 as T1, p2 as T2) + } as R + return result + } + + internal class Serializer( + private val serializer1: KSerializer, + private val serializer2: KSerializer, + `_`: KSerializer, + ) : NodeWrapperSerializer>({ + NodeAsyncParallelBailHook2(it, serializer1, serializer2) + }) +} diff --git a/jvm/core/src/main/kotlin/com/intuit/playerui/core/bridge/hooks/NodeHook.kt b/jvm/core/src/main/kotlin/com/intuit/playerui/core/bridge/hooks/NodeHook.kt index 0d344e04c..26d940af4 100644 --- a/jvm/core/src/main/kotlin/com/intuit/playerui/core/bridge/hooks/NodeHook.kt +++ b/jvm/core/src/main/kotlin/com/intuit/playerui/core/bridge/hooks/NodeHook.kt @@ -39,7 +39,7 @@ internal interface NodeHook : NodeWrapper { fun call(context: HookContext, serializedArgs: Array): R } -internal interface AsyncNodeHook : NodeHook { +internal interface AsyncNodeHook : NodeHook { override fun call(context: HookContext, serializedArgs: Array): Promise = node.runtime.Promise { resolve, reject -> val result = callAsync(context, serializedArgs) resolve(result) diff --git a/plugins/async-node/ios/Sources/AsyncNodePlugin.swift b/plugins/async-node/ios/Sources/AsyncNodePlugin.swift index a77a12458..539f3dc22 100644 --- a/plugins/async-node/ios/Sources/AsyncNodePlugin.swift +++ b/plugins/async-node/ios/Sources/AsyncNodePlugin.swift @@ -7,16 +7,59 @@ import Foundation import JavaScriptCore - -#if SWIFT_PACKAGE import PlayerUI -#endif -public typealias AsyncHookHandler = (JSValue) async throws -> AsyncNodeHandlerType +public typealias AsyncHookHandler = (JSValue, JSValue) async throws -> AsyncNodeHandlerType public enum AsyncNodeHandlerType { case multiNode([ReplacementNode]) case singleNode(ReplacementNode) + case emptyNode +} + +/// Extension for `ReplacementNode` to convert it to a `JSValue` in a given `JSContext`. +public extension ReplacementNode { + /// Converts the `ReplacementNode` to a `JSValue` in the provided `JSContext`. + /// + /// - Parameter context: The `JSContext` in which the `JSValue` will be created. + /// - Returns: A `JSValue` representing the `ReplacementNode`, or `nil` if the conversion fails. + func toJSValue(context: JSContext) -> JSValue? { + switch self { + case .encodable(let encodable): + let encoder = JSONEncoder() + do { + let res = try encoder.encode(encodable) + return context.evaluateScript("(\(String(data: res, encoding: .utf8) ?? ""))") as JSValue + } catch { + return nil + } + case .concrete(let jsValue): + return jsValue + } + } +} + +/// Extension for `AsyncNodeHandlerType` to convert it to a `JSValue` in a given `JSContext`. +public extension AsyncNodeHandlerType { + /// Converts the `AsyncNodeHandlerType` to a `JSValue` in the provided `JSContext`. + /// + /// - Parameter context: The `JSContext` in which the `JSValue` will be created. + /// - Returns: A `JSValue` representing the `AsyncNodeHandlerType`, or `nil` if the conversion fails. + func handlerTypeToJSValue(context: JSContext) -> JSValue? { + switch self { + case .multiNode(let replacementNodes): + let jsValueArray = replacementNodes.compactMap { + $0.toJSValue(context: context) + } + return context.objectForKeyedSubscript("Array").objectForKeyedSubscript("from").call(withArguments: [jsValueArray]) + + case .singleNode(let replacementNode): + return replacementNode.toJSValue(context: context) + + case .emptyNode: + return nil + } + } } /** @@ -32,8 +75,8 @@ public class AsyncNodePlugin: JSBasePlugin, NativePlugin { /** Constructs the AsyncNodePlugin - Parameters: - - handler: The callback that is used to tap into the core `onAsyncNode` hook - exposed to users of the plugin allowing them to supply the replacement node used in the tap callback + - handler: The callback that is used to tap into the core `onAsyncNode` hook + exposed to users of the plugin allowing them to supply the replacement node used in the tap callback */ public convenience init(plugins: [JSBasePlugin] = [AsyncNodePluginPlugin()], _ handler: @escaping AsyncHookHandler) { @@ -46,53 +89,17 @@ public class AsyncNodePlugin: JSBasePlugin, NativePlugin { super.setup(context: context) if let pluginRef = pluginRef { - self.hooks = AsyncNodeHook(onAsyncNode: AsyncHook(baseValue: pluginRef, name: "onAsyncNode")) + self.hooks = AsyncNodeHook(onAsyncNode: AsyncHook2(baseValue: pluginRef, name: "onAsyncNode")) } - hooks?.onAsyncNode.tap({ node in + hooks?.onAsyncNode.tap({ node, callback in // hook value is the original node guard let asyncHookHandler = self.asyncHookHandler else { return JSValue() } - let replacementNode = try await (asyncHookHandler)(node) - - switch replacementNode { - case .multiNode(let replacementNodes): - let jsValueArray = replacementNodes.compactMap({ node in - switch node { - case .concrete(let jsValue): - return jsValue - case .encodable(let encodable): - let encoder = JSONEncoder() - do { - let res = try encoder.encode(encodable) - return context.evaluateScript("(\(String(data: res, encoding: .utf8) ?? ""))") as JSValue - } catch { - return nil - } - } - }) - - return context.objectForKeyedSubscript("Array").objectForKeyedSubscript("from").call(withArguments: [jsValueArray]) - - case .singleNode(let replacementNode): - switch replacementNode { - - case .encodable(let encodable): - let encoder = JSONEncoder() - do { - let res = try encoder.encode(encodable) - return context.evaluateScript("(\(String(data: res, encoding: .utf8) ?? ""))") as JSValue - } catch { - break - } - case .concrete(let jsValue): - return jsValue - } - } - - return nil + let replacementNode = try await (asyncHookHandler)(node, callback) + return replacementNode.handlerTypeToJSValue(context:context) ?? JSValue() }) } @@ -102,29 +109,29 @@ public class AsyncNodePlugin: JSBasePlugin, NativePlugin { - returns: An array of arguments to construct the plugin */ override public func getArguments() -> [Any] { - for plugin in plugins { - plugin.context = self.context - } + for plugin in plugins { + plugin.context = self.context + } - return [["plugins": plugins.map { $0.pluginRef }]] - } + return [["plugins": plugins.map { $0.pluginRef }]] + } override open func getUrlForFile(fileName: String) -> URL? { - #if SWIFT_PACKAGE +#if SWIFT_PACKAGE ResourceUtilities.urlForFile(name: fileName, ext: "js", bundle: Bundle.module) - #else +#else ResourceUtilities.urlForFile( name: fileName, ext: "js", bundle: Bundle(for: AsyncNodePlugin.self), pathComponent: "PlayerUIAsyncNodePlugin.bundle" ) - #endif +#endif } } public struct AsyncNodeHook { - public let onAsyncNode: AsyncHook + public let onAsyncNode: AsyncHook2 } /** @@ -165,7 +172,7 @@ public struct AssetPlaceholderNode: Encodable { public struct AsyncNode: Codable, Equatable { var id: String var async: Bool = true - + public init(id: String) { self.id = id } @@ -180,15 +187,15 @@ public class AsyncNodePluginPlugin: JSBasePlugin { } override open func getUrlForFile(fileName: String) -> URL? { - #if SWIFT_PACKAGE +#if SWIFT_PACKAGE ResourceUtilities.urlForFile(name: fileName, ext: "js", bundle: Bundle.module) - #else +#else ResourceUtilities.urlForFile( name: fileName, ext: "js", bundle: Bundle(for: AsyncNodePluginPlugin.self), pathComponent: "PlayerUIAsyncNodePlugin.bundle" ) - #endif +#endif } } diff --git a/plugins/async-node/ios/Tests/AsynNodePluginTests.swift b/plugins/async-node/ios/Tests/AsynNodePluginTests.swift index ef7da334d..b6fddb8b7 100644 --- a/plugins/async-node/ios/Tests/AsynNodePluginTests.swift +++ b/plugins/async-node/ios/Tests/AsynNodePluginTests.swift @@ -16,37 +16,37 @@ import JavaScriptCore @testable import PlayerUIAsyncNodePlugin class AsyncNodePluginTests: XCTestCase { - + func testConstructionAsyncPlugin() { let context = JSContext() - let plugin = AsyncNodePlugin { _ in + let plugin = AsyncNodePlugin { _,_ in return .singleNode(.concrete(JSValue())) } plugin.context = context - + XCTAssertNotNil(plugin.pluginRef) } - + func testConstructionAsyncPluginPlugin() { let context = JSContext() - + let plugin = AsyncNodePluginPlugin() plugin.context = context - + XCTAssertNotNil(plugin.pluginRef) } - - + + func testAsyncNodeWithAnotherAsyncNodeDelay() { let handlerExpectation = XCTestExpectation(description: "first data did not change") - + let context = JSContext() - + var count = 0 - - let resolveHandler: AsyncHookHandler = { _ in + + let resolveHandler: AsyncHookHandler = { _,_ in handlerExpectation.fulfill() - + sleep(3) return .singleNode(.concrete(context?.evaluateScript(""" ([ @@ -54,25 +54,25 @@ class AsyncNodePluginTests: XCTestCase { ]) """) ?? JSValue())) } - + let asyncNodePluginPlugin = AsyncNodePluginPlugin() let plugin = AsyncNodePlugin(plugins: [asyncNodePluginPlugin], resolveHandler) - + plugin.context = context - + XCTAssertNotNil(asyncNodePluginPlugin.context) - + let player = HeadlessPlayerImpl(plugins: [ReferenceAssetsPlugin(), plugin], context: context ?? JSContext()) - + let textExpectation = XCTestExpectation(description: "newText1 found") - + var expectedMultiNode1Text: String = "" - + player.hooks?.viewController.tap({ (viewController) in viewController.hooks.view.tap { (view) in view.hooks.onUpdate.tap { val in count += 1 - + if count == 2 { let newText1 = val .objectForKeyedSubscript("values") @@ -80,31 +80,31 @@ class AsyncNodePluginTests: XCTestCase { .objectForKeyedSubscript("asset") .objectForKeyedSubscript("value") guard let textString1 = newText1?.toString() else { return XCTFail("newText1 was not a string") } - + expectedMultiNode1Text = textString1 textExpectation.fulfill() } } } }) - + player.start(flow: .asyncNodeJson, completion: {_ in}) - + wait(for: [handlerExpectation, textExpectation], timeout: 5) - + XCTAssert(count == 2) XCTAssertEqual(expectedMultiNode1Text, "new node from the hook 1") } - + func testReplaceAsyncNodeWithChainedMultiNodes() { let handlerExpectation = XCTestExpectation(description: "first data did not change") - + let context = JSContext() var count = 0 - - let resolve: AsyncHookHandler = { _ in + + let resolve: AsyncHookHandler = { _,_ in handlerExpectation.fulfill() - + if count == 1 { return .multiNode([ ReplacementNode.concrete(context?.evaluateScript(""" @@ -122,32 +122,32 @@ class AsyncNodePluginTests: XCTestCase { AssetPlaceholderNode(asset: PlaceholderNode(id: "text", type: "text", value: "3rd value in the multinode")) )) } - + return .singleNode(ReplacementNode.concrete(context?.evaluateScript("") ?? JSValue())) } - + let asyncNodePluginPlugin = AsyncNodePluginPlugin() let plugin = AsyncNodePlugin(plugins: [asyncNodePluginPlugin], resolve) - + plugin.context = context - + XCTAssertNotNil(asyncNodePluginPlugin.context) - + let player = HeadlessPlayerImpl(plugins: [ReferenceAssetsPlugin(), plugin], context: context ?? JSContext()) - + let textExpectation = XCTestExpectation(description: "newText found") let textExpectation2 = XCTestExpectation(description: "newText found") let textExpectation3 = XCTestExpectation(description: "newText found") - + var expectedMultiNode1Text: String = "" var expectedMultiNode2Text: String = "" var expectedMultiNode3Text: String = "" - + player.hooks?.viewController.tap({ (viewController) in viewController.hooks.view.tap { (view) in view.hooks.onUpdate.tap { val in count += 1 - + if count == 2 { let newText1 = val .objectForKeyedSubscript("values") @@ -155,11 +155,11 @@ class AsyncNodePluginTests: XCTestCase { .objectForKeyedSubscript("asset") .objectForKeyedSubscript("value") guard let textString1 = newText1?.toString() else { return XCTFail("newText was not a string") } - + expectedMultiNode1Text = textString1 textExpectation.fulfill() } - + if count == 3 { let newText2 = val .objectForKeyedSubscript("values") @@ -167,12 +167,12 @@ class AsyncNodePluginTests: XCTestCase { .objectForKeyedSubscript("asset") .objectForKeyedSubscript("value") guard let textString2 = newText2?.toString() else { return XCTFail("newText was not a string") } - + expectedMultiNode2Text = textString2 - + textExpectation2.fulfill() } - + if count == 4 { let newText3 = val .objectForKeyedSubscript("values") @@ -180,42 +180,42 @@ class AsyncNodePluginTests: XCTestCase { .objectForKeyedSubscript("asset") .objectForKeyedSubscript("value") guard let textString3 = newText3?.toString() else { return XCTFail("newText was not a string") } - + expectedMultiNode3Text = textString3 textExpectation3.fulfill() } } } }) - + player.start(flow: .asyncNodeJson, completion: { _ in}) - + wait(for: [handlerExpectation, textExpectation], timeout: 5) - + XCTAssert(count == 2) XCTAssertEqual(expectedMultiNode1Text, "1st value in the multinode") - + wait(for: [textExpectation2], timeout: 6) - + XCTAssert(count == 3) XCTAssertEqual(expectedMultiNode2Text, "2nd value in the multinode") - + wait(for: [textExpectation3], timeout: 7) - + XCTAssert(count == 4) XCTAssertEqual(expectedMultiNode3Text, "3rd value in the multinode") } - + func testAsyncNodeReplacementWithChainedMultiNodesSinglular() { let handlerExpectation = XCTestExpectation(description: "first data did not change") - + let context = JSContext() - + var count = 0 - - let resolve: AsyncHookHandler = { _ in + + let resolve: AsyncHookHandler = { _,_ in handlerExpectation.fulfill() - + if count == 1 { return .multiNode([ ReplacementNode.encodable(AssetPlaceholderNode(asset: PlaceholderNode(id: "text", type: "text", value: "new node from the hook 1"))), @@ -228,30 +228,30 @@ class AsyncNodePluginTests: XCTestCase { ) """) ?? JSValue())) } - + return .singleNode(ReplacementNode.concrete(context?.evaluateScript("") ?? JSValue())) } - + let asyncNodePluginPlugin = AsyncNodePluginPlugin() let plugin = AsyncNodePlugin(plugins: [asyncNodePluginPlugin], resolve) - + plugin.context = context - + XCTAssertNotNil(asyncNodePluginPlugin.context) - + let player = HeadlessPlayerImpl(plugins: [ReferenceAssetsPlugin(), plugin], context: context ?? JSContext()) - + let textExpectation = XCTestExpectation(description: "newText found") let textExpectation2 = XCTestExpectation(description: "newText found") - + var expectedMultiNode1Text: String = "" var expectedMultiNode2Text: String = "" - + player.hooks?.viewController.tap({ (viewController) in viewController.hooks.view.tap { (view) in view.hooks.onUpdate.tap { val in count += 1 - + if count == 2 { let newText1 = val .objectForKeyedSubscript("values") @@ -259,11 +259,11 @@ class AsyncNodePluginTests: XCTestCase { .objectForKeyedSubscript("asset") .objectForKeyedSubscript("value") guard let textString1 = newText1?.toString() else { return XCTFail("newText was not a string") } - + expectedMultiNode1Text = textString1 textExpectation.fulfill() } - + if count == 3 { let newText2 = val .objectForKeyedSubscript("values") @@ -271,26 +271,246 @@ class AsyncNodePluginTests: XCTestCase { .objectForKeyedSubscript("asset") .objectForKeyedSubscript("value") guard let textString2 = newText2?.toString() else { return XCTFail("newText was not a string") } - + expectedMultiNode2Text = textString2 textExpectation2.fulfill() - + } } } }) - + player.start(flow: .asyncNodeJson, completion: { _ in}) - + wait(for: [handlerExpectation, textExpectation], timeout: 5) - + XCTAssert(count == 2) XCTAssertEqual(expectedMultiNode1Text, "new node from the hook 1") - + wait(for: [textExpectation2], timeout: 5) - + + XCTAssert(count == 3) + XCTAssertEqual(expectedMultiNode2Text, "new node from the hook 2") + } + + func testHandleEmptyNode() { + let handlerExpectation = XCTestExpectation(description: "first data did not change") + + guard let context = JSContext() else { + XCTFail("JSContext initialization failed") + return + } + + var count = 0 + var args: JSValue? + var callbackFunction: JSValue? + + let resolve: AsyncHookHandler = { node, callback in + handlerExpectation.fulfill() + callbackFunction = callback + + return .singleNode(.concrete(context.evaluateScript(""" + ( + {"asset": {"id": "text", "type": "text", "value":"new node from the hook 1"}} + ) + """) ?? JSValue())) + } + + let asyncNodePluginPlugin = AsyncNodePluginPlugin() + let plugin = AsyncNodePlugin(plugins: [asyncNodePluginPlugin], resolve) + + plugin.context = context + + XCTAssertNotNil(asyncNodePluginPlugin.context) + + let player = HeadlessPlayerImpl(plugins: [ReferenceAssetsPlugin(), plugin], context: context) + + let textExpectation = XCTestExpectation(description: "newText found") + let textExpectation2 = XCTestExpectation(description: "newText found") + + var expectedMultiNode1Text: String = "" + var expectedMultiNode2Text: String = "" + + player.hooks?.viewController.tap({ (viewController) in + viewController.hooks.view.tap { (view) in + view.hooks.onUpdate.tap { val in + count += 1 + + if count == 2 { + let newText1 = val + .objectForKeyedSubscript("values") + .objectAtIndexedSubscript(1) + .objectForKeyedSubscript("asset") + .objectForKeyedSubscript("value") + guard let textString1 = newText1?.toString() else { return XCTFail("newText was not a string") } + + expectedMultiNode1Text = textString1 + textExpectation.fulfill() + } + + if count == 3 { + let newText2 = val + .objectForKeyedSubscript("values") + .objectAtIndexedSubscript(1) + .objectForKeyedSubscript("asset") + .objectForKeyedSubscript("value") + guard let textString2 = newText2?.toString() else { return XCTFail("newText was not a string") } + + expectedMultiNode2Text = textString2 + textExpectation2.fulfill() + } + } + } + }) + + player.start(flow: .asyncNodeJson, completion: { _ in}) + + wait(for: [handlerExpectation, textExpectation], timeout: 5) + + XCTAssert(count == 2) + XCTAssertEqual(expectedMultiNode1Text, "new node from the hook 1") + + let replacementResult = AsyncNodeHandlerType.emptyNode + + args = replacementResult.handlerTypeToJSValue(context: context ?? JSContext()) + + let _ = callbackFunction?.call(withArguments: [args]) + + XCTAssert(count == 3) + XCTAssertEqual(expectedMultiNode2Text, "undefined") + } + + + func testHandleMultipleUpdatesThroughCallback() { + + let handlerExpectation = XCTestExpectation(description: "first data did not change") + + guard let context = JSContext() else { + XCTFail("JSContext initialization failed") + return + } + + var count = 0 + var args: JSValue? + var callbackFunction: JSValue? + + let resolve: AsyncHookHandler = { node, callback in + handlerExpectation.fulfill() + callbackFunction = callback + + return .singleNode(.concrete(context.evaluateScript(""" + ( + {"asset": {"id": "text", "type": "text", "value":"new node from the hook 1"}} + ) + """) ?? JSValue())) + } + + let asyncNodePluginPlugin = AsyncNodePluginPlugin() + let plugin = AsyncNodePlugin(plugins: [asyncNodePluginPlugin], resolve) + + plugin.context = context + + XCTAssertNotNil(asyncNodePluginPlugin.context) + + let player = HeadlessPlayerImpl(plugins: [ReferenceAssetsPlugin(), plugin], context: context) + + let textExpectation = XCTestExpectation(description: "newText found") + let textExpectation2 = XCTestExpectation(description: "newText found") + let textExpectation3 = XCTestExpectation(description: "newText found") + + var expectedMultiNode1Text: String = "" + var expectedMultiNode2Text: String = "" + var expectedMultiNode3Text: String = "" + var expectedMultiNode4Text: String = "" + + player.hooks?.viewController.tap({ (viewController) in + viewController.hooks.view.tap { (view) in + view.hooks.onUpdate.tap { val in + count += 1 + + if count == 2 { + let newText1 = val + .objectForKeyedSubscript("values") + .objectAtIndexedSubscript(1) + .objectForKeyedSubscript("asset") + .objectForKeyedSubscript("value") + guard let textString1 = newText1?.toString() else { return XCTFail("newText was not a string") } + + expectedMultiNode1Text = textString1 + textExpectation.fulfill() + } + + if count == 3 { + let newText2 = val + .objectForKeyedSubscript("values") + .objectAtIndexedSubscript(1) + .objectForKeyedSubscript("asset") + .objectForKeyedSubscript("value") + guard let textString2 = newText2?.toString() else { return XCTFail("newText was not a string") } + + expectedMultiNode2Text = textString2 + textExpectation2.fulfill() + } + + if count == 4 { + + let newText3 = val + .objectForKeyedSubscript("values") + .objectAtIndexedSubscript(0) + .objectForKeyedSubscript("asset") + .objectForKeyedSubscript("label") + .objectForKeyedSubscript("asset") + .objectForKeyedSubscript("value") + guard let textString3 = newText3?.toString() else { return XCTFail("newText was not a string") } + + let newText4 = val + .objectForKeyedSubscript("values") + .objectAtIndexedSubscript(1) + .objectForKeyedSubscript("asset") + .objectForKeyedSubscript("value") + guard let textString4 = newText4?.toString() else { return XCTFail("newText was not a string") } + + expectedMultiNode3Text = textString3 + expectedMultiNode4Text = textString4 + textExpectation3.fulfill() + } + } + } + }) + + player.start(flow: .asyncNodeJson, completion: { _ in}) + + wait(for: [handlerExpectation, textExpectation], timeout: 5) + + XCTAssert(count == 2) + XCTAssertEqual(expectedMultiNode1Text, "new node from the hook 1") + + var replacementResult = AsyncNodeHandlerType.singleNode(.concrete(context.evaluateScript(""" + ( + {"asset": {"id": "text", "type": "text", "value":"new node from the hook 2"}} + ) + """) ?? JSValue())) + + args = replacementResult.handlerTypeToJSValue(context: context ?? JSContext()) + + let _ = callbackFunction?.call(withArguments: [args]) + XCTAssert(count == 3) XCTAssertEqual(expectedMultiNode2Text, "new node from the hook 2") + + + wait(for: [textExpectation2], timeout: 5) + + replacementResult = AsyncNodeHandlerType.emptyNode + + args = replacementResult.handlerTypeToJSValue(context: context ?? JSContext()) + + _ = callbackFunction?.call(withArguments: [args]) + + XCTAssert(count == 4) + // asset that the value at index 0 for the object + XCTAssertEqual(expectedMultiNode3Text, "test") + XCTAssertEqual(expectedMultiNode4Text, "undefined") } } @@ -319,6 +539,7 @@ extension String { }, { "id": "async", + "async": true } ] @@ -352,7 +573,7 @@ struct PlaceholderNode: Codable, Equatable, AssetData { public var id: String public var type: String var value: String? - + public init(id: String, type: String, value: String? = nil) { self.id = id self.type = type diff --git a/plugins/async-node/ios/ViewInspector/AsynNodePluginViewInspectorTests.swift b/plugins/async-node/ios/ViewInspector/AsynNodePluginViewInspectorTests.swift index 2c30fc58d..34c7682d9 100644 --- a/plugins/async-node/ios/ViewInspector/AsynNodePluginViewInspectorTests.swift +++ b/plugins/async-node/ios/ViewInspector/AsynNodePluginViewInspectorTests.swift @@ -26,7 +26,7 @@ class AsyncNodePluginViewInspectorTests: XCTestCase { let asyncNodePluginPlugin = AsyncNodePluginPlugin() - let plugin = AsyncNodePlugin(plugins: [asyncNodePluginPlugin]) { _ in + let plugin = AsyncNodePlugin(plugins: [asyncNodePluginPlugin]) { _,_ in handlerExpectation.fulfill() return .singleNode(.concrete(jsContext?.evaluateScript(""" diff --git a/plugins/async-node/jvm/src/main/kotlin/com/intuit/playerui/plugins/asyncnode/AsyncNodePlugin.kt b/plugins/async-node/jvm/src/main/kotlin/com/intuit/playerui/plugins/asyncnode/AsyncNodePlugin.kt index 37a3154d5..c183c5088 100644 --- a/plugins/async-node/jvm/src/main/kotlin/com/intuit/playerui/plugins/asyncnode/AsyncNodePlugin.kt +++ b/plugins/async-node/jvm/src/main/kotlin/com/intuit/playerui/plugins/asyncnode/AsyncNodePlugin.kt @@ -2,9 +2,10 @@ package com.intuit.playerui.plugins.asyncnode import com.intuit.playerui.core.bridge.Node import com.intuit.playerui.core.bridge.NodeWrapper -import com.intuit.playerui.core.bridge.hooks.NodeAsyncParallelBailHook1 +import com.intuit.playerui.core.bridge.hooks.NodeAsyncParallelBailHook2 import com.intuit.playerui.core.bridge.runtime.Runtime import com.intuit.playerui.core.bridge.runtime.ScriptContext +import com.intuit.playerui.core.bridge.serialization.serializers.Function1Serializer import com.intuit.playerui.core.bridge.serialization.serializers.GenericSerializer import com.intuit.playerui.core.bridge.serialization.serializers.NodeSerializableField import com.intuit.playerui.core.bridge.serialization.serializers.NodeSerializer @@ -13,11 +14,14 @@ import com.intuit.playerui.core.player.Player import com.intuit.playerui.core.player.PlayerException import com.intuit.playerui.core.plugins.JSScriptPluginWrapper import com.intuit.playerui.core.plugins.findPlugin +import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.nullable import kotlinx.serialization.builtins.serializer +public typealias asyncNodeUpdate = List>? public class AsyncNodePlugin : JSScriptPluginWrapper(pluginName, sourcePath = bundledSourcePath) { public lateinit var hooks: Hooks @@ -25,13 +29,25 @@ public class AsyncNodePlugin : JSScriptPluginWrapper(pluginName, sourcePath = bu override fun apply(runtime: Runtime<*>) { runtime.load(ScriptContext(script, bundledSourcePath)) instance = runtime.buildInstance("(new $pluginName({plugins: [new AsyncNodePlugin.AsyncNodePluginPlugin()]}))") - hooks = instance.getSerializable("hooks", Hooks.serializer()) ?: throw PlayerException("AsyncNodePlugin is not loaded correctly") + hooks = instance.getSerializable("hooks", Hooks.serializer()) + ?: throw PlayerException("AsyncNodePlugin is not loaded correctly") } @Serializable(with = Hooks.Serializer::class) public class Hooks internal constructor(override val node: Node) : NodeWrapper { /** The hook right before the View starts resolving. Attach anything custom here */ - public val onAsyncNode: NodeAsyncParallelBailHook1>> by NodeSerializableField(NodeAsyncParallelBailHook1.serializer(NodeSerializer(), ListSerializer(MapSerializer(String.serializer(), GenericSerializer())))) + public val onAsyncNode: NodeAsyncParallelBailHook2 Unit, asyncNodeUpdate> by + NodeSerializableField( + NodeAsyncParallelBailHook2.serializer( + NodeSerializer(), + Function1Serializer( + ListSerializer(MapSerializer(String.serializer(), GenericSerializer())).nullable, + GenericSerializer(), + ) as KSerializer<(asyncNodeUpdate) -> Unit>, + ListSerializer(MapSerializer(String.serializer(), GenericSerializer())).nullable, + ), + ) + internal object Serializer : NodeWrapperSerializer(AsyncNodePlugin::Hooks) } diff --git a/plugins/async-node/jvm/src/test/kotlin/com/intuit/playerui/plugins/asyncnode/AsyncNodePluginTest.kt b/plugins/async-node/jvm/src/test/kotlin/com/intuit/playerui/plugins/asyncnode/AsyncNodePluginTest.kt index fb30efa76..8b15416b3 100644 --- a/plugins/async-node/jvm/src/test/kotlin/com/intuit/playerui/plugins/asyncnode/AsyncNodePluginTest.kt +++ b/plugins/async-node/jvm/src/test/kotlin/com/intuit/playerui/plugins/asyncnode/AsyncNodePluginTest.kt @@ -2,20 +2,26 @@ package com.intuit.playerui.plugins.asyncnode import com.intuit.hooks.BailResult import com.intuit.playerui.core.asset.Asset +import com.intuit.playerui.core.bridge.Node +import com.intuit.playerui.core.player.state.inProgressState +import com.intuit.playerui.core.player.state.lastViewUpdate import com.intuit.playerui.utils.test.PlayerTest import com.intuit.playerui.utils.test.runBlockingTest import io.mockk.junit5.MockKExtension import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.yield import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.TestTemplate import org.junit.jupiter.api.extension.ExtendWith +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine @ExtendWith(MockKExtension::class) internal class AsyncNodePluginTest : PlayerTest() { - val asyncNodeFlowSimple = + private val asyncNodeFlowSimple = """{ "id": "counter-flow", "views": [ @@ -73,9 +79,9 @@ internal class AsyncNodePluginTest : PlayerTest() { } @TestTemplate - fun `async node hook is tappable`() = runBlockingTest() { + fun `async node hook is tappable`() = runBlockingTest { var update: Asset? = null - plugin?.hooks?.onAsyncNode?.tap("") { _, node -> + plugin?.hooks?.onAsyncNode?.tap("") { _, node, callback -> BailResult.Bail( listOf( mapOf( @@ -106,7 +112,7 @@ internal class AsyncNodePluginTest : PlayerTest() { @TestTemplate fun `replace async node with multiNode`() = runBlockingTest { var update: Asset? = null - plugin?.hooks?.onAsyncNode?.tap("") { _, node -> + plugin?.hooks?.onAsyncNode?.tap("") { _, node, callback -> BailResult.Bail( listOf( mapOf( @@ -164,7 +170,7 @@ internal class AsyncNodePluginTest : PlayerTest() { } } } - plugin?.hooks?.onAsyncNode?.tap("") { _, node -> + plugin?.hooks?.onAsyncNode?.tap("") { _, node, callback -> asyncTaps++ when (asyncTaps) { 1 -> BailResult.Bail( @@ -228,4 +234,155 @@ internal class AsyncNodePluginTest : PlayerTest() { } Assertions.assertTrue(true) } + + @TestTemplate + fun `handle multiple updates through callback mechanism`() = runBlockingTest { + // TODO: This typing is not great - need to go fix hook types + var deferredResolve: ((asyncNodeUpdate) -> Unit)? = null + var updateContent: ((asyncNodeUpdate) -> Unit)? = null + + var count = 0 + + plugin.hooks.onAsyncNode.tap("") { _, node, callback -> + updateContent = callback + val result = suspendCoroutine { cont -> + deferredResolve = { value -> + cont.resume(value) + } + } + BailResult.Bail(result) + } + + player.hooks.view.tap { v -> + v?.hooks?.onUpdate?.tap { asset -> + count++ + } + } + player.start(asyncNodeFlowSimple) + + var view = player.inProgressState?.lastViewUpdate + Assertions.assertNotNull(view) + Assertions.assertEquals( + "action", + view!!.getList("actions")?.filterIsInstance()?.get(0)?.getObject("asset")?.get("type"), + ) + Assertions.assertEquals(1, view.getList("actions")?.size) + + while (true) { + if (deferredResolve != null) { + break + } + + yield() + } + + // create a view object to pass it to the deferred resolve + val viewObject = mapOf( + "asset" to mapOf( + "id" to "asset-1", + "type" to "action", + "value" to "New asset!", + ), + ) + + deferredResolve!!.invoke(listOf(viewObject)) + + Assertions.assertEquals(1, count) + + view = player.inProgressState?.lastViewUpdate + + Assertions.assertNotNull(view) + Assertions.assertEquals( + "action", + view!!.getList("actions")?.filterIsInstance()?.get(0)?.getObject("asset")?.get("type"), + ) + Assertions.assertEquals( + "action", + view.getList("actions")?.filterIsInstance()?.get(1)?.getObject("asset")?.get("type"), + ) + Assertions.assertEquals(2, view.getList("actions")?.size) + Assertions.assertEquals(2, count) + + updateContent!!.invoke(null) + + Assertions.assertEquals(3, count) + view = player.inProgressState?.lastViewUpdate + + Assertions.assertNotNull(view) + Assertions.assertEquals( + "action", + view!!.getList("actions")?.filterIsInstance()?.get(0)?.getObject("asset")?.get("type"), + ) + Assertions.assertEquals(1, view.getList("actions")?.size) + } + + @TestTemplate + fun `handle null node`() = runBlockingTest { + // TODO: This typing is not great - need to go fix hook types + var deferredResolve: ((asyncNodeUpdate) -> Unit)? = null + var updateContent: ((asyncNodeUpdate) -> Unit)? = null + + plugin.hooks.onAsyncNode.tap("") { _, node, callback -> + updateContent = callback + val result = suspendCoroutine { cont -> + deferredResolve = { value -> + cont.resume(value) + } + } + + BailResult.Bail(result) + } + + var count = 0 + player.hooks.view.tap { v -> + v?.hooks?.onUpdate?.tap { asset -> + count++ + } + } + + player.start(asyncNodeFlowSimple) + + var view = player.inProgressState?.lastViewUpdate + + Assertions.assertNotNull(view) + Assertions.assertEquals( + "action", + view!!.getList("actions")?.filterIsInstance()?.get(0)?.getObject("asset")?.get("type"), + ) + Assertions.assertEquals(1, view.getList("actions")?.size) + + while (true) { + if (deferredResolve != null) { + break + } + + yield() + } + + deferredResolve!!.invoke(null) + + Assertions.assertEquals(1, count) + + view = player.inProgressState?.lastViewUpdate + + Assertions.assertNotNull(view) + Assertions.assertEquals( + "action", + view!!.getList("actions")?.filterIsInstance()?.get(0)?.getObject("asset")?.get("type"), + ) + Assertions.assertEquals(1, view.getList("actions")?.size) + + updateContent!!.invoke(null) + + Assertions.assertEquals(1, count) + + view = player.inProgressState?.lastViewUpdate + + Assertions.assertNotNull(view) + Assertions.assertEquals( + "action", + view!!.getList("actions")?.filterIsInstance()?.get(0)?.getObject("asset")?.get("type"), + ) + Assertions.assertEquals(1, view.getList("actions")?.size) + } }