diff --git a/Source/NavigationHandler.swift b/Source/NavigationHandler.swift index 59a7d4da..9f57220b 100644 --- a/Source/NavigationHandler.swift +++ b/Source/NavigationHandler.swift @@ -10,13 +10,21 @@ import Foundation /// /// - Note: See `NumbersViewController` in the demo app for an example. public protocol NavigationHandler: AnyObject { - func route(_ url: URL) + /// Routes to the given URL. + /// - Parameters: + /// - url: the URL to visit. + /// - options: passed options will override default `advance` visit options. + /// - parameters: provide context relevant to `url`. + func route(_ url: URL, options: VisitOptions?, parameters: [String: Any]?) func route(_ proposal: VisitProposal) } -extension Navigator: NavigationHandler { - public func route(_ url: URL) { +public extension NavigationHandler { + /// Routes to the given URL using the default `advance` visit options. + func route(_ url: URL) { route(url, options: VisitOptions(action: .advance), parameters: nil) } } + +extension Navigator: NavigationHandler {} diff --git a/Source/Turbo/Navigator/Navigating.swift b/Source/Turbo/Navigator/Navigating.swift new file mode 100644 index 00000000..514b4099 --- /dev/null +++ b/Source/Turbo/Navigator/Navigating.swift @@ -0,0 +1,38 @@ +import Foundation +import UIKit + +/// A testable abstraction over `Navigator`. +/// +/// Refines `NavigationHandler` (which provides the `route` overloads) with the +/// navigation operations and view controller accessors that +/// `RouteDecisionHandler` and `WebViewPolicyDecisionHandler` implementations rely on. +public protocol Navigating: NavigationHandler { + /// Pops the top controller on the presented navigation stack. + func pop(animated: Bool) + + /// Dismisses a modally presented controller if present, then pops the entire navigation stack. + func clearAll(animated: Bool) + + /// Reloads the main and modal `Session`. + func reload() + + /// The root navigation controller of the main navigation stack. + var rootViewController: UINavigationController { get } + + /// The root navigation controller of the modal navigation stack. + var modalRootViewController: UINavigationController { get } + + /// The navigation controller currently presented to the user. + var activeNavigationController: UINavigationController { get } + + /// The URL currently displayed by the top visitable on the active navigation stack, if any. + var currentVisitableURL: URL? { get } +} + +public extension Navigating { + var currentVisitableURL: URL? { + (activeNavigationController.topViewController as? VisitableViewController)?.currentVisitableURL + } +} + +extension Navigator: Navigating {} diff --git a/Source/Turbo/Navigator/Routing/Handlers/AppNavigationRouteDecisionHandler.swift b/Source/Turbo/Navigator/Routing/Handlers/AppNavigationRouteDecisionHandler.swift index b7f0047c..494ad45d 100644 --- a/Source/Turbo/Navigator/Routing/Handlers/AppNavigationRouteDecisionHandler.swift +++ b/Source/Turbo/Navigator/Routing/Handlers/AppNavigationRouteDecisionHandler.swift @@ -16,7 +16,7 @@ public final class AppNavigationRouteDecisionHandler: RouteDecisionHandler { public func handle(location: URL, configuration: Navigator.Configuration, - navigator: Navigator) -> Router.Decision { + navigator: Navigating) -> Router.Decision { logger.info("Routing \(location.absoluteString)") return .navigate } diff --git a/Source/Turbo/Navigator/Routing/Handlers/SafariViewControllerRouteDecisionHandler.swift b/Source/Turbo/Navigator/Routing/Handlers/SafariViewControllerRouteDecisionHandler.swift index f2f97dd9..e09459f2 100644 --- a/Source/Turbo/Navigator/Routing/Handlers/SafariViewControllerRouteDecisionHandler.swift +++ b/Source/Turbo/Navigator/Routing/Handlers/SafariViewControllerRouteDecisionHandler.swift @@ -23,7 +23,7 @@ public final class SafariViewControllerRouteDecisionHandler: RouteDecisionHandle public func handle(location: URL, configuration: Navigator.Configuration, - navigator: Navigator) -> HotwireNative.Router.Decision { + navigator: Navigating) -> HotwireNative.Router.Decision { Task { @MainActor in await open(externalURL: location, viewController: navigator.activeNavigationController) diff --git a/Source/Turbo/Navigator/Routing/Handlers/SystemNavigationRouteDecisionHandler.swift b/Source/Turbo/Navigator/Routing/Handlers/SystemNavigationRouteDecisionHandler.swift index d4221da4..7cc5b353 100644 --- a/Source/Turbo/Navigator/Routing/Handlers/SystemNavigationRouteDecisionHandler.swift +++ b/Source/Turbo/Navigator/Routing/Handlers/SystemNavigationRouteDecisionHandler.swift @@ -18,7 +18,7 @@ public final class SystemNavigationRouteDecisionHandler: RouteDecisionHandler { public func handle(location: URL, configuration: Navigator.Configuration, - navigator: Navigator) -> Router.Decision { + navigator: Navigating) -> Router.Decision { UIApplication.shared.open(location) return .cancel diff --git a/Source/Turbo/Navigator/Routing/RouteDecisionHandler.swift b/Source/Turbo/Navigator/Routing/RouteDecisionHandler.swift index 65812b46..877eb031 100644 --- a/Source/Turbo/Navigator/Routing/RouteDecisionHandler.swift +++ b/Source/Turbo/Navigator/Routing/RouteDecisionHandler.swift @@ -24,5 +24,5 @@ public protocol RouteDecisionHandler { /// - navigator: The navigator instance responsible for the navigation. func handle(location: URL, configuration: Navigator.Configuration, - navigator: Navigator) -> Router.Decision + navigator: Navigating) -> Router.Decision } diff --git a/Source/Turbo/Navigator/Routing/Router.swift b/Source/Turbo/Navigator/Routing/Router.swift index 786cd928..c8290aca 100644 --- a/Source/Turbo/Navigator/Routing/Router.swift +++ b/Source/Turbo/Navigator/Routing/Router.swift @@ -11,7 +11,7 @@ public final class Router { func decideRoute(for location: URL, configuration: Navigator.Configuration, - navigator: Navigator) -> Router.Decision { + navigator: Navigating) -> Router.Decision { for handler in decisionHandlers { if handler.matches(location: location, configuration: configuration) { logger.debug("[Router] handler match found handler: \(handler.name) location: \(location)") diff --git a/Source/Turbo/Navigator/WebViewPolicy/Handlers/ExternalNavigationWebViewPolicyDecisionHandler.swift b/Source/Turbo/Navigator/WebViewPolicy/Handlers/ExternalNavigationWebViewPolicyDecisionHandler.swift index 28aa957e..9b51f466 100644 --- a/Source/Turbo/Navigator/WebViewPolicy/Handlers/ExternalNavigationWebViewPolicyDecisionHandler.swift +++ b/Source/Turbo/Navigator/WebViewPolicy/Handlers/ExternalNavigationWebViewPolicyDecisionHandler.swift @@ -30,7 +30,7 @@ public struct ExternalNavigationWebViewPolicyDecisionHandler: WebViewPolicyDecis public func handle(navigationAction: WKNavigationAction, configuration: Navigator.Configuration, - navigator: Navigator) -> WebViewPolicyManager.Decision { + navigator: Navigating) -> WebViewPolicyManager.Decision { if let scheme = navigationAction.request.url?.scheme, invalidSchemes.contains(scheme) { return .cancel diff --git a/Source/Turbo/Navigator/WebViewPolicy/Handlers/LinkActivatedWebViewPolicyDecisionHandler.swift b/Source/Turbo/Navigator/WebViewPolicy/Handlers/LinkActivatedWebViewPolicyDecisionHandler.swift index 89a2ca62..fa18453d 100644 --- a/Source/Turbo/Navigator/WebViewPolicy/Handlers/LinkActivatedWebViewPolicyDecisionHandler.swift +++ b/Source/Turbo/Navigator/WebViewPolicy/Handlers/LinkActivatedWebViewPolicyDecisionHandler.swift @@ -19,7 +19,7 @@ public struct LinkActivatedWebViewPolicyDecisionHandler: WebViewPolicyDecisionHa public func handle(navigationAction: WKNavigationAction, configuration: Navigator.Configuration, - navigator: Navigator) -> WebViewPolicyManager.Decision { + navigator: Navigating) -> WebViewPolicyManager.Decision { return .cancel } } diff --git a/Source/Turbo/Navigator/WebViewPolicy/Handlers/NewWindowWebViewPolicyDecisionHandler.swift b/Source/Turbo/Navigator/WebViewPolicy/Handlers/NewWindowWebViewPolicyDecisionHandler.swift index 235e25e8..14e55bde 100644 --- a/Source/Turbo/Navigator/WebViewPolicy/Handlers/NewWindowWebViewPolicyDecisionHandler.swift +++ b/Source/Turbo/Navigator/WebViewPolicy/Handlers/NewWindowWebViewPolicyDecisionHandler.swift @@ -19,7 +19,7 @@ public struct NewWindowWebViewPolicyDecisionHandler: WebViewPolicyDecisionHandle public func handle(navigationAction: WKNavigationAction, configuration: Navigator.Configuration, - navigator: Navigator) -> WebViewPolicyManager.Decision { + navigator: Navigating) -> WebViewPolicyManager.Decision { if let url = navigationAction.request.url { navigator.route(url) } diff --git a/Source/Turbo/Navigator/WebViewPolicy/Handlers/ReloadWebViewPolicyDecisionHandler.swift b/Source/Turbo/Navigator/WebViewPolicy/Handlers/ReloadWebViewPolicyDecisionHandler.swift index 1f0ea152..5845790d 100644 --- a/Source/Turbo/Navigator/WebViewPolicy/Handlers/ReloadWebViewPolicyDecisionHandler.swift +++ b/Source/Turbo/Navigator/WebViewPolicy/Handlers/ReloadWebViewPolicyDecisionHandler.swift @@ -17,7 +17,7 @@ public struct ReloadWebViewPolicyDecisionHandler: WebViewPolicyDecisionHandler { public func handle(navigationAction: WKNavigationAction, configuration: Navigator.Configuration, - navigator: Navigator) -> WebViewPolicyManager.Decision { + navigator: Navigating) -> WebViewPolicyManager.Decision { logger.info("ReloadWebViewPolicyDecisionHandler: Reloading at \(navigationAction.request.url?.absoluteString ?? "")") navigator.reload() diff --git a/Source/Turbo/Navigator/WebViewPolicy/WebViewPolicyManager.swift b/Source/Turbo/Navigator/WebViewPolicy/WebViewPolicyManager.swift index 40652472..36c4dba6 100644 --- a/Source/Turbo/Navigator/WebViewPolicy/WebViewPolicyManager.swift +++ b/Source/Turbo/Navigator/WebViewPolicy/WebViewPolicyManager.swift @@ -12,7 +12,7 @@ public final class WebViewPolicyManager { func decidePolicy(for navigationAction: WKNavigationAction, configuration: Navigator.Configuration, - navigator: Navigator) -> WebViewPolicyManager.Decision { + navigator: Navigating) -> WebViewPolicyManager.Decision { for handler in policyDecisionHandlers { if handler.matches(navigationAction: navigationAction, configuration: configuration) { logger.debug("[WebViewPolicyManager] handler match found handler: \(handler.name) navigation action:\(navigationAction)") diff --git a/Source/Turbo/Navigator/WebViewPolicy/WebViewRouteDecisionHandler.swift b/Source/Turbo/Navigator/WebViewPolicy/WebViewRouteDecisionHandler.swift index dab6cb50..dc8eb0dd 100644 --- a/Source/Turbo/Navigator/WebViewPolicy/WebViewRouteDecisionHandler.swift +++ b/Source/Turbo/Navigator/WebViewPolicy/WebViewRouteDecisionHandler.swift @@ -26,5 +26,5 @@ public protocol WebViewPolicyDecisionHandler { /// whether to allow or cancel the WebView navigation. func handle(navigationAction: WKNavigationAction, configuration: Navigator.Configuration, - navigator: Navigator) -> WebViewPolicyManager.Decision + navigator: Navigating) -> WebViewPolicyManager.Decision } diff --git a/Source/Turbo/ViewControllers/HotwireTabBarController.swift b/Source/Turbo/ViewControllers/HotwireTabBarController.swift index eaa05551..b0371558 100644 --- a/Source/Turbo/ViewControllers/HotwireTabBarController.swift +++ b/Source/Turbo/ViewControllers/HotwireTabBarController.swift @@ -56,8 +56,8 @@ open class HotwireTabBarController: UITabBarController, NavigationHandler { // MARK: NavigationHandler - open func route(_ url: URL) { - activeNavigator.route(url) + open func route(_ url: URL, options: VisitOptions?, parameters: [String: Any]?) { + activeNavigator.route(url, options: options, parameters: parameters) } open func route(_ proposal: VisitProposal) { diff --git a/Tests/Turbo/Routing/AppNavigationRouteDecisionHandlerTest.swift b/Tests/Turbo/Routing/AppNavigationRouteDecisionHandlerTest.swift index af68abdf..4c228e32 100644 --- a/Tests/Turbo/Routing/AppNavigationRouteDecisionHandlerTest.swift +++ b/Tests/Turbo/Routing/AppNavigationRouteDecisionHandlerTest.swift @@ -7,11 +7,11 @@ final class AppNavigationRouteDecisionHandlerTest: XCTestCase { startLocation: URL(string: "https://my.app.com")! ) var route: AppNavigationRouteDecisionHandler! - var navigator: Navigator! + var navigator: NavigationSpy! override func setUp() { route = AppNavigationRouteDecisionHandler() - navigator = Navigator(configuration: navigatorConfiguration) + navigator = NavigationSpy() } func test_handling_matching_result_navigates() { diff --git a/Tests/Turbo/Routing/RouterTests.swift b/Tests/Turbo/Routing/RouterTests.swift index 8034d82c..41ccebe7 100644 --- a/Tests/Turbo/Routing/RouterTests.swift +++ b/Tests/Turbo/Routing/RouterTests.swift @@ -8,10 +8,10 @@ final class RouterTests: XCTestCase { ) let url = URL(string: "https://my.app.com/page")! var router: Router! - var navigator: Navigator! + var navigator: NavigationSpy! override func setUp() { - navigator = Navigator(configuration: navigatorConfiguration) + navigator = NavigationSpy() } func test_no_handlers_stops_navigation() { @@ -89,7 +89,7 @@ final class NoMatchRouteDecisionHandlerSpy: RouteDecisionHandler { return false } - func handle(location: URL, configuration: HotwireNative.Navigator.Configuration, navigator: HotwireNative.Navigator) -> HotwireNative.Router.Decision { + func handle(location: URL, configuration: HotwireNative.Navigator.Configuration, navigator: HotwireNative.Navigating) -> HotwireNative.Router.Decision { handleWasCalled = true return .cancel } @@ -105,7 +105,7 @@ final class MatchRouteDecisionHandlerSpy: RouteDecisionHandler { return true } - func handle(location: URL, configuration: HotwireNative.Navigator.Configuration, navigator: HotwireNative.Navigator) -> HotwireNative.Router.Decision { + func handle(location: URL, configuration: HotwireNative.Navigator.Configuration, navigator: HotwireNative.Navigating) -> HotwireNative.Router.Decision { handleWasCalled = true return .navigate } diff --git a/Tests/Turbo/Routing/SafariViewControllerRouteDecisionHandlerTests.swift b/Tests/Turbo/Routing/SafariViewControllerRouteDecisionHandlerTests.swift index bc143523..fda403ab 100644 --- a/Tests/Turbo/Routing/SafariViewControllerRouteDecisionHandlerTests.swift +++ b/Tests/Turbo/Routing/SafariViewControllerRouteDecisionHandlerTests.swift @@ -6,12 +6,12 @@ final class SafariViewControllerRouteDecisionHandlerTests: XCTestCase { name: "test", startLocation: URL(string: "https://my.app.com")! ) - var navigator: Navigator! + var navigator: NavigationSpy! var route: SafariViewControllerRouteDecisionHandler! override func setUp() { route = SafariViewControllerRouteDecisionHandler() - navigator = Navigator(configuration: navigatorConfiguration) + navigator = NavigationSpy() } func test_handling_matching_result_stops_navigation() { diff --git a/Tests/Turbo/Routing/SystemNavigationRouteDecisionHandlerTest.swift b/Tests/Turbo/Routing/SystemNavigationRouteDecisionHandlerTest.swift index 8b896d0c..ee87a33d 100644 --- a/Tests/Turbo/Routing/SystemNavigationRouteDecisionHandlerTest.swift +++ b/Tests/Turbo/Routing/SystemNavigationRouteDecisionHandlerTest.swift @@ -6,12 +6,12 @@ final class SystemNavigationRouteDecisionHandlerTests: XCTestCase { name: "test", startLocation: URL(string: "https://my.app.com")! ) - var navigator: Navigator! + var navigator: NavigationSpy! var route: SystemNavigationRouteDecisionHandler! override func setUp() { route = SystemNavigationRouteDecisionHandler() - navigator = Navigator(configuration: navigatorConfiguration) + navigator = NavigationSpy() } func test_handling_matching_result_stops_navigation() { diff --git a/Tests/Turbo/WebViewPolicy/BaseWebViewPolicyDecisionHandlerTests.swift b/Tests/Turbo/WebViewPolicy/BaseWebViewPolicyDecisionHandlerTests.swift index e7a31d1b..8b0984d7 100644 --- a/Tests/Turbo/WebViewPolicy/BaseWebViewPolicyDecisionHandlerTests.swift +++ b/Tests/Turbo/WebViewPolicy/BaseWebViewPolicyDecisionHandlerTests.swift @@ -12,7 +12,7 @@ class BaseWebViewPolicyDecisionHandlerTests: XCTestCase { ) override func setUp() async throws { - navigatorSpy = NavigationSpy(configuration: navigatorConfiguration) + navigatorSpy = NavigationSpy() webNavigationSimulator = WebViewPolicyNavigationSimulator() } diff --git a/Tests/Turbo/WebViewPolicy/Spies/NavigationSpy.swift b/Tests/Turbo/WebViewPolicy/Spies/NavigationSpy.swift index cd923163..633f1ce1 100644 --- a/Tests/Turbo/WebViewPolicy/Spies/NavigationSpy.swift +++ b/Tests/Turbo/WebViewPolicy/Spies/NavigationSpy.swift @@ -1,25 +1,36 @@ @testable import HotwireNative import Foundation +import UIKit -final class NavigationSpy: Navigator { +/// A lightweight `Navigating` test double that records navigation calls without +/// instantiating real `Session` or `WKWebView` instances. +final class NavigationSpy: Navigating { var routeWasCalled = false var routeURL: URL? var reloadWasCalled = false - init(configuration: Navigator.Configuration) { - super.init( - session: Session(webView: Hotwire.config.makeWebView()), - modalSession: Session(webView: Hotwire.config.makeWebView()), - configuration: configuration - ) + /// Stub for the currently displayed URL, overriding the `Navigating` default. + var currentVisitableURL: URL? + + lazy var rootViewController = UINavigationController() + lazy var modalRootViewController = UINavigationController() + lazy var activeNavigationController = UINavigationController() + + func route(_ proposal: VisitProposal) { + routeWasCalled = true + routeURL = proposal.url } - override func route(_ url: URL, options: VisitOptions? = VisitOptions(action: .advance), parameters: [String : Any]? = nil) { + func route(_ url: URL, options: VisitOptions?, parameters: [String: Any]?) { routeWasCalled = true routeURL = url } - override func reload() { + func pop(animated: Bool) {} + + func clearAll(animated: Bool) {} + + func reload() { reloadWasCalled = true } } diff --git a/Tests/Turbo/WebViewPolicy/WebViewPolicyManagerTests.swift b/Tests/Turbo/WebViewPolicy/WebViewPolicyManagerTests.swift index 45686e20..41026331 100644 --- a/Tests/Turbo/WebViewPolicy/WebViewPolicyManagerTests.swift +++ b/Tests/Turbo/WebViewPolicy/WebViewPolicyManagerTests.swift @@ -11,11 +11,11 @@ final class WebViewPolicyManagerTests: XCTestCase { let url = URL(string: "https://my.app.com/page")! var policyManager: WebViewPolicyManager! var webNavigationSimulator: WebViewPolicyNavigationSimulator! - var navigator: Navigator! + var navigator: NavigationSpy! static var navigationAction: WKNavigationAction! override func setUp() async throws { - navigator = Navigator(configuration: navigatorConfiguration) + navigator = NavigationSpy() // We can't instantiate a `WKNavigationAction`, so we load HTML and retrieve one. // In these tests, the navigation action is not actually evaluated, but we need it as an argument @@ -112,7 +112,7 @@ final class NoMatchWebViewPolicyDecisionHandler: WebViewPolicyDecisionHandler { func handle(navigationAction: WKNavigationAction, configuration: Navigator.Configuration, - navigator: Navigator) -> WebViewPolicyManager.Decision { + navigator: Navigating) -> WebViewPolicyManager.Decision { handleWasCalled = true return .cancel } @@ -131,7 +131,7 @@ final class MatchWebViewPolicyDecisionHandler: WebViewPolicyDecisionHandler { func handle(navigationAction: WKNavigationAction, configuration: Navigator.Configuration, - navigator: Navigator) -> WebViewPolicyManager.Decision { + navigator: Navigating) -> WebViewPolicyManager.Decision { handleWasCalled = true return .cancel }