diff --git a/Source/Turbo/Errors/HTTPError.swift b/Source/Turbo/Errors/HTTPError.swift index 315de35f..94c90113 100644 --- a/Source/Turbo/Errors/HTTPError.swift +++ b/Source/Turbo/Errors/HTTPError.swift @@ -24,14 +24,6 @@ public enum HTTPError: LocalizedError, Equatable, Sendable { } } - /// Whether the HTTP error is transient and worth retrying. - public var isRetryable: Bool { - switch self { - case .client(let error): return error.isRetryable - case .server(let error): return error.isRetryable - } - } - /// Creates an HTTPError from an HTTP status code. /// Returns `nil` for status codes outside the 400-599 error range. public init?(statusCode: Int) { @@ -106,13 +98,6 @@ extension HTTPError { } } - public var isRetryable: Bool { - switch self { - case .requestTimeout, .tooManyRequests: return true - default: return false - } - } - public init(statusCode: Int) { switch statusCode { case 400: self = .badRequest @@ -172,13 +157,6 @@ extension HTTPError { } } - public var isRetryable: Bool { - switch self { - case .serviceUnavailable, .gatewayTimeout: return true - default: return false - } - } - public init(statusCode: Int) { switch statusCode { case 500: self = .internalServerError diff --git a/Source/Turbo/Errors/HotwireNativeError.swift b/Source/Turbo/Errors/HotwireNativeError.swift index 97d0e569..d20591e3 100644 --- a/Source/Turbo/Errors/HotwireNativeError.swift +++ b/Source/Turbo/Errors/HotwireNativeError.swift @@ -38,20 +38,6 @@ public enum HotwireNativeError: LocalizedError, Equatable, Sendable { return nil } - /// Whether the error is recoverable by retrying the request. - /// HTTP 408/429 and 503/504 are retryable. Network-level timeouts, - /// offline errors, and configuration errors are not. - public var isRetryable: Bool { - switch self { - case .http(let error): - return error.isRetryable - case .web(let error): - return !error.isOffline && !error.isTimeout - case .load: - return false - } - } - /// Creates an error from a Turbo.js status code. /// - Positive status codes are HTTP errors /// - 0 = network failure, -1 = timeout, -2 = content type mismatch diff --git a/Source/Turbo/Navigator/Navigator.swift b/Source/Turbo/Navigator/Navigator.swift index abef10af..35821654 100644 --- a/Source/Turbo/Navigator/Navigator.swift +++ b/Source/Turbo/Navigator/Navigator.swift @@ -54,6 +54,7 @@ public class Navigator { return } + logger.info("Navigator starting at \(configuration.startLocation.absoluteString)") route(configuration.startLocation) } @@ -78,6 +79,7 @@ public class Navigator { } guard let controller = controller(for: proposal) else { return } + logger.info("Routing \(proposal.url.absoluteString)") hierarchyController.route(controller: controller, proposal: proposal) } @@ -204,7 +206,14 @@ extension Navigator: SessionDelegate { } public func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: HotwireNativeError) { - let retryHandler: (() -> Void)? = error.isRetryable ? { session.reload() } : nil + let retryHandler: (() -> Void)? = { + if session.topmostVisitable == nil { + // Preserve reload semantics (force cold boot) when there is no topmost visitable yet. + session.visit(visitable, reload: true) + } else { + session.reload() + } + } delegate?.visitableDidFailRequest(visitable, error: error, retryHandler: retryHandler) } @@ -269,6 +278,7 @@ extension Navigator: WKUIControllerDelegate { extension Navigator { private func inspectAllSessions() { + logger.info("Inspecting sessions") [session, modalSession].forEach { inspect($0) } } @@ -282,9 +292,13 @@ extension Navigator { /// side-effects for the next visit (like showing the wrong bridge components). We can't just /// check if the view controller is visible, since it may be further back in the stack of a navigation controller. /// Seeing if there is a parent was the best solution I could find. - guard let viewController = session.activeVisitable?.visitableViewController, - viewController.parent != nil - else { + guard let viewController = session.activeVisitable?.visitableViewController else { + logger.info("Skipping session reload: no visitableViewController found") + return + } + + guard viewController.parent != nil else { + logger.info("Skipping session reload: visitableViewController has no parent") return } @@ -293,6 +307,7 @@ extension Navigator { // and reload it when the app is back in foreground. if appLifecycleObserver.appState == .background { if !backgroundTerminatedWebViewSessions.contains(where: { $0 === session }) { + logger.info("Skipping session reload: app in background") backgroundTerminatedWebViewSessions.append(session) } return @@ -316,16 +331,21 @@ extension Navigator { private func inspect(_ session: Session) { if let index = backgroundTerminatedWebViewSessions.firstIndex(where: { $0 === session }) { backgroundTerminatedWebViewSessions.remove(at: index) + logger.debug("Reloading background terminated web view") reload(session) return } guard let _ = session.topmostVisitable?.initialVisitableURL else { + logger.debug("Skipping inspection: no topmostVisitable found") return } session.webView.queryWebContentProcessState { [weak self] state in - guard case .terminated = state else { return } + guard case .terminated = state else { + logger.debug("Skipping web view recreation: process not terminated") + return + } self?.recreateWebView(for: session) } } @@ -335,8 +355,12 @@ extension Navigator { /// - Parameter session: The session to recreate. private func recreateWebView(for session: Session) { guard let _ = session.activeVisitable?.visitableViewController, - let url = session.activeVisitable?.initialVisitableURL else { return } + let url = session.activeVisitable?.initialVisitableURL else { + logger.debug("Skipping web view recreation: no initialVisitableURL found") + return + } + logger.debug("Recreating web view for \(url.absoluteString)") let newSession = Session(webView: Hotwire.config.makeWebView()) newSession.pathConfiguration = session.pathConfiguration newSession.delegate = self diff --git a/Source/Turbo/Navigator/Routing/Handlers/AppNavigationRouteDecisionHandler.swift b/Source/Turbo/Navigator/Routing/Handlers/AppNavigationRouteDecisionHandler.swift index b5f92b63..b7f0047c 100644 --- a/Source/Turbo/Navigator/Routing/Handlers/AppNavigationRouteDecisionHandler.swift +++ b/Source/Turbo/Navigator/Routing/Handlers/AppNavigationRouteDecisionHandler.swift @@ -17,6 +17,7 @@ public final class AppNavigationRouteDecisionHandler: RouteDecisionHandler { public func handle(location: URL, configuration: Navigator.Configuration, navigator: Navigator) -> Router.Decision { + logger.info("Routing \(location.absoluteString)") return .navigate } } diff --git a/Source/Turbo/Navigator/WebViewPolicy/Handlers/ReloadWebViewPolicyDecisionHandler.swift b/Source/Turbo/Navigator/WebViewPolicy/Handlers/ReloadWebViewPolicyDecisionHandler.swift index 4d77491e..1f0ea152 100644 --- a/Source/Turbo/Navigator/WebViewPolicy/Handlers/ReloadWebViewPolicyDecisionHandler.swift +++ b/Source/Turbo/Navigator/WebViewPolicy/Handlers/ReloadWebViewPolicyDecisionHandler.swift @@ -18,6 +18,7 @@ public struct ReloadWebViewPolicyDecisionHandler: WebViewPolicyDecisionHandler { public func handle(navigationAction: WKNavigationAction, configuration: Navigator.Configuration, navigator: Navigator) -> WebViewPolicyManager.Decision { + logger.info("ReloadWebViewPolicyDecisionHandler: Reloading at \(navigationAction.request.url?.absoluteString ?? "")") navigator.reload() return .cancel diff --git a/Source/Turbo/Navigator/WebViewPolicy/WebViewPolicyManager.swift b/Source/Turbo/Navigator/WebViewPolicy/WebViewPolicyManager.swift index 10a8c62d..40652472 100644 --- a/Source/Turbo/Navigator/WebViewPolicy/WebViewPolicyManager.swift +++ b/Source/Turbo/Navigator/WebViewPolicy/WebViewPolicyManager.swift @@ -22,7 +22,7 @@ public final class WebViewPolicyManager { } } - logger.warning("[WebViewPolicyManager] no handler for navigation action: \(navigationAction)") + logger.info("[WebViewPolicyManager] no handler for navigation action: \(navigationAction)") return .allow } } diff --git a/Source/Turbo/Networking/RedirectHandler.swift b/Source/Turbo/Networking/JSFetchRecoveryHandler.swift similarity index 66% rename from Source/Turbo/Networking/RedirectHandler.swift rename to Source/Turbo/Networking/JSFetchRecoveryHandler.swift index dfe8214f..0b782763 100644 --- a/Source/Turbo/Networking/RedirectHandler.swift +++ b/Source/Turbo/Networking/JSFetchRecoveryHandler.swift @@ -1,6 +1,6 @@ import Foundation -enum RedirectHandlerError: LocalizedError { +enum JSFetchRecoveryError: LocalizedError { case requestFailed(Error) case responseValidationFailed(reason: ResponseValidationFailureReason) @@ -13,19 +13,19 @@ enum RedirectHandlerError: LocalizedError { var errorDescription: String? { switch self { case .requestFailed(let error): - return "Redirect resolution failed: \(error.localizedDescription)" + return "Failed to recover js fetch: \(error.localizedDescription)" case .responseValidationFailed(let reason): switch reason { case .missingURL: - return "Redirect resolution failed: missing URL" + return "Failed to validate js fetch resolution response: missing URL" case .invalidResponse: - return "Redirect resolution response invalid" + return "Failed to validate js fetch resolution response: response invalid" } } } } -struct RedirectHandler { +struct JSFetchRecoveryHandler { enum Result { case noRedirect case sameOriginRedirect(URL) @@ -36,6 +36,7 @@ struct RedirectHandler { location: URL, timeout: TimeInterval = Hotwire.config.redirectResolutionTimeout ) async throws -> Result { + logger.debug("[JSFetchRecoveryHandler] resolve: \(location.absoluteString)") do { var request = URLRequest(url: location) request.timeoutInterval = timeout @@ -43,31 +44,34 @@ struct RedirectHandler { let httpResponse = try validateResponse(response) guard let responseUrl = httpResponse.url else { - throw RedirectHandlerError.responseValidationFailed(reason: .missingURL) + throw JSFetchRecoveryError.responseValidationFailed(reason: .missingURL) } let isRedirect = location != responseUrl let redirectIsCrossOrigin = isRedirect && location.host != responseUrl.host guard isRedirect else { + logger.debug("[JSFetchRecoveryHandler] no redirect, status: \(httpResponse.statusCode)") return .noRedirect } if redirectIsCrossOrigin { + logger.debug("[JSFetchRecoveryHandler] cross-origin redirect to \(responseUrl.absoluteString)") return .crossOriginRedirect(responseUrl) } + logger.debug("[JSFetchRecoveryHandler] same-origin redirect to \(responseUrl.absoluteString)") return .sameOriginRedirect(responseUrl) - } catch let error as RedirectHandlerError { + } catch let error as JSFetchRecoveryError { throw error } catch { - throw RedirectHandlerError.requestFailed(error) + throw JSFetchRecoveryError.requestFailed(error) } } private func validateResponse(_ response: URLResponse) throws -> HTTPURLResponse { guard let httpResponse = response as? HTTPURLResponse else { - throw RedirectHandlerError.responseValidationFailed(reason: .invalidResponse) + throw JSFetchRecoveryError.responseValidationFailed(reason: .invalidResponse) } return httpResponse diff --git a/Source/Turbo/Session/Session.swift b/Source/Turbo/Session/Session.swift index 0e64ef0e..3273e270 100644 --- a/Source/Turbo/Session/Session.swift +++ b/Source/Turbo/Session/Session.swift @@ -81,8 +81,12 @@ public class Session: NSObject { } public func reload() { - guard let visitable = topmostVisitable else { return } + guard let visitable = topmostVisitable else { + log("Skipping session reload: no visitable found") + return + } + log("Reloading session with visitable: \(visitable)") initialized = false visit(visitable) topmostVisit = currentVisit @@ -396,10 +400,10 @@ extension Session: WebViewDelegate { private func resolveRedirect(to location: URL, identifier: String, statusCode: Int) async { do { - let result = try await RedirectHandler().resolve(location: location) + let result = try await JSFetchRecoveryHandler().resolve(location: location) switch result { case .noRedirect: - // The server is reachable (RedirectHandler confirmed an HTTP response + // The server is reachable (JSFetchRecoveryHandler confirmed an HTTP response // with no redirect). The Turbo.js fetch failure was transient — // retry with a cold boot visit before showing an error. log("resolveRedirect: no redirect, retrying", @@ -429,7 +433,12 @@ extension Session: WebViewDelegate { visitIdentifier: identifier ) } - } catch let error as RedirectHandlerError { + } catch let error as JSFetchRecoveryError { + log("resolveRedirect: recovery failed", + ["location": location, + "visitIdentifier": identifier, + "error": error.localizedDescription]) + let visitError: HotwireNativeError switch error { case .responseValidationFailed(reason: .missingURL), @@ -440,6 +449,11 @@ extension Session: WebViewDelegate { } await retryOrFailCurrentVisit(with: visitError, visitIdentifier: identifier) } catch { + log("resolveRedirect: unexpected error", + ["location": location, + "visitIdentifier": identifier, + "error": "\(error)"]) + await retryOrFailCurrentVisit( with: .web(WebError(error)), visitIdentifier: identifier diff --git a/Source/Turbo/Visit/ColdBootVisit.swift b/Source/Turbo/Visit/ColdBootVisit.swift index 919aa64e..7d9d7581 100644 --- a/Source/Turbo/Visit/ColdBootVisit.swift +++ b/Source/Turbo/Visit/ColdBootVisit.swift @@ -105,6 +105,7 @@ extension ColdBootVisit: WKNavigationDelegate { decisionHandler(.allow) } else { decisionHandler(.cancel) + logger.warning("[ColdBootVisit] request failed with status \(httpResponse.statusCode) for \(self.location.absoluteString)") if let httpError = HTTPError(statusCode: httpResponse.statusCode) { fail(with: .http(httpError)) } else { diff --git a/Source/Turbo/Visitable/VisitableViewController.swift b/Source/Turbo/Visitable/VisitableViewController.swift index ff2a887c..df2032ce 100644 --- a/Source/Turbo/Visitable/VisitableViewController.swift +++ b/Source/Turbo/Visitable/VisitableViewController.swift @@ -72,6 +72,9 @@ open class VisitableViewController: UIViewController, Visitable { } open func visitableWillDeactivateWebView() { + let webViewURL = visitableView.webView?.url + let locationStateDescription = webViewURL != nil ? "webView's url: \(webViewURL!.absoluteString)" : "initialVisitableURL: \(initialVisitableURL.absoluteString)" + logger.info("visitableWillDeactivateWebView: setting location state to \(locationStateDescription)") visitableLocationState = .deactivated(visitableView.webView?.url ?? initialVisitableURL) } @@ -109,6 +112,9 @@ open class VisitableViewController: UIViewController, Visitable { private func resolveVisitableLocation() -> URL { switch visitableLocationState { case .resolved: + if visitableView.webView?.url == nil { + logger.info("Resolving visitable location to initialVisitableURL: \(initialVisitableURL.absoluteString)") + } return visitableView.webView?.url ?? initialVisitableURL case .initialized(let url): return url diff --git a/Tests/Turbo/HotwireNativeErrorTests.swift b/Tests/Turbo/HotwireNativeErrorTests.swift index 92552df2..dbb048dc 100644 --- a/Tests/Turbo/HotwireNativeErrorTests.swift +++ b/Tests/Turbo/HotwireNativeErrorTests.swift @@ -144,52 +144,6 @@ final class HotwireNativeErrorTests: XCTestCase { XCTAssertNil(HotwireNativeError.load(.notPresent).urlError) } - // MARK: - isRetryable - - func test_isRetryable_http_requestTimeout_isTrue() { - XCTAssertTrue(HotwireNativeError.http(.client(.requestTimeout)).isRetryable) - } - - func test_isRetryable_http_tooManyRequests_isTrue() { - XCTAssertTrue(HotwireNativeError.http(.client(.tooManyRequests)).isRetryable) - } - - func test_isRetryable_http_notFound_isFalse() { - XCTAssertFalse(HotwireNativeError.http(.client(.notFound)).isRetryable) - } - - func test_isRetryable_http_unauthorized_isFalse() { - XCTAssertFalse(HotwireNativeError.http(.client(.unauthorized)).isRetryable) - } - - func test_isRetryable_http_internalServerError_isFalse() { - XCTAssertFalse(HotwireNativeError.http(.server(.internalServerError)).isRetryable) - } - - func test_isRetryable_http_serviceUnavailable_isTrue() { - XCTAssertTrue(HotwireNativeError.http(.server(.serviceUnavailable)).isRetryable) - } - - func test_isRetryable_http_gatewayTimeout_isTrue() { - XCTAssertTrue(HotwireNativeError.http(.server(.gatewayTimeout)).isRetryable) - } - - func test_isRetryable_web_networkFailure_isTrue() { - XCTAssertTrue(HotwireNativeError.web(WebError(errorCode: 0, message: nil)).isRetryable) - } - - func test_isRetryable_web_offline_isFalse() { - XCTAssertFalse(HotwireNativeError.web(WebError(urlError: URLError(.notConnectedToInternet))).isRetryable) - } - - func test_isRetryable_web_timeout_isFalse() { - XCTAssertFalse(HotwireNativeError.web(WebError(turboError: .timeout)).isRetryable) - } - - func test_isRetryable_load_isFalse() { - XCTAssertFalse(HotwireNativeError.load(.contentTypeMismatch).isRetryable) - } - // MARK: - errorDescription func test_errorDescription_forHttpError() { diff --git a/Tests/Turbo/HttpErrorTests.swift b/Tests/Turbo/HttpErrorTests.swift index ac43dbdf..6fc3860f 100644 --- a/Tests/Turbo/HttpErrorTests.swift +++ b/Tests/Turbo/HttpErrorTests.swift @@ -171,80 +171,6 @@ final class HTTPErrorTests: XCTestCase { XCTAssertEqual(HTTPError.ServerError.other(statusCode: 599).errorDescription, "Server Error (599)") } - // MARK: - ClientError isRetryable - - func test_clientError_requestTimeout_isRetryable() { - XCTAssertTrue(HTTPError.ClientError.requestTimeout.isRetryable) - } - - func test_clientError_tooManyRequests_isRetryable() { - XCTAssertTrue(HTTPError.ClientError.tooManyRequests.isRetryable) - } - - func test_clientError_badRequest_isNotRetryable() { - XCTAssertFalse(HTTPError.ClientError.badRequest.isRetryable) - } - - func test_clientError_unauthorized_isNotRetryable() { - XCTAssertFalse(HTTPError.ClientError.unauthorized.isRetryable) - } - - func test_clientError_forbidden_isNotRetryable() { - XCTAssertFalse(HTTPError.ClientError.forbidden.isRetryable) - } - - func test_clientError_notFound_isNotRetryable() { - XCTAssertFalse(HTTPError.ClientError.notFound.isRetryable) - } - - func test_clientError_other_isNotRetryable() { - XCTAssertFalse(HTTPError.ClientError.other(statusCode: 418).isRetryable) - } - - // MARK: - ServerError isRetryable - - func test_serverError_serviceUnavailable_isRetryable() { - XCTAssertTrue(HTTPError.ServerError.serviceUnavailable.isRetryable) - } - - func test_serverError_gatewayTimeout_isRetryable() { - XCTAssertTrue(HTTPError.ServerError.gatewayTimeout.isRetryable) - } - - func test_serverError_internalServerError_isNotRetryable() { - XCTAssertFalse(HTTPError.ServerError.internalServerError.isRetryable) - } - - func test_serverError_badGateway_isNotRetryable() { - XCTAssertFalse(HTTPError.ServerError.badGateway.isRetryable) - } - - func test_serverError_other_isNotRetryable() { - XCTAssertFalse(HTTPError.ServerError.other(statusCode: 599).isRetryable) - } - - // MARK: - HTTPError isRetryable Delegation - - func test_httpError_client_requestTimeout_isRetryable() { - XCTAssertTrue(HTTPError.client(.requestTimeout).isRetryable) - } - - func test_httpError_client_tooManyRequests_isRetryable() { - XCTAssertTrue(HTTPError.client(.tooManyRequests).isRetryable) - } - - func test_httpError_client_notFound_isNotRetryable() { - XCTAssertFalse(HTTPError.client(.notFound).isRetryable) - } - - func test_httpError_server_serviceUnavailable_isRetryable() { - XCTAssertTrue(HTTPError.server(.serviceUnavailable).isRetryable) - } - - func test_httpError_server_badGateway_isNotRetryable() { - XCTAssertFalse(HTTPError.server(.badGateway).isRetryable) - } - // MARK: - Helpers private func assertClientErrorRoundTrip( diff --git a/Tests/Turbo/RedirectHandlerTests.swift b/Tests/Turbo/JSFetchRecoveryHandlerTests.swift similarity index 94% rename from Tests/Turbo/RedirectHandlerTests.swift rename to Tests/Turbo/JSFetchRecoveryHandlerTests.swift index a9862334..49b2f896 100644 --- a/Tests/Turbo/RedirectHandlerTests.swift +++ b/Tests/Turbo/JSFetchRecoveryHandlerTests.swift @@ -3,8 +3,8 @@ import OHHTTPStubs import OHHTTPStubsSwift import XCTest -final class RedirectHandlerTests: XCTestCase { - private let handler = RedirectHandler() +final class JSFetchRecoveryHandlerTests: XCTestCase { + private let handler = JSFetchRecoveryHandler() private let testURL = URL(string: "https://example.com/page")! override func tearDown() { @@ -72,7 +72,7 @@ final class RedirectHandlerTests: XCTestCase { do { _ = try await handler.resolve(location: testURL) XCTFail("Expected requestFailed error") - } catch let error as RedirectHandlerError { + } catch let error as JSFetchRecoveryError { guard case .requestFailed = error else { return XCTFail("Expected .requestFailed, got \(error)") } @@ -123,8 +123,8 @@ final class RedirectHandlerTests: XCTestCase { // MARK: - Equatable for test assertions -extension RedirectHandler.Result: @retroactive Equatable { - public static func == (lhs: RedirectHandler.Result, rhs: RedirectHandler.Result) -> Bool { +extension JSFetchRecoveryHandler.Result: Equatable { + public static func == (lhs: JSFetchRecoveryHandler.Result, rhs: JSFetchRecoveryHandler.Result) -> Bool { switch (lhs, rhs) { case (.noRedirect, .noRedirect): return true diff --git a/Tests/Turbo/Navigator/NavigatorRetryHandlerTests.swift b/Tests/Turbo/Navigator/NavigatorRetryHandlerTests.swift new file mode 100644 index 00000000..d2c967b1 --- /dev/null +++ b/Tests/Turbo/Navigator/NavigatorRetryHandlerTests.swift @@ -0,0 +1,87 @@ +@testable import HotwireNative +import WebKit +import XCTest + +final class NavigatorRetryHandlerTests: XCTestCase { + private var delegate: RetryCapturingNavigatorDelegate! + private var session: RetryRecordingSessionSpy! + private var navigator: Navigator! + private let visitable = TestVisitable(url: URL(string: "https://example.com")!) + + override func setUp() { + delegate = RetryCapturingNavigatorDelegate() + session = RetryRecordingSessionSpy(webView: Hotwire.config.makeWebView()) + navigator = Navigator( + session: session, + modalSession: Session(webView: Hotwire.config.makeWebView()), + delegate: delegate, + configuration: .init(name: "", startLocation: URL(string: "https://example.com")!) + ) + } + + /// A retry handler is now always provided, even for errors that were + /// previously considered non-retryable (e.g. a 404). + func test_didFailRequest_alwaysProvidesRetryHandler() { + navigator.session(session, didFailRequestForVisitable: visitable, error: .http(.client(.notFound))) + + XCTAssertTrue(delegate.didFailRequestCalled) + XCTAssertNotNil(delegate.capturedRetryHandler) + } + + /// When there is no topmost visitable yet (e.g. a failed cold boot), + /// `session.reload()` would no-op, so the handler retries the visit directly. + func test_retryHandler_withNoTopmostVisitable_visitsVisitableWithReload() { + session.stubbedTopmostVisitable = nil + + navigator.session(session, didFailRequestForVisitable: visitable, error: .http(.client(.notFound))) + delegate.capturedRetryHandler?() + + XCTAssertEqual(session.visitWithReloadCallCount, 1) + XCTAssertIdentical(session.visitedVisitable, visitable) + XCTAssertEqual(session.reloadCallCount, 0) + } + + /// When a topmost visitable exists, the handler reloads the session as before. + func test_retryHandler_withTopmostVisitable_reloadsSession() { + session.stubbedTopmostVisitable = visitable + + navigator.session(session, didFailRequestForVisitable: visitable, error: .http(.client(.notFound))) + delegate.capturedRetryHandler?() + + XCTAssertEqual(session.reloadCallCount, 1) + XCTAssertEqual(session.visitWithReloadCallCount, 0) + } +} + +// MARK: - Test doubles + +private final class RetryCapturingNavigatorDelegate: NavigatorDelegate { + private(set) var didFailRequestCalled = false + private(set) var capturedRetryHandler: RetryBlock? + + func visitableDidFailRequest(_ visitable: Visitable, error: HotwireNativeError, retryHandler: RetryBlock?) { + didFailRequestCalled = true + capturedRetryHandler = retryHandler + } +} + +private final class RetryRecordingSessionSpy: Session { + var stubbedTopmostVisitable: Visitable? + private(set) var reloadCallCount = 0 + private(set) var visitWithReloadCallCount = 0 + private(set) var visitedVisitable: Visitable? + + override var topmostVisitable: Visitable? { + stubbedTopmostVisitable + } + + override func reload() { + reloadCallCount += 1 + } + + override func visit(_ visitable: any Visitable, options: VisitOptions?, reload: Bool) { + guard reload else { return } + visitWithReloadCallCount += 1 + visitedVisitable = visitable + } +}