From 82270dcbee70fc05ba5b5d68e566b431ff5258ec Mon Sep 17 00:00:00 2001 From: Harris Borawski Date: Wed, 30 Aug 2023 09:54:47 -0700 Subject: [PATCH] Sync iOS Changes (#145) * sync extended AnyType and usage in BeaconPlugin and PubSubPlugin * sync nav state refactor and hook updates * add SwiftUICheckPathPlugin * clear external state for viewmodifier if state transitions from another plugin * handle NaN -> nil when decoding view updates * sync allow wrapped function to return custom decodable type * mark some metrics data optional for non VIEW states * make DefaultBeacon Codable and Hashable * Expose LocalModel, BindingParser in swift * bump circle to use xcode 14.3 * try lowering ruby version to satisfy cocoapods * fix indent * pick specific ruby version * only init rbenv through bashrc * try chruby instead of rbenv * try circle step * try using preinstalled rbenv * rbenv install * bump bazel to 5.4.1 --- .bazelversion | 2 +- .circleci/config.yml | 14 +- PlayerUI.podspec | 8 + generated.bzl | 8 + .../core/Sources/Player/HeadlessPlayer.swift | 20 +- .../Types/Assets/BaseAssetRegistry.swift | 35 ++- .../Sources/Types/Core/BindingParser.swift | 120 ++++++++++ .../Sources/Types/Core/CompletedState.swift | 67 ++---- .../core/Sources/Types/Core/Flow.swift | 29 +++ .../Sources/Types/Core/FlowController.swift | 8 +- .../Sources/Types/Core/NavigationStates.swift | 110 +++++++++ .../core/Sources/Types/Generic/AnyType.swift | 172 ++++++++++++++- .../Types/Hooks/FlowControllerHooks.swift | 4 +- .../core/Sources/Types/Hooks/Hook.swift | 71 ++++-- .../Sources/utilities/GenericInterfaces.swift | 7 + .../core/Sources/utilities/JSUtilities.swift | 13 -- ios/packages/core/Tests/FlowStateTests.swift | 99 +++++++++ .../Tests/Types/Core/BindingParserTests.swift | 56 +++++ .../Tests/Types/Generic/AnyTypeTests.swift | 208 +++++++++++++++++- .../Tests/utilities/JSUtilitiesTests.swift | 58 ++++- .../Sources/FlowData.swift | 86 ++++++++ .../Sources/types/WrappedFunction.swift | 9 + .../Tests/types/WrappedFunctionTests.swift | 39 ++++ .../Sources/BaseBeaconPlugin.swift | 22 +- .../Tests/BaseBeaconPluginTests.swift | 83 +++++++ .../Sources/CheckPathPlugin.swift | 27 ++- .../Sources/ExternalActionPlugin.swift | 2 +- .../Sources/NavigationFlowExternalState.swift | 32 --- .../ExternalActionViewModifierPlugin.swift | 17 +- ...xternalActionViewModifierPluginTests.swift | 115 ++++++++-- .../MetricsPlugin/Sources/MetricsPlugin.swift | 4 +- .../PubSubPlugin/Sources/PubSubPlugin.swift | 4 +- .../Tests/PubSubPluginTests.swift | 25 +++ .../Sources/SwiftUICheckPathPlugin.swift | 31 +++ .../SwiftUICheckPathPluginTests.swift | 41 ++++ xcode/Podfile | 1 + xcode/Podfile.lock | 9 +- 37 files changed, 1468 insertions(+), 188 deletions(-) create mode 100644 ios/packages/core/Sources/Types/Core/BindingParser.swift create mode 100644 ios/packages/core/Sources/Types/Core/NavigationStates.swift create mode 100644 ios/packages/core/Tests/FlowStateTests.swift create mode 100644 ios/packages/core/Tests/Types/Core/BindingParserTests.swift delete mode 100644 ios/plugins/ExternalActionPlugin/Sources/NavigationFlowExternalState.swift create mode 100644 ios/plugins/SwiftUICheckPathPlugin/Sources/SwiftUICheckPathPlugin.swift create mode 100644 ios/plugins/SwiftUICheckPathPlugin/ViewInspector/SwiftUICheckPathPluginTests.swift diff --git a/.bazelversion b/.bazelversion index 1e20ec35c..04edabda2 100644 --- a/.bazelversion +++ b/.bazelversion @@ -1 +1 @@ -5.4.0 \ No newline at end of file +5.4.1 \ No newline at end of file diff --git a/.circleci/config.yml b/.circleci/config.yml index 0d6af02cb..fb4fa362d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -25,7 +25,7 @@ executors: working_directory: ~/player resource_class: macos.x86.medium.gen2 macos: - xcode: 13.4.1 + xcode: 14.3 environment: TZ: "/usr/share/zoneinfo/America/Los_Angeles" android: @@ -112,7 +112,17 @@ jobs: - attach_workspace: at: ~/player - - run: HOMEBREW_NO_AUTO_UPDATE=1 brew install bazelisk maven openjdk@8 + - run: + name: Homebrew Dependencies + command: | + HOMEBREW_NO_AUTO_UPDATE=1 brew install bazelisk maven openjdk@8 + + - run: + name: Set Ruby Version + command: | + rbenv install 2.6.10 + rbenv global 2.6.10 + rbenv rehash - restore_cache: keys: diff --git a/PlayerUI.podspec b/PlayerUI.podspec index b36873bbc..d4743b49c 100644 --- a/PlayerUI.podspec +++ b/PlayerUI.podspec @@ -320,6 +320,14 @@ and display it as a SwiftUI view comprised of registered assets. } end + s.subspec 'SwiftUICheckPathPlugin' do |plugin| + plugin.ios.deployment_target = '13.0' + plugin.dependency 'PlayerUI/Core' + plugin.dependency 'PlayerUI/SwiftUI' + plugin.dependency 'PlayerUI/CheckPathPlugin' + plugin.source_files = 'ios/plugins/SwiftUICheckPathPlugin/Sources/**/*' + end + s.subspec 'TypesProviderPlugin' do |plugin| plugin.dependency 'PlayerUI/Core' plugin.source_files = 'ios/plugins/TypesProviderPlugin/Sources/**/*' diff --git a/generated.bzl b/generated.bzl index d1aa48913..450be70ac 100644 --- a/generated.bzl +++ b/generated.bzl @@ -147,6 +147,14 @@ def PlayerUI( "ios/packages/swiftui/Sources/**/*.c", "ios/packages/swiftui/Sources/**/*.cc", "ios/packages/swiftui/Sources/**/*.cpp", + "ios/plugins/SwiftUICheckPathPlugin/Sources/**/*.h", + "ios/plugins/SwiftUICheckPathPlugin/Sources/**/*.hh", + "ios/plugins/SwiftUICheckPathPlugin/Sources/**/*.m", + "ios/plugins/SwiftUICheckPathPlugin/Sources/**/*.mm", + "ios/plugins/SwiftUICheckPathPlugin/Sources/**/*.swift", + "ios/plugins/SwiftUICheckPathPlugin/Sources/**/*.c", + "ios/plugins/SwiftUICheckPathPlugin/Sources/**/*.cc", + "ios/plugins/SwiftUICheckPathPlugin/Sources/**/*.cpp", "ios/packages/test-utils/Sources/**/*.h", "ios/packages/test-utils/Sources/**/*.hh", "ios/packages/test-utils/Sources/**/*.m", diff --git a/ios/packages/core/Sources/Player/HeadlessPlayer.swift b/ios/packages/core/Sources/Player/HeadlessPlayer.swift index f192fa8e4..33dc4429b 100644 --- a/ios/packages/core/Sources/Player/HeadlessPlayer.swift +++ b/ios/packages/core/Sources/Player/HeadlessPlayer.swift @@ -261,16 +261,7 @@ public extension HeadlessPlayer { - context: The context to load the headless player into */ private func loadCore(into context: JSContext) { - guard - let url = ResourceUtilities.urlForFile( - name: "player.prod", - ext: "js", - bundle: Bundle(for: ResourceBundleShim.self), - pathComponent: "PlayerUI.bundle" - ), - let jsString = try? String(contentsOf: url, encoding: String.Encoding.utf8) - else { return } - context.evaluateScript(jsString) + context.loadCore() } /** @@ -307,6 +298,15 @@ public protocol WithSymbol { internal class ResourceBundleShim {} internal extension JSContext { + /// Loads the core player bundle into the give JSContext + func loadCore() { + guard + let url = ResourceUtilities.urlForFile(name: "player.prod", ext: "js", bundle: Bundle(for: ResourceBundleShim.self), pathComponent: "PlayerUI.bundle"), + let jsString = try? String(contentsOf: url, encoding: String.Encoding.utf8) + else { return } + evaluateScript(jsString) + } + func getSymbol(_ symbolName: String) -> JSValue? { guard let ref = getClassReference(symbolName, load: {_ in}), diff --git a/ios/packages/core/Sources/Types/Assets/BaseAssetRegistry.swift b/ios/packages/core/Sources/Types/Assets/BaseAssetRegistry.swift index b9dbed1ce..d02f7c462 100644 --- a/ios/packages/core/Sources/Types/Assets/BaseAssetRegistry.swift +++ b/ios/packages/core/Sources/Types/Assets/BaseAssetRegistry.swift @@ -26,6 +26,9 @@ public enum DecodingError: Error { /// More than one asset is using the attached identifier case duplicateIdentifier(String) + + /// Update from core player was not convertible + case malformedData } /** @@ -187,19 +190,39 @@ public struct RegistryDecodeShim: Decodable { extension JSValue { var jsonDisplayString: String { do { - return try String(data: jsonData(options: [.prettyPrinted]), encoding: .utf8) ?? "notuf8" + return try String(data: jsonData(pretty: true), encoding: .utf8) ?? "notuf8" } catch { return error.localizedDescription } } - /// Returns the contents of this value encoded into UTF-8 string data. Throws DecodingError.jsonDataNotFound + /// Returns the contents of this value encoded into UTF-8 string data. Throws DecodingError.malformedData /// if this value can't be transformed. - func jsonData(options: JSONSerialization.WritingOptions = []) throws -> Data { - guard let object = toObject(), object is NSArray || object is NSDictionary else { - return try JSONSerialization.data(withJSONObject: NSDictionary(), options: options) + func jsonData(pretty: Bool = false) throws -> Data { + guard + let json = context?.objectForKeyedSubscript("JSON"), + !json.isUndefined, + !json.isNull, + // replace functions with placeholders, since we retrieve the value in WrappedFunction + // with the coding path + let replacer = context?.evaluateScript("(key, value) => (typeof value === 'function' ? {} : value)"), + let output = json.invokeMethod( + "stringify", + withArguments: [ + self, + replacer as Any, + ( + pretty ? 2 : nil + ) as Any + ] + ), + !output.isUndefined, + !output.isNull, + let data = output.toString().data(using: .utf8) + else { + throw DecodingError.malformedData } - return try JSONSerialization.data(withJSONObject: object, options: options) + return data } } diff --git a/ios/packages/core/Sources/Types/Core/BindingParser.swift b/ios/packages/core/Sources/Types/Core/BindingParser.swift new file mode 100644 index 000000000..e59e32829 --- /dev/null +++ b/ios/packages/core/Sources/Types/Core/BindingParser.swift @@ -0,0 +1,120 @@ +import Foundation +import JavaScriptCore + +/// A parser for creating bindings from a string +public class BindingParser { + internal var value: JSValue? + internal var options: BindingParserOptions + + /// Create a BindingParser + /// - Parameters: + /// - options: Required options to pass to the core BindingParser + /// - context: The JSContext to create in + public init(options: BindingParserOptions, in context: JSContext) { + self.options = options + let getCallback: @convention(block) (JSValue) -> JSValue? = { binding in + let binding = BindingInstance(from: binding) + return options.get(binding) + } + value = context + .getClassReference("Player.BindingParser", load: { $0.loadCore() })? + .construct(withArguments: [[ + "get": JSValue(object: getCallback, in: context) as Any + ]]) + } + + /// Parse a string into a binding + /// - Parameter path: The path to parse into a binding + /// - Returns: a BindingInstance fromt the parsed path + public func parse(path: String) -> BindingInstance? { + value? + .invokeMethod("parse", withArguments: [path]) + .map { BindingInstance(from: $0) } + } +} + +/// A path in the data model +public class BindingInstance { + internal var value: JSValue? + + /// Create a BindingInstance, a path to data in the data model + /// - Parameters: + /// - rawBinding: The string representation of the path + /// - context: The JSContext to create in + public init(rawBinding: String, in context: JSContext) { + value = context + .getClassReference("Player.BindingInstance", load: { $0.loadCore() })? + .construct(withArguments: [rawBinding]) + } + + /// Create a BindingInstance from an existing JSValue reference + /// - Parameter value: The JSValue that represents a JavaScript BindingInstance + public init(from value: JSValue?) { + self.value = value + } + + /// Retrieve this Binding as an array + /// - Returns: The array of segments in the binding + public func asArray() -> [String]? { + return value? + .invokeMethod("asArray", withArguments: []) + .toArray() + /// Array indices are automatically treated as ints, so we need to map + .map { $0 as? String ?? String(describing: $0) } + } + + /// Retrieve this Binding as its string form + /// - Returns: The string representation of this binding + public func asString() -> String? { + return value?.invokeMethod("asString", withArguments: []).toString() + } +} + +/// Options for ``BindingParser`` +public struct BindingParserOptions { + /// Get the value for a specific binding + public var get: (BindingInstance) -> JSValue? + + /// Create BindingParserOptions + /// - Parameter get: A callback to retrieve nested values + public init( + get: @escaping (BindingInstance) -> JSValue? + ) { + self.get = get + } +} + +/// A data model that stores things in an in-memory JS object +public class LocalModel { + /// Reference to the JavaScript LocalModel + internal var value: JSValue? + + /// Create a new LocalModel + /// - Parameters: + /// - data: The data to start with + /// - context: The JSContext to create it in + public init(data: [String: Any] = [:], in context: JSContext) { + value = context.getClassReference("Player.LocalModel", load: { $0.loadCore() })?.construct(withArguments: [data]) + } + + /// Get a value in the model for a given binding + /// - Parameter binding: The binding for where the data is + /// - Returns: The data at the binding + public func get(binding: BindingInstance) -> JSValue? { + value?.invokeMethod("get", withArguments: [binding.value as Any]) + } + + /// Set data in the model + /// - Parameter transaction: An array of transaction objects, tuples of ``BindingInstance`` and the value to set + public func set(transaction: [(BindingInstance, Any)]) { + value?.invokeMethod("set", withArguments: [transaction.map { [$0.0.value as Any, $0.1] as [Any] }]) + } + + /// Get a value in the model for a given path + /// - Parameter path: The string path to use for creating a binding + /// - Returns: The data at the path + public func get(path: String) -> JSValue? { + let binding = value?.context.map { BindingInstance(rawBinding: path, in: $0) } + return binding.flatMap { get(binding: $0) } + } +} diff --git a/ios/packages/core/Sources/Types/Core/CompletedState.swift b/ios/packages/core/Sources/Types/Core/CompletedState.swift index bfa0774a8..85c9d9231 100644 --- a/ios/packages/core/Sources/Types/Core/CompletedState.swift +++ b/ios/packages/core/Sources/Types/Core/CompletedState.swift @@ -37,7 +37,7 @@ public class PlayerControllers { } /** - Enum with the different possible states of Player + Enum with the different possible states of the player */ public enum PlayerFlowStatus: String { /// The Flow has not been started @@ -108,58 +108,17 @@ public protocol PlayerFlowExecutionData { var flow: Flow { get } } -/** - Structure holding the result of the flow, defined in the content - */ -@dynamicMemberLookup -public struct EndState { - /** - The full FlowResult object for dynamicMemberLookup - */ - private var endState: [String: Any] - /// The outcome string for the end state - public let outcome: String - - /// The param object associated with the state - public var param: [String: Any]? - - /** - Create an instance of `EndState` from a JSValue - - parameters: - - value: The JSValue representing the EndState - - returns: An EndState object if the JSValue was one - */ - public init?(from value: JSValue?) { - guard - let value = value, - let outcome = value.objectForKeyedSubscript("outcome")?.toString() - else { return nil } - self.outcome = outcome - self.param = value.objectForKeyedSubscript("param")?.toObject() as? [String: Any] - self.endState = value.toObject() as? [String: Any] ?? [:] - } - - /** - Subscript function to allow fetching any additional properties that the FlowResult might have - - parameters: - - member: The name of the member to access - - returns: The member cast to the receiving type if it exists - */ - public subscript(dynamicMember member: String) -> T? { - return endState[member] as? T - } - -} +public typealias EndState = NavigationFlowEndState /** - A structure that holds the data of a completed Flow + A structure that holds the data of a completed Fuego Flow */ public class CompletedState: BaseFlowState, PlayerFlowExecutionData { /// The flow object for the completed state public var flow: Flow /// The result of the flow - public var endState: EndState? + public var endState: NavigationFlowEndState? /// The local data from the flow public var data: [String: Any] @@ -176,12 +135,12 @@ public class CompletedState: BaseFlowState, PlayerFlowExecutionData { else { return nil } return CompletedState( flow: Flow.createInstance(value: flow), - endState: EndState(from: value?.objectForKeyedSubscript("endState")), + endState: value.map { NavigationFlowEndState($0.objectForKeyedSubscript("endState")) }, data: value?.objectForKeyedSubscript("data")?.toObject() as? [String: Any] ?? [:] ) } - private init(flow: Flow, endState: EndState?, data: [String: Any]) { + private init(flow: Flow, endState: NavigationFlowEndState?, data: [String: Any]) { self.flow = flow self.endState = endState self.data = data @@ -190,7 +149,7 @@ public class CompletedState: BaseFlowState, PlayerFlowExecutionData { } /** - A structure that holds the data of a Flow that hasnt been started + A structure that holds the data of a Fuego Flow that hasnt been started */ public class NotStartedState: BaseFlowState { /** @@ -209,14 +168,14 @@ public class NotStartedState: BaseFlowState { } /** - A structure that holds the data of a Flow that is in progress + A structure that holds the data of a Fuego Flow that is in progress */ public class InProgressState: BaseFlowState, PlayerFlowExecutionData { /// The flow object that is currently in progress public var flow: Flow /// A promise that resolves when the flow completes - public var flowResult: EndState? + public var flowResult: NavigationFlowEndState? /// Controllers for the active state public var controllers: PlayerControllers? @@ -224,7 +183,7 @@ public class InProgressState: BaseFlowState, PlayerFlowExecutionData { /// The Logger for the current player instance public let logger: JSLogger? - /// A function to force Player to a failed state + /// A function to force the player to a failed state public let fail: (PlayerError) -> Void /** @@ -239,7 +198,7 @@ public class InProgressState: BaseFlowState, PlayerFlowExecutionData { else { return nil } return InProgressState( flow: Flow.createInstance(value: flow), - flowResult: EndState(from: value?.objectForKeyedSubscript("flowResult")), + flowResult: value.map { NavigationFlowEndState($0.objectForKeyedSubscript("flowResult")) }, controllers: PlayerControllers(from: value?.objectForKeyedSubscript("controllers")), logger: JSLogger(from: value?.objectForKeyedSubscript("logger")), fail: { value?.objectForKeyedSubscript("fail")?.call(withArguments: [value?.context.error(for: $0) as Any]) } @@ -248,7 +207,7 @@ public class InProgressState: BaseFlowState, PlayerFlowExecutionData { private init( flow: Flow, - flowResult: EndState?, + flowResult: NavigationFlowEndState?, controllers: PlayerControllers?, logger: JSLogger?, fail: @escaping (PlayerError) -> Void @@ -263,7 +222,7 @@ public class InProgressState: BaseFlowState, PlayerFlowExecutionData { } /** -A structure that holds the data of a Flow that has errored +A structure that holds the data of a Fuego Flow that has errored */ public class ErrorState: BaseFlowState, PlayerFlowExecutionData { /// The flow object that is currently in progress diff --git a/ios/packages/core/Sources/Types/Core/Flow.swift b/ios/packages/core/Sources/Types/Core/Flow.swift index 8eda68a89..5a50d0e4f 100644 --- a/ios/packages/core/Sources/Types/Core/Flow.swift +++ b/ios/packages/core/Sources/Types/Core/Flow.swift @@ -21,6 +21,12 @@ public class Flow: CreatedFromJSValue { /// The original data associated with this flow public var data: [String: Any]? { value.objectForKeyedSubscript("data")?.toObject() as? [String: Any] } + /// The name of this flow + public var currentState: NamedState? { value.objectForKeyedSubscript("currentState").map { NamedState($0) } } + + /// Lifecycle hooks + public let hooks: FlowHooks + /** Creates an instance from a JSValue, used for generic construction - parameters: @@ -38,5 +44,28 @@ public class Flow: CreatedFromJSValue { */ public init(_ value: JSValue) { self.value = value + hooks = FlowHooks(transition: Hook2(baseValue: value, name: "transition")) + } +} + +public struct FlowHooks { + /// A hook that fires when transitioning states and giving the old and new states as parameters + public var transition: Hook2 +} + +public struct NamedState: CreatedFromJSValue { + public typealias T = NamedState + + public static func createInstance(value: JSValue) -> NamedState { .init(value) } + + /// The name of the navigation node + public let name: String + + /// The navigation node itself + public let value: NavigationBaseState? + + init(_ value: JSValue) { + self.name = value.objectForKeyedSubscript("name").toString() + self.value = NavigationBaseState.createInstance(value: value.objectForKeyedSubscript("value")) } } diff --git a/ios/packages/core/Sources/Types/Core/FlowController.swift b/ios/packages/core/Sources/Types/Core/FlowController.swift index e526a5f09..b61ea8dee 100644 --- a/ios/packages/core/Sources/Types/Core/FlowController.swift +++ b/ios/packages/core/Sources/Types/Core/FlowController.swift @@ -28,6 +28,11 @@ public class FlowController: CreatedFromJSValue { /// The hooks that can be tapped into public let hooks: FlowControllerHooks + /// The current flow for this controller + public var current: Flow? { + value.objectForKeyedSubscript("current").map { Flow($0) } + } + /** Construct a FlowController from a JSValue - parameters: @@ -35,7 +40,7 @@ public class FlowController: CreatedFromJSValue { */ public init(_ value: JSValue) { self.value = value - hooks = FlowControllerHooks(transition: Hook(baseValue: value, name: "transition")) + hooks = FlowControllerHooks(flow: Hook(baseValue: value, name: "flow")) } /** @@ -47,3 +52,4 @@ public class FlowController: CreatedFromJSValue { value.invokeMethod("transition", withArguments: [action]) } } + diff --git a/ios/packages/core/Sources/Types/Core/NavigationStates.swift b/ios/packages/core/Sources/Types/Core/NavigationStates.swift new file mode 100644 index 000000000..e1395c4a0 --- /dev/null +++ b/ios/packages/core/Sources/Types/Core/NavigationStates.swift @@ -0,0 +1,110 @@ +import Foundation +import JavaScriptCore + +/// The base representation of a state within a Flow +open class NavigationBaseState: CreatedFromJSValue { + public typealias T = NavigationBaseState + + /// A property to determine the type of state this is + public let stateType: String + + internal let rawValue: JSValue + + public static func createInstance(value: JSValue) -> NavigationBaseState { + let base = NavigationBaseState(value) + switch base.stateType { + case "VIEW": return NavigationFlowViewState(value) + case "ACTION": return NavigationFlowActionState(value) + case "FLOW": return NavigationFlowFlowState(value) + case "EXTERNAL": return NavigationFlowExternalState(value) + case "END": return NavigationFlowEndState(value) + default: return base + } + } + + init(_ value: JSValue) { + rawValue = value + stateType = value.objectForKeyedSubscript("state_type").toString() + } +} + +/// A generic state that can transition to another state +open class NavigationFlowTransitionableState: NavigationBaseState { + /// A mapping of transition-name to FlowState name + public var transitions: [String: String]? { + rawValue.objectForKeyedSubscript("transitions").toObject() as? [String: String] + } +} + +/// A state representing a view +@dynamicMemberLookup +public class NavigationFlowViewState: NavigationFlowTransitionableState { + /// An id corresponding to a view from the 'views' array + public var ref: String { rawValue.objectForKeyedSubscript("ref").toString() } + + /// View meta-properties + public var attributes: [String: String]? { + rawValue.objectForKeyedSubscript("attributes").toObject() as? [String: String] + } + + public subscript(dynamicMember member: String) -> T? { + rawValue.objectForKeyedSubscript(member).toObject() as? T + } +} + +/// External Flow states represent states in the FSM that can't be resolved internally in the player. +/// The flow will wait for the embedded application to manage moving to the next state via a transition +@dynamicMemberLookup +public class NavigationFlowExternalState: NavigationFlowTransitionableState { + /// A reference for this external state + public var ref: String? { + rawValue.objectForKeyedSubscript("ref").toString() + } + + public subscript(dynamicMember member: String) -> T? { + rawValue.objectForKeyedSubscript(member).toObject() as? T + } +} + +/// An END state of the flow +@dynamicMemberLookup +public class NavigationFlowEndState: NavigationBaseState { + + /// A description of _how_ the flow ended. + public var outcome: String { rawValue.objectForKeyedSubscript("outcome").toString() } + + public convenience init?(from value: JSValue?) { + guard let value = value else { return nil } + self.init(value) + } + + public subscript(dynamicMember member: String) -> T? { + rawValue.objectForKeyedSubscript(member).toObject() as? T + } +} + +public extension NavigationFlowEndState { + var param: [String: Any]? { rawValue.objectForKeyedSubscript("param").toObject() as? [String: Any] } +} + +public class NavigationFlowFlowState: NavigationFlowTransitionableState { + /// A reference to a FLOW id state to run + public var ref: String { rawValue.objectForKeyedSubscript("ref").toString() } +} + +/// Action states execute an expression to determine the next state to transition to +public class NavigationFlowActionState: NavigationFlowTransitionableState { + /// An expression to execute. The return value determines the transition to take + public var exp: Expression { + if let multi = rawValue.objectForKeyedSubscript("exp").toObject() as? [String] { + return .multi(exp: multi) + } else { + return .single(exp: rawValue.objectForKeyedSubscript("exp").toString()) + } + } + + public enum Expression { + case single(exp: String) + case multi(exp: [String]) + } +} diff --git a/ios/packages/core/Sources/Types/Generic/AnyType.swift b/ios/packages/core/Sources/Types/Generic/AnyType.swift index 57df9d231..af123a8ce 100644 --- a/ios/packages/core/Sources/Types/Generic/AnyType.swift +++ b/ios/packages/core/Sources/Types/Generic/AnyType.swift @@ -11,19 +11,91 @@ import Foundation A union type to match the JS core players any type */ public enum AnyType: Hashable { + // swiftlint:disable cyclomatic_complexity + public func hash(into hasher: inout Hasher) { + switch self { + case .string(let data): + hasher.combine(data) + case .bool(let data): + hasher.combine(data) + case .number(let data): + hasher.combine(data) + case .dictionary(let data): + hasher.combine(data) + case .numberDictionary(let data): + hasher.combine(data) + case .booleanDictionary(let data): + hasher.combine(data) + case .array(let data): + hasher.combine(data) + case .numberArray(let data): + hasher.combine(data) + case .booleanArray(let data): + hasher.combine(data) + case .anyDictionary(let data): + hasher.combine(data as NSDictionary) + case .unknownData: + return + } + } + /// The underlying data was a string case string(data: String) - /// The underlying data was a dictionary + /// The underlying data was a boolean + case bool(data: Bool) + + /// The underlying data was a number + case number(data: Double) + + /// The underlying data was a dictionary of strings case dictionary(data: [String: String]) + /// The underlying data was a dictionary of numbers + case numberDictionary(data: [String: Double]) + + /// The underlying data was a dictionary of booleans + case booleanDictionary(data: [String: Bool]) + /// The underlying data was an array of strings case array(data: [String]) + /// The underlying data was an array of numbers + case numberArray(data: [Double]) + + /// The underlying data was an array of booleans + case booleanArray(data: [Bool]) + + /** + The underlying data was a dictionary of varied value types + + **This requires the decoder to add `AnyTypeDecodingContext` to the decoders userInfo** + */ + case anyDictionary(data: [String: Any]) + /// The underlying data was not in a known format case unknownData } +extension AnyType: Equatable { + // swiftlint:disable cyclomatic_complexity + public static func == (lhs: AnyType, rhs: AnyType) -> Bool { + switch (lhs, rhs) { + case (.string(let lhv), .string(let rhv)): return lhv == rhv + case (.bool(let lhv), .bool(let rhv)): return lhv == rhv + case (.number(let lhv), .number(let rhv)): return lhv == rhv + case (.dictionary(let lhv), .dictionary(let rhv)): return lhv == rhv + case (.numberDictionary(let lhv), .numberDictionary(let rhv)): return lhv == rhv + case (.booleanDictionary(let lhv), .booleanDictionary(let rhv)): return lhv == rhv + case (.array(let lhv), .array(let rhv)): return lhv == rhv + case (.numberArray(let lhv), .numberArray(let rhv)): return lhv == rhv + case (.booleanArray(let lhv), .booleanArray(let rhv)): return lhv == rhv + case (.anyDictionary(let lhv), .anyDictionary(let rhv)): return (lhv as NSDictionary).isEqual(to: rhv) + default: return false + } + } +} + /** Make AnyType Decodable */ @@ -33,22 +105,71 @@ extension AnyType: Decodable { - parameters: - decoder: A decoder to decode from */ + // swiftlint:disable cyclomatic_complexity public init(from decoder: Decoder) throws { if let dictionary = try? decoder.singleValueContainer().decode([String: String].self) { self = .dictionary(data: dictionary) return + } else if let dictionary = try? decoder.singleValueContainer().decode([String: Double].self) { + self = .numberDictionary(data: dictionary) + return + } else if let dictionary = try? decoder.singleValueContainer().decode([String: Bool].self) { + self = .booleanDictionary(data: dictionary) + return } else if let stringArray = try? decoder.singleValueContainer().decode([String].self) { self = .array(data: stringArray) return + } else if let numberArray = try? decoder.singleValueContainer().decode([Double].self) { + self = .numberArray(data: numberArray) + return + } else if let boolArray = try? decoder.singleValueContainer().decode([Bool].self) { + self = .booleanArray(data: boolArray) + return } else if let string = try? decoder.singleValueContainer().decode(String.self) { self = .string(data: string) return + } else if let bool = try? decoder.singleValueContainer().decode(Bool.self) { + self = .bool(data: bool) + return + } else if let number = try? decoder.singleValueContainer().decode(Double.self) { + self = .number(data: number) + return + } else if let context = decoder.userInfo[AnyTypeDecodingContext.key] as? AnyTypeDecodingContext { + let obj = try context.objectFor(path: decoder.singleValueContainer().codingPath) + if let dictionary = obj as? [String: Any] { + self = .anyDictionary(data: dictionary) + return + } } self = .unknownData return } } +// Custom CodingKey for dynamic key name +// and to try to coerce `Any` into `Encodable` +struct CustomEncodable: CodingKey { + var data: Encodable? + init(_ encodable: Any?, key: String) { + self.stringValue = key + if let encodable = encodable as? Encodable { + self.data = encodable + } + } + var stringValue: String + + init?(stringValue: String) { + return nil + } + + var intValue: Int? + + init?(intValue: Int) { + return nil + } + +} + /** Make AnyType Encodable */ @@ -63,13 +184,62 @@ extension AnyType: Encodable { switch self { case .string(let string): try container.encode(string) + case .bool(let boolean): + try container.encode(boolean) + case .number(let number): + try container.encode(number) case .array(let stringArray): try container.encode(stringArray) + case .numberArray(let numberArray): + try container.encode(numberArray) + case .booleanArray(let booleanArray): + try container.encode(booleanArray) case .dictionary(let dictionary): try container.encode(dictionary) + case .numberDictionary(let dictionary): + try container.encode(dictionary) + case .booleanDictionary(let dictionary): + try container.encode(dictionary) + case .anyDictionary(data: let dictionary): + var keyed = encoder.container(keyedBy: CustomEncodable.self) + for key in dictionary.keys { + let customEncodable = CustomEncodable(dictionary[key], key: key) + if let value = customEncodable.data { + try keyed.encode(value, forKey: customEncodable) + } + } default: try container.encodeNil() return } } } + +public struct AnyTypeDecodingContext { + static let key = CodingUserInfoKey(rawValue: "AnyTypeDecodingContext")! + + public var rawData: Data + + public init(rawData: Data) { + self.rawData = rawData + } + + public func objectFor(path: [CodingKey]) throws -> Any { + let jsonData = try JSONSerialization.jsonObject(with: rawData) + return traverse(path: path, in: jsonData) + } + + private func traverse(path: [CodingKey], in obj: Any) -> Any { + path.reduce(obj) { partialResult, key in + if let index = key.intValue { + return (partialResult as? [Any])?[index] as Any + } + return (partialResult as? [String: Any])?[key.stringValue] as Any + } + } + + public func inject(to decoder: JSONDecoder) -> JSONDecoder { + decoder.userInfo[AnyTypeDecodingContext.key] = self + return decoder + } +} diff --git a/ios/packages/core/Sources/Types/Hooks/FlowControllerHooks.swift b/ios/packages/core/Sources/Types/Hooks/FlowControllerHooks.swift index 5f69a9c31..fe8f45070 100644 --- a/ios/packages/core/Sources/Types/Hooks/FlowControllerHooks.swift +++ b/ios/packages/core/Sources/Types/Hooks/FlowControllerHooks.swift @@ -13,6 +13,6 @@ import JavaScriptCore This lets users tap into events in the JS environment */ public struct FlowControllerHooks { - /// Fired when the FlowController does a transition - var transition: Hook + /// Fired for new Flows + public var flow: Hook } diff --git a/ios/packages/core/Sources/Types/Hooks/Hook.swift b/ios/packages/core/Sources/Types/Hooks/Hook.swift index 4b60b0552..ca2dbf588 100644 --- a/ios/packages/core/Sources/Types/Hooks/Hook.swift +++ b/ios/packages/core/Sources/Types/Hooks/Hook.swift @@ -8,38 +8,34 @@ import Foundation import JavaScriptCore -/** - This class represents an object in the JS runtime that can be tapped into - to receive JS events - */ -public class Hook where T: CreatedFromJSValue { - /// The JS Binding to the hook object - private let hook: JSValue +/// A base for implementing JS backed hooks +open class BaseJSHook { + private let baseValue: JSValue + + /// The JS reference to the hook + public var hook: JSValue { baseValue.objectForKeyedSubscript("hooks").objectForKeyedSubscript(name) } - /// The JSContext in which the hook is bound - private let context: JSContext + /// The JSContext for the hook + public var context: JSContext { hook.context } /// The name of the hook - private let name: String + public let name: String - /** - Constructs a hook - - parameters: - - baseValue: The JS binding to the hooks object - - name: The name of the hook - */ + /// Retrieves a hook by name from an object in JS + /// - Parameters: + /// - baseValue: The object that has `hooks` + /// - name: The name of the hook public init(baseValue: JSValue, name: String) { - self.context = baseValue.context + self.baseValue = baseValue self.name = name - guard - let hooks = baseValue.objectForKeyedSubscript("hooks"), - let hookToTap = hooks.objectForKeyedSubscript(name) - else { - fatalError("Player hook not found: \(name)") - } - self.hook = hookToTap } +} +/** + This class represents an object in the JS runtime that can be tapped into + to receive JS events + */ +public class Hook: BaseJSHook where T: CreatedFromJSValue { /** Attach a closure to the hook, so when the hook is fired in the JS runtime we receive the event in the native runtime @@ -59,3 +55,30 @@ public class Hook where T: CreatedFromJSValue { 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 + */ +public class Hook2: BaseJSHook where T: CreatedFromJSValue, U: CreatedFromJSValue { + /** + 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 (T, U) -> Void) { + let tapMethod: @convention(block) (JSValue?, JSValue?) -> Void = { 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 } + hook(hookValue, hookValue2) + } + + self.hook.invokeMethod("tap", withArguments: [name, JSValue(object: tapMethod, in: context) as Any]) + } +} diff --git a/ios/packages/core/Sources/utilities/GenericInterfaces.swift b/ios/packages/core/Sources/utilities/GenericInterfaces.swift index c2810e600..fcc24c288 100644 --- a/ios/packages/core/Sources/utilities/GenericInterfaces.swift +++ b/ios/packages/core/Sources/utilities/GenericInterfaces.swift @@ -40,3 +40,10 @@ extension JSValue: CreatedFromJSValue { */ public static func createInstance(value: JSValue) -> JSValue { value } } + +extension Optional: CreatedFromJSValue where Wrapped: CreatedFromJSValue { + public static func createInstance(value: JSValue) -> Wrapped.T? { + guard !value.isUndefined else { return nil } + return Wrapped.createInstance(value: value) + } +} diff --git a/ios/packages/core/Sources/utilities/JSUtilities.swift b/ios/packages/core/Sources/utilities/JSUtilities.swift index b1408403d..0e13d3856 100644 --- a/ios/packages/core/Sources/utilities/JSUtilities.swift +++ b/ios/packages/core/Sources/utilities/JSUtilities.swift @@ -61,19 +61,6 @@ public class JSUtilities { } } -internal extension JSValue { - /// A JSON representation of this value - var jsonString: String { - guard !isUndefined, let obj = toObject() else { return "undefined" } - // only consider arrays and dictionaries, other objects are invalid top level JSON - guard obj is NSArray || obj is NSDictionary else { return toString() ?? "bad top level \(obj)" } - do { - let data = try JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted) - return String(data: data, encoding: .utf8) ?? "not utf8?" - } catch { return error.localizedDescription } - } -} - internal extension JSContext { func error(for error: E) -> JSValue? where E: Error, E: JSConvertibleError { objectForKeyedSubscript("Error").construct(withArguments: [error.jsDescription]) diff --git a/ios/packages/core/Tests/FlowStateTests.swift b/ios/packages/core/Tests/FlowStateTests.swift new file mode 100644 index 000000000..3ec183c1d --- /dev/null +++ b/ios/packages/core/Tests/FlowStateTests.swift @@ -0,0 +1,99 @@ +// +// FlowStateTests.swift +// PlayerUI_Tests +// +// Created by Harris Borawski on 3/23/23. +// + +import Foundation +import XCTest +import JavaScriptCore +@testable import PlayerUI + +class FlowStateTests: XCTestCase { + func testViewFlowState() { + let player = HeadlessPlayerImpl(plugins: []) + + player.start(flow: FlowData.COUNTER) { _ in} + + guard let inProgress = player.state as? InProgressState else { return XCTFail("State not in progress") } + + XCTAssertNotNil(inProgress.controllers?.flow.current?.currentState?.value as? NavigationFlowViewState) + let view = inProgress.controllers?.flow.current?.currentState?.value as? NavigationFlowViewState + + XCTAssertEqual(view?.attributes?["test"], "value") + } + + func testExternalFlowState() { + let player = HeadlessPlayerImpl(plugins: []) + + player.start(flow: FlowData.externalFlow) { _ in} + + guard let inProgress = player.state as? InProgressState else { return XCTFail("State not in progress") } + + XCTAssertNotNil(inProgress.controllers?.flow.current?.currentState?.value as? NavigationFlowExternalState) + } + + func testActionFlowState() { + let player = HeadlessPlayerImpl(plugins: []) + let hitActionNode = expectation(description: "ACTION state hit") + player.hooks?.flowController.tap({ flowController in + flowController.hooks.flow.tap { flow in + flow.hooks.transition.tap { oldState, _ in + guard + let old = oldState?.value as? NavigationFlowActionState, + case .single(let exp) = old.exp + else { return } + XCTAssertEqual(exp, "{{foo}}") + hitActionNode.fulfill() + } + } + }) + player.start(flow: FlowData.actionFlow) { _ in} + + wait(for: [hitActionNode], timeout: 1) + + } + + func testActionMultiExpFlowState() { + let player = HeadlessPlayerImpl(plugins: []) + let hitActionNode = expectation(description: "ACTION state hit") + player.hooks?.flowController.tap({ flowController in + flowController.hooks.flow.tap { flow in + flow.hooks.transition.tap { oldState, _ in + guard + let old = oldState?.value as? NavigationFlowActionState, + case .multi(let exp) = old.exp + else { return } + XCTAssertEqual(exp, ["{{foo}}", "{{bar}}"]) + hitActionNode.fulfill() + } + } + }) + player.start(flow: FlowData.actionMultiExpFlow) { _ in} + + wait(for: [hitActionNode], timeout: 1) + + } + + func testEndFlowState() { + let player = HeadlessPlayerImpl(plugins: []) + let endStateHit = expectation(description: "Flow Ended") + player.start(flow: FlowData.actionFlow) { result in + switch result { + case .success(let completed): + XCTAssertEqual(completed.endState?.outcome, "done") + XCTAssertEqual(completed.endState?.param?["someKey"] as? String, "someValue") + let extraKey: String? = completed.endState?.extraKey + XCTAssertEqual(extraKey, "extraValue") + let extraObject: [String: Any]? = completed.endState?.extraObject + XCTAssertEqual(extraObject?["someInt"] as? Int, 1) + endStateHit.fulfill() + default: XCTFail("Flow should have succeeded") + } + } + + wait(for: [endStateHit], timeout: 1) + + } +} diff --git a/ios/packages/core/Tests/Types/Core/BindingParserTests.swift b/ios/packages/core/Tests/Types/Core/BindingParserTests.swift new file mode 100644 index 000000000..670c92c3a --- /dev/null +++ b/ios/packages/core/Tests/Types/Core/BindingParserTests.swift @@ -0,0 +1,56 @@ +import XCTest +import JavaScriptCore +@testable import PlayerUI + +class LocalModelTests: XCTestCase { + func testLocalModel() { + let context = JSContext()! + + let model = LocalModel(data: ["foo": "bar"], in: context) + + XCTAssertEqual("bar", model.get(path: "foo")?.toString()) + + model.set(transaction: [(BindingInstance(rawBinding: "baz.bar", in: context), "test")]) + + XCTAssertEqual("test", model.get(path: "baz.bar")?.toString()) + } +} + +class BindingParserTests: XCTestCase { + func testBindingParser() { + let context = JSContext()! + + let options = BindingParserOptions(get: { _ in nil }) + let parser = BindingParser(options: options, in: context) + + XCTAssertEqual(["foo"], parser.parse(path: "foo")?.asArray()) + XCTAssertEqual(["foo", "bar"], parser.parse(path: "foo.bar")?.asArray()) + XCTAssertEqual(["baz", "1", "key"], parser.parse(path: "baz[1].key")?.asArray()) + } + + func testBindingParserThroughModel() { + let context = JSContext()! + + let model = LocalModel(data: ["foo": ["baz": "value"], "bar": "baz"], in: context) + + let options = BindingParserOptions(get: { model.get(binding: $0) }) + let parser = BindingParser(options: options, in: context) + + XCTAssertEqual(["foo"], parser.parse(path: "foo")?.asArray()) + XCTAssertEqual(["foo", "bar"], parser.parse(path: "foo.bar")?.asArray()) + + XCTAssertEqual("value", parser.parse(path: "foo.{{bar}}").map { model.get(binding: $0)?.toString() }) + } +} + +class BindingInstanceTests: XCTestCase { + func testSimpleBinding() { + let context = JSContext()! + + let binding = BindingInstance(rawBinding: "foo.bar", in: context) + + XCTAssertEqual(["foo", "bar"], binding.asArray()) + + XCTAssertEqual("foo.bar", binding.asString()) + } +} diff --git a/ios/packages/core/Tests/Types/Generic/AnyTypeTests.swift b/ios/packages/core/Tests/Types/Generic/AnyTypeTests.swift index 90866c615..5ba059f38 100644 --- a/ios/packages/core/Tests/Types/Generic/AnyTypeTests.swift +++ b/ios/packages/core/Tests/Types/Generic/AnyTypeTests.swift @@ -25,6 +25,48 @@ class AnyTypeTests: XCTestCase { } } + func testBoolData() { + let string = "true" + guard + let data = string.data(using: .utf8), + let anyType = try? JSONDecoder().decode(AnyType.self, from: data) + else { return XCTFail("could not decode") } + switch anyType { + case .bool(let result): + XCTAssertEqual(true, result) + default: + XCTFail("data was not string") + } + } + + func testNumberDataNoDecimal() { + let string = "1" + guard + let data = string.data(using: .utf8), + let anyType = try? JSONDecoder().decode(AnyType.self, from: data) + else { return XCTFail("could not decode") } + switch anyType { + case .number(let result): + XCTAssertEqual(1, result) + default: + XCTFail("data was not string") + } + } + + func testNumberDataDecimal() { + let string = "1.5" + guard + let data = string.data(using: .utf8), + let anyType = try? JSONDecoder().decode(AnyType.self, from: data) + else { return XCTFail("could not decode") } + switch anyType { + case .number(let result): + XCTAssertEqual(1.5, result) + default: + XCTFail("data was not string") + } + } + func testArrayData() { let string = "[\"test\", \"data\"]" guard @@ -39,6 +81,34 @@ class AnyTypeTests: XCTestCase { } } + func testNumberArrayData() { + let string = "[1, 2]" + guard + let data = string.data(using: .utf8), + let anyType = try? JSONDecoder().decode(AnyType.self, from: data) + else { return XCTFail("could not decode") } + switch anyType { + case .numberArray(let result): + XCTAssertEqual([1, 2], result) + default: + XCTFail("data was not array") + } + } + + func testBoolArrayData() { + let string = "[false, true]" + guard + let data = string.data(using: .utf8), + let anyType = try? JSONDecoder().decode(AnyType.self, from: data) + else { return XCTFail("could not decode") } + switch anyType { + case .booleanArray(let result): + XCTAssertEqual([false, true], result) + default: + XCTFail("data was not array") + } + } + func testDictionaryData() { let string = "{\"key\":\"value\"}" guard @@ -53,8 +123,51 @@ class AnyTypeTests: XCTestCase { } } + func testNumberDictionaryData() { + let string = "{\"key\":1}" + guard + let data = string.data(using: .utf8), + let anyType = try? JSONDecoder().decode(AnyType.self, from: data) + else { return XCTFail("could not decode") } + switch anyType { + case .numberDictionary(let result): + XCTAssertEqual(1, result["key"]) + default: + XCTFail("data was not dictionary") + } + } + + func testBoolDictionaryData() { + let string = "{\"key\":false}" + guard + let data = string.data(using: .utf8), + let anyType = try? JSONDecoder().decode(AnyType.self, from: data) + else { return XCTFail("could not decode") } + switch anyType { + case .booleanDictionary(let result): + XCTAssertEqual(false, result["key"]) + default: + XCTFail("data was not dictionary") + } + } + + func testAnyDictionaryData() { + let string = "{\"key\":false,\"key2\":1}" + guard + let data = string.data(using: .utf8), + let anyType = try? AnyTypeDecodingContext(rawData: string.data(using: .utf8)!).inject(to: JSONDecoder()).decode(AnyType.self, from: data) + else { return XCTFail("could not decode") } + switch anyType { + case .anyDictionary(let result): + XCTAssertEqual(false, result["key"] as? Bool) + XCTAssertEqual(1, result["key2"] as? Double) + default: + XCTFail("data was not dictionary") + } + } + func testUnknownData() { - let string = "1" + let string = "{\"key\":\"value\", \"key2\": 2}" guard let data = string.data(using: .utf8), let anyType = try? JSONDecoder().decode(AnyType.self, from: data) @@ -76,4 +189,97 @@ class AnyTypeTests: XCTestCase { XCTAssertNotNil(data) } + + func testEncode() { + XCTAssertEqual("\"test\"", doEncode(AnyType.string(data: "test"))) + XCTAssertEqual("1", doEncode(AnyType.number(data: 1))) + XCTAssertEqual("1.5", doEncode(AnyType.number(data: 1.5))) + XCTAssertEqual("false", doEncode(AnyType.bool(data: false))) + XCTAssertEqual("{\"key\":\"value\"}", doEncode(AnyType.dictionary(data: ["key": "value"]))) + XCTAssertEqual("{\"key\":1}", doEncode(AnyType.numberDictionary(data: ["key": 1]))) + XCTAssertEqual("{\"key\":1.5}", doEncode(AnyType.numberDictionary(data: ["key": 1.5]))) + XCTAssertEqual("{\"key\":false}", doEncode(AnyType.booleanDictionary(data: ["key": false]))) + XCTAssertEqual("[\"test\",\"data\"]", doEncode(AnyType.array(data: ["test", "data"]))) + XCTAssertEqual("[1,2]", doEncode(AnyType.numberArray(data: [1, 2]))) + XCTAssertEqual("[false,true]", doEncode(AnyType.booleanArray(data: [false, true]))) + XCTAssertEqual("{\"key\":false,\"key2\":1}", doEncode(AnyType.anyDictionary(data: ["key": false, "key2": 1]))) + } + + func doEncode(_ data: AnyType) -> String? { + let data = try? JSONEncoder().encode(data) + guard let data = data else { return nil } + return String(data: data, encoding: .utf8) + } + + func testCustomEncodable() { + XCTAssertNil(CustomEncodable(stringValue: "test")) + XCTAssertNil(CustomEncodable(intValue: 1)) + } + + func testHash() { + XCTAssertNotEqual(AnyType.string(data: "test").hashValue, 0) + XCTAssertNotEqual(AnyType.number(data: 1).hashValue, 0) + XCTAssertNotEqual(AnyType.number(data: 1.5).hashValue, 0) + XCTAssertNotEqual(AnyType.bool(data: false).hashValue, 0) + XCTAssertNotEqual(AnyType.dictionary(data: ["key": "value"]).hashValue, 0) + XCTAssertNotEqual(AnyType.numberDictionary(data: ["key": 1]).hashValue, 0) + XCTAssertNotEqual(AnyType.numberDictionary(data: ["key": 1.5]).hashValue, 0) + XCTAssertNotEqual(AnyType.booleanDictionary(data: ["key": false]).hashValue, 0) + XCTAssertNotEqual(AnyType.array(data: ["test", "data"]).hashValue, 0) + XCTAssertNotEqual(AnyType.numberArray(data: [1, 2]).hashValue, 0) + XCTAssertNotEqual(AnyType.booleanArray(data: [false, true]).hashValue, 0) + XCTAssertNotEqual(AnyType.anyDictionary(data: ["key": false, "key2": 1]).hashValue, 0) + XCTAssertNotEqual(AnyType.unknownData.hashValue, 0) + } + + func testEquality() { + XCTAssertEqual(AnyType.string(data: "test"), AnyType.string(data: "test")) + XCTAssertEqual(AnyType.number(data: 1), AnyType.number(data: 1)) + XCTAssertEqual(AnyType.number(data: 1.5), AnyType.number(data: 1.5)) + XCTAssertEqual(AnyType.bool(data: false), AnyType.bool(data: false)) + XCTAssertEqual(AnyType.dictionary(data: ["key": "value"]), AnyType.dictionary(data: ["key": "value"])) + XCTAssertEqual(AnyType.numberDictionary(data: ["key": 1]), AnyType.numberDictionary(data: ["key": 1])) + XCTAssertEqual(AnyType.numberDictionary(data: ["key": 1.5]), AnyType.numberDictionary(data: ["key": 1.5])) + XCTAssertEqual(AnyType.booleanDictionary(data: ["key": false]), AnyType.booleanDictionary(data: ["key": false])) + XCTAssertEqual(AnyType.array(data: ["test", "data"]), AnyType.array(data: ["test", "data"])) + XCTAssertEqual(AnyType.numberArray(data: [1, 2]), AnyType.numberArray(data: [1, 2])) + XCTAssertEqual(AnyType.booleanArray(data: [false, true]), AnyType.booleanArray(data: [false, true])) + XCTAssertEqual(AnyType.anyDictionary(data: ["key": false, "key2": 1]), AnyType.anyDictionary(data: ["key": false, "key2": 1])) + XCTAssertNotEqual(AnyType.unknownData, AnyType.string(data: "test")) + } + + func testDecodingContext() throws { + let structure = [ + "object": [ + "key1": [ + 5 + ] + ] + ] + + struct TestCodingKey: CodingKey { + init?(stringValue: String) { + self.stringValue = stringValue + intValue = nil + } + + init?(intValue: Int) { + stringValue = "\(intValue)" + self.intValue = intValue + } + + var stringValue: String + var intValue: Int? + } + let data = try JSONSerialization.data(withJSONObject: structure) + let context = AnyTypeDecodingContext(rawData: data) + + let object = try context.objectFor(path: [ + TestCodingKey(stringValue: "object")!, + TestCodingKey(stringValue: "key1")!, + TestCodingKey(intValue: 0)! + ]) + + XCTAssertEqual(object as? Double, 5) + } } diff --git a/ios/packages/core/Tests/utilities/JSUtilitiesTests.swift b/ios/packages/core/Tests/utilities/JSUtilitiesTests.swift index 11546b4d3..4ebc4f146 100644 --- a/ios/packages/core/Tests/utilities/JSUtilitiesTests.swift +++ b/ios/packages/core/Tests/utilities/JSUtilitiesTests.swift @@ -70,19 +70,31 @@ class JSUtilitiesTests: XCTestCase { func testJsonStringPretty() { let context = JSContext()! -// let undef = context.evaluateScript("(undefined)") - let obj = context.evaluateScript("({a: 1})") - let array = context.evaluateScript("(['a', 'b'])") + let objWithNaN = context.evaluateScript("({a: NaN})") -// let str = context.evaluateScript("('a')") + let objWithFunction = context.evaluateScript("({a: () => {}})") + + let array = context.evaluateScript("(['a', 'b'])") -// XCTAssertEqual(undef?.jsonDisplayString, "undefined") + let str = context.evaluateScript("('a')") XCTAssertEqual(obj?.jsonDisplayString, """ { - "a" : 1 + "a": 1 + } + """) + + XCTAssertEqual(objWithNaN?.jsonDisplayString, """ + { + "a": null + } + """) + + XCTAssertEqual(objWithFunction?.jsonDisplayString, """ + { + "a": {} } """) @@ -93,6 +105,38 @@ class JSUtilitiesTests: XCTestCase { ] """) -// XCTAssertEqual(str?.jsonDisplayString, "a") + XCTAssertEqual(str?.jsonDisplayString, "\"a\"") + } + + func testJsonData() throws { + let context = JSContext()! + + let obj = context.evaluateScript("({a: 1})") + + let objWithNaN = context.evaluateScript("({a: NaN})") + + let objWithFunction = context.evaluateScript("({a: () => {}})") + + let array = context.evaluateScript("(['a', 'b'])") + + let str = context.evaluateScript("('a')") + + XCTAssertEqual(try obj?.jsonData(), """ + {"a":1} + """.data(using: .utf8)) + + XCTAssertEqual(try objWithNaN?.jsonData(), """ + {"a":null} + """.data(using: .utf8)) + + XCTAssertEqual(try objWithFunction?.jsonData(), """ + {"a":{}} + """.data(using: .utf8)) + + XCTAssertEqual(try array?.jsonData(), """ + ["a","b"] + """.data(using: .utf8)) + + XCTAssertEqual(try str?.jsonData(), "\"a\"".data(using: .utf8)) } } diff --git a/ios/packages/internal-test-utils/Sources/FlowData.swift b/ios/packages/internal-test-utils/Sources/FlowData.swift index 42eecfacb..89cbc3a4c 100644 --- a/ios/packages/internal-test-utils/Sources/FlowData.swift +++ b/ios/packages/internal-test-utils/Sources/FlowData.swift @@ -26,6 +26,92 @@ public struct FlowData { "VIEW_1": { "state_type": "VIEW", "ref": "action", + "transitions": { + "*": "END_Done" + }, + "attributes": { "test": "value" } + }, + "END_Done": { + "state_type": "END", + "outcome": "done", + "param": { + "someKey": "someValue" + }, + "extraKey": "extraValue", + "extraObject": { + "someInt": 1 + } + } + } + } +} +""" + + public static let externalFlow: String = """ +{ + "id": "counter-flow", + "views": [], + "navigation": { + "BEGIN": "FLOW_1", + "FLOW_1": { + "startState": "EXTERNAL_1", + "EXTERNAL_1": { + "state_type": "EXTERNAL", + "ref": "action", + "transitions": { + "*": "END_Done" + } + }, + "END_Done": { + "state_type": "END", + "outcome": "done" + } + } + } +} +""" + + public static let actionFlow: String = """ +{ + "id": "counter-flow", + "views": [], + "navigation": { + "BEGIN": "FLOW_1", + "FLOW_1": { + "startState": "ACTION_1", + "ACTION_1": { + "state_type": "ACTION", + "exp": "{{foo}}", + "transitions": { + "*": "END_Done" + } + }, + "END_Done": { + "state_type": "END", + "outcome": "done", + "param": { + "someKey": "someValue" + }, + "extraKey": "extraValue", + "extraObject": { + "someInt": 1 + } + } + } + } +} +""" + public static let actionMultiExpFlow: String = """ +{ + "id": "counter-flow", + "views": [], + "navigation": { + "BEGIN": "FLOW_1", + "FLOW_1": { + "startState": "ACTION_1", + "ACTION_1": { + "state_type": "ACTION", + "exp": ["{{foo}}", "{{bar}}"], "transitions": { "*": "END_Done" } diff --git a/ios/packages/swiftui/Sources/types/WrappedFunction.swift b/ios/packages/swiftui/Sources/types/WrappedFunction.swift index 19735583a..692a95f47 100644 --- a/ios/packages/swiftui/Sources/types/WrappedFunction.swift +++ b/ios/packages/swiftui/Sources/types/WrappedFunction.swift @@ -36,6 +36,15 @@ public struct WrappedFunction: JSValueBacked, Decodable, Hashable { let val = jsValue.call(withArguments: args) return val?.toObject() as? T } + + /** + Executes the function and returns the customType specified + */ + public func callAsFunction(customType: T.Type, args: Any...) throws -> T? where T: Decodable { + guard let jsValue = rawValue else { throw DecodingError.malformedData } + let decodedState = try JSONDecoder().decode(customType, from: jsValue.call(withArguments: args)) + return decodedState + } } /** diff --git a/ios/packages/swiftui/Tests/types/WrappedFunctionTests.swift b/ios/packages/swiftui/Tests/types/WrappedFunctionTests.swift index ddaa5e6a6..cdef07dcd 100644 --- a/ios/packages/swiftui/Tests/types/WrappedFunctionTests.swift +++ b/ios/packages/swiftui/Tests/types/WrappedFunctionTests.swift @@ -26,6 +26,45 @@ class WrappedFunctionTests: XCTestCase { wait(for: [called], timeout: 1) } + struct CustomStruct: Decodable, Hashable, Encodable { + var someString: String + } + + func testWrappedFunctionWithCustomType() { + let called = expectation(description: "Function Called") + let callback: @convention(block) (JSValue) -> JSValue = { _ in + called.fulfill() + return self.context.evaluateScript("({someString: 'test'})") + } + + let function = JSValue(object: callback, in: context) + let wrapper = WrappedFunction(rawValue: function) + + do { + let customStruct = try wrapper.callAsFunction(customType: CustomStruct.self) + XCTAssertEqual(customStruct, CustomStruct(someString: "test")) + } catch { + XCTFail("could not call wrapped function with custom type") + } + + wait(for: [called], timeout: 1) + } + + func testWrappedFunctionThrowsError() { + let called = expectation(description: "Function Called") + let callback: @convention(block) (JSValue) -> JSValue = { _ in + called.fulfill() + return self.context.evaluateScript("({someStringWrong: 'test'})") + } + + let function = JSValue(object: callback, in: context) + let wrapper = WrappedFunction(rawValue: function) + + XCTAssertThrowsError(try wrapper.callAsFunction(customType: CustomStruct.self)) + + wait(for: [called], timeout: 1) + } + func testModelReference() throws { let context = JSContext() guard let val = JSValue(object: "Hello World", in: context!) else { diff --git a/ios/plugins/BaseBeaconPlugin/Sources/BaseBeaconPlugin.swift b/ios/plugins/BaseBeaconPlugin/Sources/BaseBeaconPlugin.swift index f14407aad..860fbe71b 100644 --- a/ios/plugins/BaseBeaconPlugin/Sources/BaseBeaconPlugin.swift +++ b/ios/plugins/BaseBeaconPlugin/Sources/BaseBeaconPlugin.swift @@ -11,7 +11,7 @@ import JavaScriptCore /** Represenation of a Beacon coming from Player */ -public struct DefaultBeacon: Decodable { +public struct DefaultBeacon: Codable, Hashable { /// The action the user performed public let action: String @@ -26,6 +26,22 @@ public struct DefaultBeacon: Decodable { /// Additional data added from the asset or metaData public let data: AnyType? + + + /// Construct a ``DefaultBeacon`` + /// - Parameters: + /// - action: The action the user performed + /// - element: The element that performed the action + /// - assetId: The ID of the asset triggering the beacon + /// - viewId: The ID of the view triggering the beacon + /// - data: Additional data added from the asset or metaData + public init(action: String, element: String, assetId: String?, viewId: String?, data: AnyType?) { + self.action = action + self.element = element + self.assetId = assetId + self.viewId = viewId + self.data = data + } } /** @@ -73,7 +89,9 @@ open class BaseBeaconPlugin: JSBasePlugin { guard let object = rawBeacon?.toObject(), let data = try? JSONSerialization.data(withJSONObject: object), - let beacon = try? JSONDecoder().decode(BeaconStruct.self, from: data) + let beacon = try? AnyTypeDecodingContext(rawData: data) + .inject(to: JSONDecoder()) + .decode(BeaconStruct.self, from: data) else { return } self.callback?(beacon) } diff --git a/ios/plugins/BaseBeaconPlugin/Tests/BaseBeaconPluginTests.swift b/ios/plugins/BaseBeaconPlugin/Tests/BaseBeaconPluginTests.swift index e18d69df1..ef8ffea9d 100644 --- a/ios/plugins/BaseBeaconPlugin/Tests/BaseBeaconPluginTests.swift +++ b/ios/plugins/BaseBeaconPlugin/Tests/BaseBeaconPluginTests.swift @@ -41,4 +41,87 @@ class BaseBeaconPluginTests: XCTestCase { plugin.beacon(assetBeacon: AssetBeacon(action: "action", element: "element", asset: BeaconableAsset(id: "id"))) wait(for: [beaconed], timeout: 1) } + + func testBeaconPluginStringData() { + let context = JSContext()! + JSUtilities.polyfill(context) + + let expectation = XCTestExpectation(description: "beacon callback called") + let plugin = BaseBeaconPlugin( onBeacon: { (beacon) in + XCTAssertEqual(beacon.assetId, "test") + XCTAssertEqual(beacon.element, BeaconElement.button.rawValue) + switch beacon.data { + case .string(let string): + XCTAssertEqual(string, "example") + default: + XCTFail("beacon data was not a string") + } + expectation.fulfill() + }) + plugin.context = context + + plugin.beacon(assetBeacon: AssetBeacon( + action: BeaconAction.clicked.rawValue, + element: BeaconElement.button.rawValue, + asset: BeaconableAsset(id: "test"), + data: .string(data: "example") + )) + wait(for: [expectation], timeout: 2) + } + + func testBeaconPluginDictionaryData() { + let context = JSContext()! + JSUtilities.polyfill(context) + + let expectation = XCTestExpectation(description: "beacon callback called") + let plugin = BaseBeaconPlugin(onBeacon: { (beacon) in + XCTAssertEqual(beacon.assetId, "test") + XCTAssertEqual(beacon.element, BeaconElement.button.rawValue) + switch beacon.data { + case .dictionary(let dict): + XCTAssertEqual(dict, ["data": "example"]) + default: + XCTFail("beacon data was not a dictionary") + } + expectation.fulfill() + }) + plugin.context = context + + plugin.beacon(assetBeacon: AssetBeacon( + action: BeaconAction.clicked.rawValue, + element: BeaconElement.button.rawValue, + asset: BeaconableAsset(id: "test"), + data: .dictionary(data: ["data": "example"]) + )) + wait(for: [expectation], timeout: 2) + } + + func testBeaconPluginAnyDictionaryData() { + let context = JSContext()! + JSUtilities.polyfill(context) + + let expectation = XCTestExpectation(description: "beacon callback called") + let plugin = BaseBeaconPlugin(onBeacon: { (beacon) in + XCTAssertEqual(beacon.assetId, "test") + XCTAssertEqual(beacon.element, BeaconElement.button.rawValue) + switch beacon.data { + case .anyDictionary(let dict): + XCTAssertEqual(2, dict.keys.count) + XCTAssertEqual("example", dict["data"] as? String) + XCTAssertEqual(3, dict["value"] as? Double) + default: + XCTFail("beacon data was not anyDictionary") + } + expectation.fulfill() + }) + plugin.context = context + + plugin.beacon(assetBeacon: AssetBeacon( + action: BeaconAction.clicked.rawValue, + element: BeaconElement.button.rawValue, + asset: BeaconableAsset(id: "test"), + data: .anyDictionary(data: ["data": "example", "value": 3]) + )) + wait(for: [expectation], timeout: 2) + } } diff --git a/ios/plugins/CheckPathPlugin/Sources/CheckPathPlugin.swift b/ios/plugins/CheckPathPlugin/Sources/CheckPathPlugin.swift index 5bb6df1f6..c84a0d7e7 100644 --- a/ios/plugins/CheckPathPlugin/Sources/CheckPathPlugin.swift +++ b/ios/plugins/CheckPathPlugin/Sources/CheckPathPlugin.swift @@ -7,17 +7,8 @@ import Foundation -/** - A plugin that can query the asset tree for contextual information about the hierarchy - */ -public class CheckPathPlugin: JSBasePlugin, NativePlugin { - /** - Constructs the CheckPathPlugin - */ - public convenience init() { - self.init(fileName: "check-path-plugin.prod", pluginName: "CheckPathPlugin.CheckPathPlugin") - } - +/// Base functionality for CheckPath +open class BaseCheckPathPlugin: JSBasePlugin { /** The getParent method allows you to query up the tree and return the first parent that matches the given query if such exists. In case when query is not provided, the closest parent returned. @@ -66,6 +57,18 @@ public class CheckPathPlugin: JSBasePlugin, NativePlugin { } override open func getUrlForFile(fileName: String) -> URL? { - ResourceUtilities.urlForFile(name: fileName, ext: "js", bundle: Bundle(for: CheckPathPlugin.self), pathComponent: "CheckPathPlugin.bundle") + ResourceUtilities.urlForFile(name: fileName, ext: "js", bundle: Bundle(for: BaseCheckPathPlugin.self), pathComponent: "CheckPathPlugin.bundle") + } +} + +/** + A plugin that can query the asset tree for contextual information about the hierarchy + */ +open class CheckPathPlugin: BaseCheckPathPlugin, NativePlugin { + /** + Constructs the CheckPathPlugin + */ + public convenience init() { + self.init(fileName: "check-path-plugin.prod", pluginName: "CheckPathPlugin.CheckPathPlugin") } } diff --git a/ios/plugins/ExternalActionPlugin/Sources/ExternalActionPlugin.swift b/ios/plugins/ExternalActionPlugin/Sources/ExternalActionPlugin.swift index d4bbdce8e..1cc9cb02c 100644 --- a/ios/plugins/ExternalActionPlugin/Sources/ExternalActionPlugin.swift +++ b/ios/plugins/ExternalActionPlugin/Sources/ExternalActionPlugin.swift @@ -48,7 +48,7 @@ public class ExternalActionPlugin: JSBasePlugin, NativePlugin { let controllers = PlayerControllers(from: options), let promise = JSUtilities.createPromise(context: context, handler: { (resolve, reject) in do { - try self.handler?(NavigationFlowExternalState(from: state), controllers) { transition in + try self.handler?(NavigationFlowExternalState(state), controllers) { transition in resolve(transition) } } catch { diff --git a/ios/plugins/ExternalActionPlugin/Sources/NavigationFlowExternalState.swift b/ios/plugins/ExternalActionPlugin/Sources/NavigationFlowExternalState.swift deleted file mode 100644 index efe8ed07d..000000000 --- a/ios/plugins/ExternalActionPlugin/Sources/NavigationFlowExternalState.swift +++ /dev/null @@ -1,32 +0,0 @@ -import JavaScriptCore -/** - A structure representing the External state - */ -@dynamicMemberLookup -public struct NavigationFlowExternalState { - /** - The full state object has some defined properties, but can also have any additional properties that are - added in the JSON - */ - private var fullState: [String: Any] - - /// The transitions that are associated with this state - public var transitions: [String: String]? { fullState["transitions"] as? [String: String] } - - /// A reference for this external state - public var ref: String? { fullState["ref"] as? String } - - init(from value: JSValue) { - self.fullState = value.toObject() as? [String: Any] ?? [:] - } - - /** - Subscript function to allow fetching any additional properties that the state might have - - parameters: - - member: The name of the member to access - - returns: The member cast to the receiving type if it exists - */ - public subscript(dynamicMember member: String) -> T? { - return fullState[member] as? T - } -} diff --git a/ios/plugins/ExternalActionViewModifierPlugin/Sources/ExternalActionViewModifierPlugin.swift b/ios/plugins/ExternalActionViewModifierPlugin/Sources/ExternalActionViewModifierPlugin.swift index 89df3920f..d8fc6f1f0 100644 --- a/ios/plugins/ExternalActionViewModifierPlugin/Sources/ExternalActionViewModifierPlugin.swift +++ b/ios/plugins/ExternalActionViewModifierPlugin/Sources/ExternalActionViewModifierPlugin.swift @@ -40,6 +40,21 @@ open class ExternalActionViewModifierPlugin AnyView in return AnyView(view.modifier(ModifierType.init(plugin: self))) }) + + // If the state changes without our intervention + // we should update our state + player.hooks?.flowController.tap({ flowController in + flowController.hooks.flow.tap { flow in + flow.hooks.transition.tap {[weak self] old, newState in + guard + old?.value?.stateType == "EXTERNAL", + newState.value?.stateType != "EXTERNAL" + else { return } + self?.isExternalState = false + self?.state = nil + } + } + }) } /** @@ -54,7 +69,7 @@ open class ExternalActionViewModifierPlugin { (state, _, transition) in + XCTAssertEqual(state.transitions, ["Next": "VIEW_1", "Prev": "END_BCK"]) + XCTAssertEqual(state.ref, "test-1") + // Test out subscript fetching additional properties + let extra: String? = state.extraProperty + XCTAssertEqual(extra, "extraValue") + handlerExpectation.fulfill() + return AnyView(Text("External State")) + } + + let context = SwiftUIPlayer.Context() + + let player = SwiftUIPlayer(flow: json, plugins: [ReferenceAssetsPlugin(), plugin], result: Binding(get: {nil}, set: { (result) in + switch result { + case .success: + completionExpectation.fulfill() + default: + break + } + }), context: context, unloadOnDisappear: false) + + ViewHosting.host(view: player) + + let exp = player.inspection.inspect(after: 0.5) { view in + XCTAssertNotNil(plugin.state) + let content = try view.vStack().first?.anyView().anyView().modifier(ExternalStateSheetModifier.self).viewModifierContent() + let value = try content?.sheet().anyView().text().string() + XCTAssertEqual(value, "External State") + (try view.actualView().state as? InProgressState)?.controllers?.flow.transition(with: "Next") + } + + wait(for: [exp, handlerExpectation], timeout: 10) + let state = player.state as? InProgressState + XCTAssertNotNil(state) + XCTAssertEqual(state?.controllers?.flow.current?.currentState?.value?.stateType, "VIEW") + XCTAssertNil(plugin.state) + XCTAssertFalse(plugin.isExternalState) + state?.controllers?.flow.transition(with: "Next") + wait(for: [completionExpectation], timeout: 10) + + ViewHosting.expel() + } + func testExternalStateHandlingThrowsError() throws { let json = """ { @@ -132,16 +220,17 @@ class ExternalActionViewModifierPluginTests: ViewInspectorTestCase { throw PlayerError.jsConversionFailure } + let context = SwiftUIPlayer.Context() + let player = SwiftUIPlayer(flow: json, plugins: [ReferenceAssetsPlugin(), plugin], result: Binding(get: {nil}, set: { (result) in guard result != nil else { return } switch result { case .success: XCTFail("Should have failed") - case .failure: + default: completionExpectation.fulfill() - default: break } - })) + }), context: context, unloadOnDisappear: false) ViewHosting.host(view: player) diff --git a/ios/plugins/MetricsPlugin/Sources/MetricsPlugin.swift b/ios/plugins/MetricsPlugin/Sources/MetricsPlugin.swift index cbb4deef1..083707c1e 100644 --- a/ios/plugins/MetricsPlugin/Sources/MetricsPlugin.swift +++ b/ios/plugins/MetricsPlugin/Sources/MetricsPlugin.swift @@ -134,9 +134,9 @@ public struct NodeRenderMetrics: Decodable { /// the name of the flow-state public let stateName: String /// Timing representing the initial render - public let render: MetricsTiming + public let render: MetricsTiming? /// An array of timings representing updates to the view - public let updates: [MetricsTiming] + public let updates: [MetricsTiming]? } public struct MetricsFlow: Decodable { diff --git a/ios/plugins/PubSubPlugin/Sources/PubSubPlugin.swift b/ios/plugins/PubSubPlugin/Sources/PubSubPlugin.swift index 72d149b1b..6cfda1917 100644 --- a/ios/plugins/PubSubPlugin/Sources/PubSubPlugin.swift +++ b/ios/plugins/PubSubPlugin/Sources/PubSubPlugin.swift @@ -86,7 +86,9 @@ public class PubSubPlugin: JSBasePlugin, NativePlugin { } else if let object = data?.toObject(), let objectData = try? JSONSerialization.data(withJSONObject: object), - let eventData = try? JSONDecoder().decode(AnyType.self, from: objectData) + let eventData = try? AnyTypeDecodingContext(rawData: objectData) + .inject(to: JSONDecoder()) + .decode(AnyType.self, from: objectData) { callback(name, eventData) } else { diff --git a/ios/plugins/PubSubPlugin/Tests/PubSubPluginTests.swift b/ios/plugins/PubSubPlugin/Tests/PubSubPluginTests.swift index f1ee08d6b..6abb92ac7 100644 --- a/ios/plugins/PubSubPlugin/Tests/PubSubPluginTests.swift +++ b/ios/plugins/PubSubPlugin/Tests/PubSubPluginTests.swift @@ -173,4 +173,29 @@ class PubSubPluginTests: XCTestCase { plugin.publish(eventName: "test", eventData: .dictionary(data: ["example": "data"])) wait(for: [expectation], timeout: 2) } + + func testPubSubPluginAnyDictionaryData() { + let context = JSContext()! + JSUtilities.polyfill(context) + + let expectation = XCTestExpectation(description: "beacon callback called") + let subscription: PubSubSubscription = ("test", { (_, data) in + guard let eventData = data else { return XCTFail("data did not exist") } + switch eventData { + case .anyDictionary(let dict): + XCTAssertEqual(2, dict.keys.count) + XCTAssertEqual("example", dict["data"] as? String) + XCTAssertEqual(3, dict["value"] as? Double) + default: + XCTFail("data was not anyDictionary") + } + expectation.fulfill() + }) + + let plugin = PubSubPlugin([subscription]) + plugin.context = context + + plugin.publish(eventName: "test", eventData: .anyDictionary(data: ["data": "example", "value": 3])) + wait(for: [expectation], timeout: 2) + } } diff --git a/ios/plugins/SwiftUICheckPathPlugin/Sources/SwiftUICheckPathPlugin.swift b/ios/plugins/SwiftUICheckPathPlugin/Sources/SwiftUICheckPathPlugin.swift new file mode 100644 index 000000000..b04025315 --- /dev/null +++ b/ios/plugins/SwiftUICheckPathPlugin/Sources/SwiftUICheckPathPlugin.swift @@ -0,0 +1,31 @@ +import Foundation +import SwiftUI + +/// SwiftUI Version of `CheckPathPlugin` that puts itself into `\.checkPath` in EnvironmentValues +public class SwiftUICheckPathPlugin: BaseCheckPathPlugin, NativePlugin { + /** + Constructs the SwiftUICheckPathPlugin + */ + public convenience init() { + self.init(fileName: "check-path-plugin.prod", pluginName: "CheckPathPlugin.CheckPathPlugin") + } + + public func apply

(player: P) where P: HeadlessPlayer { + guard let player = player as? SwiftUIPlayer else { return } + player.hooks?.view.tap(name: self.pluginName) { view in + AnyView(view.environment(\.checkPath, self)) + } + } +} + +struct CheckPathPluginKey: EnvironmentKey { + static var defaultValue: BaseCheckPathPlugin? +} + +public extension EnvironmentValues { + /// The `BaseCheckPathPlugin` for this player instance if one was included + internal(set) var checkPath: BaseCheckPathPlugin? { + get { self[CheckPathPluginKey.self] } + set { self[CheckPathPluginKey.self] = newValue } + } +} diff --git a/ios/plugins/SwiftUICheckPathPlugin/ViewInspector/SwiftUICheckPathPluginTests.swift b/ios/plugins/SwiftUICheckPathPlugin/ViewInspector/SwiftUICheckPathPluginTests.swift new file mode 100644 index 000000000..341960e56 --- /dev/null +++ b/ios/plugins/SwiftUICheckPathPlugin/ViewInspector/SwiftUICheckPathPluginTests.swift @@ -0,0 +1,41 @@ +import Foundation +import XCTest +import SwiftUI +import ViewInspector +@testable import PlayerUI + +class SwiftUICheckPathPluginTests: XCTestCase { + func testContextAttachment() throws { + let player = SwiftUIPlayer(flow: FlowData.COUNTER, plugins: [SwiftUICheckPathPlugin()]) + var baseView = CheckPathTestAssetView() + + let appear = baseView.on(\.didAppear) { view in + let value = try view.actualView().checkPath + guard let value = value else { return } + XCTAssertEqual(value.getParentProp(id: "action-label"), "label") + XCTAssertTrue(value.hasParentContext(id: "action-label", query: "action")) + XCTAssertNotNil(value.getParentContext(id: "action-label", query: "action")) + } + + guard let view: AnyView = player.hooks?.view.call(AnyView(baseView)) else { + return XCTFail("no view returned from hook") + } + + ViewHosting.host(view: view) + + wait(for: [appear], timeout: 2) + } +} + +private struct CheckPathTestAssetView: View { + @Environment(\.checkPath) var checkPath + + // For Testing Purposes + internal var didAppear: ((Self) -> Void)? + + var body: some View { + Text(checkPath == nil ? "Not Found" : "Found").onAppear { didAppear?(self) } + } +} + +extension CheckPathTestAssetView: Inspectable {} diff --git a/xcode/Podfile b/xcode/Podfile index 0cdfd2bcf..bb49a80dc 100644 --- a/xcode/Podfile +++ b/xcode/Podfile @@ -27,6 +27,7 @@ target 'PlayerUI_Example' do pod 'PlayerUI/MetricsPlugin', :path => '../' pod 'PlayerUI/PrintLoggerPlugin', :path => '../' pod 'PlayerUI/PubSubPlugin', :path => '../' + pod 'PlayerUI/SwiftUICheckPathPlugin', :path => '../' pod 'PlayerUI/TransitionPlugin', :path => '../' pod 'PlayerUI/TypesProviderPlugin', :path => '../' diff --git a/xcode/Podfile.lock b/xcode/Podfile.lock index ef95bc6c2..10d0ef5fc 100644 --- a/xcode/Podfile.lock +++ b/xcode/Podfile.lock @@ -50,6 +50,10 @@ PODS: - PlayerUI/SwiftUI - PlayerUI/SwiftUI (0.0.1-placeholder): - PlayerUI/Core + - PlayerUI/SwiftUICheckPathPlugin (0.0.1-placeholder): + - PlayerUI/CheckPathPlugin + - PlayerUI/Core + - PlayerUI/SwiftUI - PlayerUI/TestUtilities (0.0.1-placeholder): - PlayerUI/Core - PlayerUI/SwiftUI @@ -96,6 +100,7 @@ DEPENDENCIES: - PlayerUI/PubSubPlugin (from `../`) - PlayerUI/ReferenceAssets (from `../`) - PlayerUI/SwiftUI (from `../`) + - PlayerUI/SwiftUICheckPathPlugin (from `../`) - PlayerUI/TestUtilities (from `../`) - PlayerUI/TestUtilitiesCore (from `../`) - PlayerUI/TransitionPlugin (from `../`) @@ -118,11 +123,11 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: EyesXCUI: bbb10a48b8bd1a15d541f2bc1f4d18f4db654ef1 - PlayerUI: 09b8d103175ee046a5e081360a332ea2ddc15080 + PlayerUI: 970ef71701a7f4b3550574594507726a2f86207a SwiftHooks: 3ecc67c23da335d44914a8a74bd1dd23c7c149e6 SwiftLint: 4fa9579c63416865179bc416f0a92d55f009600d ViewInspector: 53313c757eddc5c4842bc7943a66821a68d02d3e -PODFILE CHECKSUM: f03f1fe80c0ccb51f66a36486ef366a9e0030994 +PODFILE CHECKSUM: be8ad788f2acf6661be04bf0cd051612ab5f2bc8 COCOAPODS: 1.11.3