diff --git a/ios/BUILD.bazel b/ios/BUILD.bazel index 3a5bd0edf..4e0ad66c6 100644 --- a/ios/BUILD.bazel +++ b/ios/BUILD.bazel @@ -14,6 +14,9 @@ xcodeproj( "//ios/logger:PlayerUILoggerTests", "//ios/swiftui:PlayerUISwiftUITests", "//ios/swiftui:PlayerUISwiftUIViewInspectorTests", + "//ios/test-utils-core:PlayerUITestUtilitiesCoreTests", + "//ios/test-utils:PlayerUITestUtilitiesTests", + "//ios/test-utils:PlayerUITestUtilitiesViewInspectorTests", # plugins "//plugins/beacon/ios:PlayerUIBaseBeaconPluginTests", @@ -44,6 +47,8 @@ xcodeproj( "//plugins/reference-assets/swiftui:PlayerUIReferenceAssetsViewInspectorTests", "//plugins/reference-assets/swiftui:PlayerUIReferenceAssetsUITests", + "//plugins/stage-revert-data/ios:PlayerUIStageRevertDataPluginTests", + "//plugins/transition/swiftui:PlayerUITransitionPluginViewInspectorTests", "//plugins/types-provider/ios:PlayerUITypesProviderPluginTests" ], diff --git a/ios/core/Sources/Types/Core/Flow.swift b/ios/core/Sources/Types/Core/Flow.swift index 5a50d0e4f..ed101cddd 100644 --- a/ios/core/Sources/Types/Core/Flow.swift +++ b/ios/core/Sources/Types/Core/Flow.swift @@ -44,13 +44,16 @@ public class Flow: CreatedFromJSValue { */ public init(_ value: JSValue) { self.value = value - hooks = FlowHooks(transition: Hook2(baseValue: value, name: "transition")) + hooks = FlowHooks(transition: Hook2(baseValue: value, name: "transition"), afterTransition: Hook(baseValue: value, name: "afterTransition")) } } public struct FlowHooks { /// A hook that fires when transitioning states and giving the old and new states as parameters public var transition: Hook2 + + /// A hook that fires after a transition occurs giving the FlowInstance as parameter + public var afterTransition: Hook } public struct NamedState: CreatedFromJSValue { diff --git a/ios/core/Sources/Types/Core/FlowController.swift b/ios/core/Sources/Types/Core/FlowController.swift index b61ea8dee..9eb5f249c 100644 --- a/ios/core/Sources/Types/Core/FlowController.swift +++ b/ios/core/Sources/Types/Core/FlowController.swift @@ -48,8 +48,8 @@ public class FlowController: CreatedFromJSValue { - parameters: - action: The action to use for transitioning */ - public func transition(with action: String) { - value.invokeMethod("transition", withArguments: [action]) + public func transition(with action: String) throws { + try self.value.objectForKeyedSubscript("transition").tryCatch(args: [action]) } } diff --git a/ios/core/Sources/utilities/JSValue+Extensions.swift b/ios/core/Sources/utilities/JSValue+Extensions.swift new file mode 100644 index 000000000..76196ae28 --- /dev/null +++ b/ios/core/Sources/utilities/JSValue+Extensions.swift @@ -0,0 +1,59 @@ +// +// JSValue+Extensions.swift +// PlayerUI +// +// Created by Zhao Xia Wu on 2024-01-18. +// + +import Foundation +import JavaScriptCore + +extension JSValue { + + + /** + A way to catch errors for functions not called inside a player process. Can be called on functions with a return value and void with discardableResult. + - parameters: + - args: List of arguments taken by the function + */ + @discardableResult + public func tryCatch(args: Any...) throws -> JSValue? { + var tryCatchWrapper: JSValue? { + self.context.evaluateScript( + """ + (fn, args) => { + try { + return fn(...args) + } catch(e) { + return e + } + } + """) + } + + var errorCheckWrapper: JSValue? { + self.context.evaluateScript( + """ + (obj) => (obj instanceof Error) + """) + } + let result = tryCatchWrapper?.call(withArguments: [self, args]) + + let isError = errorCheckWrapper?.call(withArguments: [result as Any]) + + let errorMessage = result?.toString() ?? "" + + if isError?.toBool() == true { + throw JSValueError.thrownFromJS(message: errorMessage) + } else { + return result + } + } +} + +/** + Represents the different errors that occur when evaluating JSValue + */ +public enum JSValueError: Error, Equatable { + case thrownFromJS(message: String) +} \ No newline at end of file diff --git a/ios/core/Tests/HeadlessPlayerTests.swift b/ios/core/Tests/HeadlessPlayerTests.swift index 224ca80c9..fc3ec8155 100644 --- a/ios/core/Tests/HeadlessPlayerTests.swift +++ b/ios/core/Tests/HeadlessPlayerTests.swift @@ -72,7 +72,11 @@ class HeadlessPlayerTests: XCTestCase { }) player.start(flow: FlowData.COUNTER, completion: {_ in}) - (player.state as? InProgressState)?.controllers?.flow.transition(with: "NEXT") + do { + try (player.state as? InProgressState)?.controllers?.flow.transition(with: "NEXT") + } catch { + XCTFail("Transition with 'NEXT' failed") + } wait(for: [inProgress, completed], timeout: 5) } @@ -144,7 +148,11 @@ class HeadlessPlayerTests: XCTestCase { } XCTAssertNotNil(player.state as? InProgressState) XCTAssertEqual(player.state?.status, .inProgress) - (player.state as? InProgressState)?.controllers?.flow.transition(with: "NEXT") + do { + try (player.state as? InProgressState)?.controllers?.flow.transition(with: "NEXT") + } catch { + XCTFail("Transition with 'NEXT' failed") + } } func testPlayerControllers() { diff --git a/ios/core/Tests/utilities/JSValueExtensionTests.swift b/ios/core/Tests/utilities/JSValueExtensionTests.swift new file mode 100644 index 000000000..805c13b99 --- /dev/null +++ b/ios/core/Tests/utilities/JSValueExtensionTests.swift @@ -0,0 +1,73 @@ +// +// JSValueExtensionsTests.swift +// PlayerUI-Unit-Unit +// +// Created by Zhao Xia Wu on 2024-01-22. +// + + +import Foundation +import XCTest +import JavaScriptCore +@testable import PlayerUI + +class JSValueExtensionsTests: XCTestCase { + let context: JSContext = JSContext() + func testTryCatchWrapperReturningError() { + + let functionReturningError = self.context + .evaluateScript(""" + (() => { + throw new Error("Fail") + }) + """) + + do { + let _ = try functionReturningError?.tryCatch(args: [] as [String]) + } catch let error { + XCTAssertEqual(error as? JSValueError, JSValueError.thrownFromJS(message: "Error: Fail")) + } + } + + func testTryCatchWrapperReturningNumber() { + let functionReturningInt = self.context + .evaluateScript(""" + (() => { + return 1 + }) + """) + + do { + let result = try functionReturningInt?.tryCatch(args: [] as [String]) + XCTAssertEqual(result?.toInt32(), 1) + } catch let error { + XCTFail("Should have returned Int but failed with \(error)") + } + } + + func testTransitionDuringAnActiveTransitionShouldCatchErrorUsingTryCatchWrapper() { + let player = HeadlessPlayerImpl(plugins: []) + + let expectation = expectation(description: "Wait for on update") + + player.hooks?.viewController.tap { viewController in + viewController.hooks.view.tap { view in + view.hooks.onUpdate.tap { value in + guard view.id == "view-2" else { + do { + try (player.state as? InProgressState)?.controllers?.flow.transition(with: "NEXT") + } catch let error { + XCTAssertEqual(error as? JSValueError, JSValueError.thrownFromJS(message: "Error: Transitioning while ongoing transition from VIEW_1 is in progress is not supported")) + expectation.fulfill() + } + + return + } + } + } + } + + player.start(flow: FlowData.MULTIPAGE, completion: {_ in}) + wait(for: [expectation], timeout: 1) + } +} \ No newline at end of file diff --git a/ios/swiftui/Tests/ManagedPlayer/ManagedPlayerViewModelTests.swift b/ios/swiftui/Tests/ManagedPlayer/ManagedPlayerViewModelTests.swift index 3d86ff716..267df0aed 100644 --- a/ios/swiftui/Tests/ManagedPlayer/ManagedPlayerViewModelTests.swift +++ b/ios/swiftui/Tests/ManagedPlayer/ManagedPlayerViewModelTests.swift @@ -239,7 +239,11 @@ class ManagedPlayerViewModelTests: XCTestCase { XCTAssertNotNil(model.currentState) - (player.state as? InProgressState)?.controllers?.flow.transition(with: "next") + do { + try (player.state as? InProgressState)?.controllers?.flow.transition(with: "next") + } catch { + XCTFail("Transition with 'next' Failed") + } XCTAssertNil(model.currentState) } diff --git a/ios/test-utils/ViewInspector/ui-test/AssetFlowViewTests.swift b/ios/test-utils/ViewInspector/ui-test/AssetFlowViewTests.swift index 9e662cc3b..c911cae94 100644 --- a/ios/test-utils/ViewInspector/ui-test/AssetFlowViewTests.swift +++ b/ios/test-utils/ViewInspector/ui-test/AssetFlowViewTests.swift @@ -132,13 +132,17 @@ class ForceTransitionPlugin: NativePlugin { func apply

(player: P) where P: HeadlessPlayer { guard let player = player as? SwiftUIPlayer else { return } - player.hooks?.viewController.tap { viewController in - viewController.hooks.view.tap { view in - view.hooks.onUpdate.tap { _ in + player.hooks?.flowController.tap({ flowController in + flowController.hooks.flow.tap { flow in + flow.hooks.afterTransition.tap { _ in guard let state = player.state as? InProgressState else { return } - state.controllers?.flow.transition(with: "Next") + do { + try flowController.transition(with: "NEXT") + } catch { + XCTFail("Transition with 'NEXT' failed") + } } } - } + }) } } diff --git a/plugins/external-action/swiftui/ViewInspector/ExternalActionViewModifierPluginTests.swift b/plugins/external-action/swiftui/ViewInspector/ExternalActionViewModifierPluginTests.swift index 7d9952f72..ac6de9fc5 100644 --- a/plugins/external-action/swiftui/ViewInspector/ExternalActionViewModifierPluginTests.swift +++ b/plugins/external-action/swiftui/ViewInspector/ExternalActionViewModifierPluginTests.swift @@ -172,7 +172,11 @@ class ExternalActionViewModifierPluginTests: XCTestCase { 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") + do { + try (view.actualView().state as? InProgressState)?.controllers?.flow.transition(with: "Next") + } catch { + XCTFail("Transition with 'Next' failed") + } } wait(for: [exp, handlerExpectation], timeout: 10) @@ -181,7 +185,11 @@ class ExternalActionViewModifierPluginTests: XCTestCase { XCTAssertEqual(state?.controllers?.flow.current?.currentState?.value?.stateType, "VIEW") XCTAssertNil(plugin.state) XCTAssertFalse(plugin.isExternalState) - state?.controllers?.flow.transition(with: "Next") + do { + try state?.controllers?.flow.transition(with: "Next") + } catch { + XCTFail("Transition with 'Next' failed") + } wait(for: [completionExpectation], timeout: 10) ViewHosting.expel() diff --git a/plugins/stage-revert-data/ios/Tests/StageRevertDataPluginTests.swift b/plugins/stage-revert-data/ios/Tests/StageRevertDataPluginTests.swift index 66f982a71..cc93d5a11 100644 --- a/plugins/stage-revert-data/ios/Tests/StageRevertDataPluginTests.swift +++ b/plugins/stage-revert-data/ios/Tests/StageRevertDataPluginTests.swift @@ -70,8 +70,6 @@ class StageRevertDataPluginTests: XCTestCase { player.hooks?.viewController.tap { viewController in viewController.hooks.view.tap { view in guard view.id == "view-3" else { - (player.state as? InProgressState)?.controllers?.data.set(transaction: ["name": "Test"]) - (player.state as? InProgressState)?.controllers?.flow.transition(with: "clear") return } view.hooks.onUpdate.tap { value in @@ -83,6 +81,22 @@ class StageRevertDataPluginTests: XCTestCase { } } + player.hooks?.flowController.tap({ flowController in + flowController.hooks.flow.tap { flow in + flow.hooks.afterTransition.tap { flowInstance in + guard flowInstance.currentState?.name == "VIEW_3" else { + (player.state as? InProgressState)?.controllers?.data.set(transaction: ["name": "Test"]) + do { + try flowController.transition(with: "clear") + } catch { + XCTFail("Transition with 'clear' failed") + } + return + } + } + } + }) + player.start(flow: json, completion: {_ in}) wait(for: [expected], timeout: 1) } @@ -94,8 +108,6 @@ class StageRevertDataPluginTests: XCTestCase { player.hooks?.viewController.tap { viewController in viewController.hooks.view.tap { view in guard view.id == "view-2" else { - (player.state as? InProgressState)?.controllers?.data.set(transaction: ["name": "Test"]) - (player.state as? InProgressState)?.controllers?.flow.transition(with: "commit") return } view.hooks.onUpdate.tap { value in @@ -107,6 +119,22 @@ class StageRevertDataPluginTests: XCTestCase { } } + player.hooks?.flowController.tap({ flowController in + flowController.hooks.flow.tap { flow in + flow.hooks.afterTransition.tap { flowInstance in + guard flowInstance.currentState?.name == "VIEW_2" else { + (player.state as? InProgressState)?.controllers?.data.set(transaction: ["name": "Test"]) + do { + try flowController.transition(with: "commit") + } catch { + XCTFail("Transition with 'commit' failed") + } + return + } + } + } + }) + player.start(flow: json, completion: {_ in}) wait(for: [expected], timeout: 1) }