From 3cc2a6fb747666fff21bd630546e1e2e1bb90523 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 03:16:02 +0200 Subject: [PATCH 1/6] fix: retry a failed node start when the app returns to the foreground The scene-phase active handler now restarts the node when it is in errorStarting with a wallet and a connection, sharing the restart used when the network is restored. --- Bitkit/AppScene.swift | 24 +++++++++++++++++---- BitkitTests/NodeStartRetryTests.swift | 30 +++++++++++++++++++++++++++ changelog.d/next/777.fixed.md | 1 + 3 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 BitkitTests/NodeStartRetryTests.swift create mode 100644 changelog.d/next/777.fixed.md diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 328c244d9..5b595c58f 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -207,6 +207,11 @@ struct PaykitPaymentRequestPollingSchedule { struct AppScene: View { private static let initialPaykitSyncRetryDelays = Array(repeating: Duration.seconds(2), count: 14) + static func shouldRetryNodeStart(state: NodeLifecycleState, isConnected: Bool, walletExists: Bool?) -> Bool { + guard isConnected, walletExists == true, case .errorStarting = state else { return false } + return true + } + @Environment(\.scenePhase) var scenePhase @EnvironmentObject private var session: SessionManager @@ -1070,6 +1075,13 @@ struct AppScene: View { if retryPendingWalletRestoreIfNeeded() { return } + if Self.shouldRetryNodeStart( + state: wallet.nodeLifecycleState, + isConnected: network.isConnected, + walletExists: wallet.walletExists + ) { + restartNode(reason: "App returned to foreground") + } Task { if pubkyProfile.isInitialized { await pubkyProfile.checkAdoptedSource() @@ -1561,16 +1573,20 @@ struct AppScene: View { // Restart node if necessary (e.g. create/restore was skipped due to offline) switch wallet.nodeLifecycleState { case .stopped, .initializing, .errorStarting: - Logger.info("Network restored, retrying wallet start...", context: "AppScene") - Task { - await startWallet() - } + restartNode(reason: "Network restored") default: break } } } + private func restartNode(reason: String) { + Logger.info("\(reason), retrying wallet start...", context: "AppScene") + Task { + await startWallet() + } + } + private func handleQuickAction(_ notification: Notification) { guard let userInfo = notification.userInfo, let shortcutType = userInfo["shortcutType"] as? String diff --git a/BitkitTests/NodeStartRetryTests.swift b/BitkitTests/NodeStartRetryTests.swift new file mode 100644 index 000000000..c63be3d12 --- /dev/null +++ b/BitkitTests/NodeStartRetryTests.swift @@ -0,0 +1,30 @@ +@testable import Bitkit +import XCTest + +final class NodeStartRetryTests: XCTestCase { + private struct StartFailure: Error {} + + func testRetriesAFailedStartWhenConnectedWithAWallet() { + XCTAssertTrue(AppScene.shouldRetryNodeStart(state: .errorStarting(cause: StartFailure()), isConnected: true, walletExists: true)) + } + + func testDoesNotRetryWhileOffline() { + XCTAssertFalse(AppScene.shouldRetryNodeStart(state: .errorStarting(cause: StartFailure()), isConnected: false, walletExists: true)) + } + + func testDoesNotRetryWithoutAWallet() { + for walletExists in [false, nil] { + XCTAssertFalse(AppScene.shouldRetryNodeStart( + state: .errorStarting(cause: StartFailure()), + isConnected: true, + walletExists: walletExists + )) + } + } + + func testDoesNotRetryStatesThatAreNotAFailedStart() { + for state in [NodeLifecycleState.stopped, .starting, .running, .stopping, .initializing] { + XCTAssertFalse(AppScene.shouldRetryNodeStart(state: state, isConnected: true, walletExists: true), "\(state)") + } + } +} diff --git a/changelog.d/next/777.fixed.md b/changelog.d/next/777.fixed.md new file mode 100644 index 000000000..7dda770d6 --- /dev/null +++ b/changelog.d/next/777.fixed.md @@ -0,0 +1 @@ +Fixed the wallet staying on "Connection issues" after a failed node start until the app was relaunched; it now retries when the app returns to the foreground. From d323213090350faf7c9f0d4af3d07672b333f4fd Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 03:46:33 +0200 Subject: [PATCH 2/6] chore: rename changelog fragment --- changelog.d/next/{777.fixed.md => 848.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{777.fixed.md => 848.fixed.md} (100%) diff --git a/changelog.d/next/777.fixed.md b/changelog.d/next/848.fixed.md similarity index 100% rename from changelog.d/next/777.fixed.md rename to changelog.d/next/848.fixed.md From c84c12a03f77219aa5f205f6706b3b8d7f71757b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 1 Oct 2026 21:36:48 +0200 Subject: [PATCH 3/6] fix: skip the foreground node start retry while recovery mode is shown --- Bitkit/AppScene.swift | 12 +++++++++--- BitkitTests/NodeStartRetryTests.swift | 27 +++++++++++++++++++-------- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 5b595c58f..d46969daa 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -207,8 +207,13 @@ struct PaykitPaymentRequestPollingSchedule { struct AppScene: View { private static let initialPaykitSyncRetryDelays = Array(repeating: Duration.seconds(2), count: 14) - static func shouldRetryNodeStart(state: NodeLifecycleState, isConnected: Bool, walletExists: Bool?) -> Bool { - guard isConnected, walletExists == true, case .errorStarting = state else { return false } + static func shouldRetryNodeStart( + state: NodeLifecycleState, + isConnected: Bool, + walletExists: Bool?, + isRecoveryShown: Bool + ) -> Bool { + guard isConnected, walletExists == true, !isRecoveryShown, case .errorStarting = state else { return false } return true } @@ -1078,7 +1083,8 @@ struct AppScene: View { if Self.shouldRetryNodeStart( state: wallet.nodeLifecycleState, isConnected: network.isConnected, - walletExists: wallet.walletExists + walletExists: wallet.walletExists, + isRecoveryShown: showRecoveryScreen ) { restartNode(reason: "App returned to foreground") } diff --git a/BitkitTests/NodeStartRetryTests.swift b/BitkitTests/NodeStartRetryTests.swift index c63be3d12..90ba8f8d5 100644 --- a/BitkitTests/NodeStartRetryTests.swift +++ b/BitkitTests/NodeStartRetryTests.swift @@ -4,27 +4,38 @@ import XCTest final class NodeStartRetryTests: XCTestCase { private struct StartFailure: Error {} + private let failedStart = NodeLifecycleState.errorStarting(cause: StartFailure()) + + private func shouldRetry( + _ state: NodeLifecycleState, + isConnected: Bool = true, + walletExists: Bool? = true, + isRecoveryShown: Bool = false + ) -> Bool { + AppScene.shouldRetryNodeStart(state: state, isConnected: isConnected, walletExists: walletExists, isRecoveryShown: isRecoveryShown) + } + func testRetriesAFailedStartWhenConnectedWithAWallet() { - XCTAssertTrue(AppScene.shouldRetryNodeStart(state: .errorStarting(cause: StartFailure()), isConnected: true, walletExists: true)) + XCTAssertTrue(shouldRetry(failedStart)) } func testDoesNotRetryWhileOffline() { - XCTAssertFalse(AppScene.shouldRetryNodeStart(state: .errorStarting(cause: StartFailure()), isConnected: false, walletExists: true)) + XCTAssertFalse(shouldRetry(failedStart, isConnected: false)) + } + + func testDoesNotRetryWhileTheRecoveryScreenIsShown() { + XCTAssertFalse(shouldRetry(failedStart, isRecoveryShown: true)) } func testDoesNotRetryWithoutAWallet() { for walletExists in [false, nil] { - XCTAssertFalse(AppScene.shouldRetryNodeStart( - state: .errorStarting(cause: StartFailure()), - isConnected: true, - walletExists: walletExists - )) + XCTAssertFalse(shouldRetry(failedStart, walletExists: walletExists)) } } func testDoesNotRetryStatesThatAreNotAFailedStart() { for state in [NodeLifecycleState.stopped, .starting, .running, .stopping, .initializing] { - XCTAssertFalse(AppScene.shouldRetryNodeStart(state: state, isConnected: true, walletExists: true), "\(state)") + XCTAssertFalse(shouldRetry(state), "\(state)") } } } From f5fa8957548a5493acc8192a54b3e24fb5eec2cc Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 2 Oct 2026 00:29:00 +0200 Subject: [PATCH 4/6] fix: check recovery mode when the node restart runs --- Bitkit/AppScene.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index d46969daa..338d066db 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1587,8 +1587,13 @@ struct AppScene: View { } private func restartNode(reason: String) { - Logger.info("\(reason), retrying wallet start...", context: "AppScene") Task { + // Checked when the task runs, because the Recovery quick action can be handled after the caller decided to restart. + guard !showRecoveryScreen else { + Logger.info("\(reason), skipping wallet start in recovery mode", context: "AppScene") + return + } + Logger.info("\(reason), retrying wallet start...", context: "AppScene") await startWallet() } } From cbb424197103157933202b5b652ac2bcc9c24fb8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 2 Oct 2026 12:47:25 +0200 Subject: [PATCH 5/6] fix: retry the node start only after a return from the background and stop a node started under recovery --- Bitkit/AppScene.swift | 29 ++++++++++++++++++++++++--- BitkitTests/NodeStartRetryTests.swift | 26 ++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 338d066db..f01d089ee 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -211,12 +211,18 @@ struct AppScene: View { state: NodeLifecycleState, isConnected: Bool, walletExists: Bool?, - isRecoveryShown: Bool + isRecoveryShown: Bool, + returnedFromBackground: Bool ) -> Bool { - guard isConnected, walletExists == true, !isRecoveryShown, case .errorStarting = state else { return false } + guard returnedFromBackground, isConnected, walletExists == true, !isRecoveryShown, case .errorStarting = state else { return false } return true } + /// A restart can still be in flight when the Recovery quick action lands; the node it started must not keep running under Recovery. + static func shouldStopNodeStartedUnderRecovery(state: NodeLifecycleState, isRecoveryShown: Bool) -> Bool { + isRecoveryShown && state == .running + } + @Environment(\.scenePhase) var scenePhase @EnvironmentObject private var session: SessionManager @@ -259,6 +265,9 @@ struct AppScene: View { @State private var didWalletBackupRestoreFail = false @State private var isPinVerified: Bool = false @State private var showRecoveryScreen = false + /// Set when the app enters the background, so that only a return from the background retries a failed node start, + /// not a brief inactive phase (Control Center, notification shade, Face ID). + @State private var wasInBackground = false /// Check if there's a critical update available private var hasCriticalUpdate: Bool { @@ -1061,6 +1070,7 @@ struct AppScene: View { Logger.info("Scene phase changed: \(newPhase)", context: "AppScene") if newPhase == .background { + wasInBackground = true if settings.pinEnabled { // If PIN is enabled, lock the app when the app goes to the background isPinVerified = false @@ -1072,6 +1082,8 @@ struct AppScene: View { if newPhase == .active { // Called even behind the PIN screen, so a background release still pending is called off. hwWalletManager.onAppBecameActive() + let returnedFromBackground = wasInBackground + wasInBackground = false // Reconnect a known hardware device so its connection indicator turns green again; if isPinVerified || !settings.pinEnabled { Task { await hwWalletManager.reconnectOnForeground() } @@ -1084,7 +1096,8 @@ struct AppScene: View { state: wallet.nodeLifecycleState, isConnected: network.isConnected, walletExists: wallet.walletExists, - isRecoveryShown: showRecoveryScreen + isRecoveryShown: showRecoveryScreen, + returnedFromBackground: returnedFromBackground ) { restartNode(reason: "App returned to foreground") } @@ -1595,6 +1608,16 @@ struct AppScene: View { } Logger.info("\(reason), retrying wallet start...", context: "AppScene") await startWallet() + + // Recovery can open while the start is in flight; stop the node this restart started instead of leaving it running under Recovery. + if Self.shouldStopNodeStartedUnderRecovery(state: wallet.nodeLifecycleState, isRecoveryShown: showRecoveryScreen) { + Logger.info("\(reason), stopping the node started while recovery mode opened", context: "AppScene") + do { + try await wallet.stopLightningNode() + } catch { + Logger.warn("Failed to stop the node under recovery mode: \(error)", context: "AppScene") + } + } } } diff --git a/BitkitTests/NodeStartRetryTests.swift b/BitkitTests/NodeStartRetryTests.swift index 90ba8f8d5..9196209d2 100644 --- a/BitkitTests/NodeStartRetryTests.swift +++ b/BitkitTests/NodeStartRetryTests.swift @@ -10,9 +10,16 @@ final class NodeStartRetryTests: XCTestCase { _ state: NodeLifecycleState, isConnected: Bool = true, walletExists: Bool? = true, - isRecoveryShown: Bool = false + isRecoveryShown: Bool = false, + returnedFromBackground: Bool = true ) -> Bool { - AppScene.shouldRetryNodeStart(state: state, isConnected: isConnected, walletExists: walletExists, isRecoveryShown: isRecoveryShown) + AppScene.shouldRetryNodeStart( + state: state, + isConnected: isConnected, + walletExists: walletExists, + isRecoveryShown: isRecoveryShown, + returnedFromBackground: returnedFromBackground + ) } func testRetriesAFailedStartWhenConnectedWithAWallet() { @@ -27,6 +34,21 @@ final class NodeStartRetryTests: XCTestCase { XCTAssertFalse(shouldRetry(failedStart, isRecoveryShown: true)) } + func testDoesNotRetryOnAnInactiveBlipThatNeverEnteredTheBackground() { + XCTAssertFalse(shouldRetry(failedStart, returnedFromBackground: false)) + } + + func testStopsANodeThatStartedWhileRecoveryOpened() { + XCTAssertTrue(AppScene.shouldStopNodeStartedUnderRecovery(state: .running, isRecoveryShown: true)) + } + + func testLeavesTheNodeAloneOutsideRecoveryOrBeforeItRuns() { + XCTAssertFalse(AppScene.shouldStopNodeStartedUnderRecovery(state: .running, isRecoveryShown: false)) + for state in [NodeLifecycleState.stopped, .starting, .stopping, .initializing, failedStart] { + XCTAssertFalse(AppScene.shouldStopNodeStartedUnderRecovery(state: state, isRecoveryShown: true), "\(state)") + } + } + func testDoesNotRetryWithoutAWallet() { for walletExists in [false, nil] { XCTAssertFalse(shouldRetry(failedStart, walletExists: walletExists)) From c011a0716e4116383bf19ee9a46cb7ef4e14ea75 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 6 Oct 2026 00:10:53 +0200 Subject: [PATCH 6/6] fix: keep lifecycle node restarts silent and test the restart effects --- Bitkit/AppScene.swift | 77 ++++-------- Bitkit/Utilities/NodeRestarter.swift | 68 +++++++++++ BitkitTests/NodeStartRetryTests.swift | 166 ++++++++++++++++++++------ 3 files changed, 220 insertions(+), 91 deletions(-) create mode 100644 Bitkit/Utilities/NodeRestarter.swift diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index f01d089ee..5b229a06d 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -207,22 +207,6 @@ struct PaykitPaymentRequestPollingSchedule { struct AppScene: View { private static let initialPaykitSyncRetryDelays = Array(repeating: Duration.seconds(2), count: 14) - static func shouldRetryNodeStart( - state: NodeLifecycleState, - isConnected: Bool, - walletExists: Bool?, - isRecoveryShown: Bool, - returnedFromBackground: Bool - ) -> Bool { - guard returnedFromBackground, isConnected, walletExists == true, !isRecoveryShown, case .errorStarting = state else { return false } - return true - } - - /// A restart can still be in flight when the Recovery quick action lands; the node it started must not keep running under Recovery. - static func shouldStopNodeStartedUnderRecovery(state: NodeLifecycleState, isRecoveryShown: Bool) -> Bool { - isRecoveryShown && state == .running - } - @Environment(\.scenePhase) var scenePhase @EnvironmentObject private var session: SessionManager @@ -265,9 +249,19 @@ struct AppScene: View { @State private var didWalletBackupRestoreFail = false @State private var isPinVerified: Bool = false @State private var showRecoveryScreen = false - /// Set when the app enters the background, so that only a return from the background retries a failed node start, - /// not a brief inactive phase (Control Center, notification shade, Face ID). - @State private var wasInBackground = false + /// Lets only a return from the background retry a failed node start, not a brief inactive phase. + @State private var foregroundReturnTracker = ForegroundReturnTracker() + + private var nodeRestarter: NodeRestarter { + NodeRestarter( + nodeState: { wallet.nodeLifecycleState }, + isConnected: { network.isConnected }, + walletExists: { wallet.walletExists }, + isRecoveryShown: { showRecoveryScreen }, + start: { playsErrorHaptic in await startWallet(playsErrorHaptic: playsErrorHaptic) }, + stop: { try await wallet.stopLightningNode() } + ) + } /// Check if there's a critical update available private var hasCriticalUpdate: Bool { @@ -784,7 +778,7 @@ struct AppScene: View { } } - private func startWallet(completingBackupRestore: Bool = false) async { + private func startWallet(completingBackupRestore: Bool = false, playsErrorHaptic: Bool = true) async { let hasPendingRestore = BackupService.shared.hasPendingWalletRestore() guard !WalletBackupRestoreGate.blocksWalletStart( isRestoreRunning: isWalletBackupRestoreRunning, @@ -825,7 +819,9 @@ struct AppScene: View { await BackupService.shared.scheduleFullBackup() } catch { Logger.error(error, context: "Failed to start wallet") - Haptics.notify(.error) + if playsErrorHaptic { + Haptics.notify(.error) + } if MigrationsService.shared.isShowingMigrationLoading { await MainActor.run { @@ -1069,8 +1065,9 @@ struct AppScene: View { private func handleScenePhaseChange(_ newPhase: ScenePhase) { Logger.info("Scene phase changed: \(newPhase)", context: "AppScene") + let returnedFromBackground = foregroundReturnTracker.scenePhaseChanged(to: newPhase) + if newPhase == .background { - wasInBackground = true if settings.pinEnabled { // If PIN is enabled, lock the app when the app goes to the background isPinVerified = false @@ -1082,8 +1079,6 @@ struct AppScene: View { if newPhase == .active { // Called even behind the PIN screen, so a background release still pending is called off. hwWalletManager.onAppBecameActive() - let returnedFromBackground = wasInBackground - wasInBackground = false // Reconnect a known hardware device so its connection indicator turns green again; if isPinVerified || !settings.pinEnabled { Task { await hwWalletManager.reconnectOnForeground() } @@ -1092,15 +1087,7 @@ struct AppScene: View { if retryPendingWalletRestoreIfNeeded() { return } - if Self.shouldRetryNodeStart( - state: wallet.nodeLifecycleState, - isConnected: network.isConnected, - walletExists: wallet.walletExists, - isRecoveryShown: showRecoveryScreen, - returnedFromBackground: returnedFromBackground - ) { - restartNode(reason: "App returned to foreground") - } + nodeRestarter.retryOnForeground(returnedFromBackground: returnedFromBackground) Task { if pubkyProfile.isInitialized { await pubkyProfile.checkAdoptedSource() @@ -1592,35 +1579,13 @@ struct AppScene: View { // Restart node if necessary (e.g. create/restore was skipped due to offline) switch wallet.nodeLifecycleState { case .stopped, .initializing, .errorStarting: - restartNode(reason: "Network restored") + nodeRestarter.restart(reason: "Network restored") default: break } } } - private func restartNode(reason: String) { - Task { - // Checked when the task runs, because the Recovery quick action can be handled after the caller decided to restart. - guard !showRecoveryScreen else { - Logger.info("\(reason), skipping wallet start in recovery mode", context: "AppScene") - return - } - Logger.info("\(reason), retrying wallet start...", context: "AppScene") - await startWallet() - - // Recovery can open while the start is in flight; stop the node this restart started instead of leaving it running under Recovery. - if Self.shouldStopNodeStartedUnderRecovery(state: wallet.nodeLifecycleState, isRecoveryShown: showRecoveryScreen) { - Logger.info("\(reason), stopping the node started while recovery mode opened", context: "AppScene") - do { - try await wallet.stopLightningNode() - } catch { - Logger.warn("Failed to stop the node under recovery mode: \(error)", context: "AppScene") - } - } - } - } - private func handleQuickAction(_ notification: Notification) { guard let userInfo = notification.userInfo, let shortcutType = userInfo["shortcutType"] as? String diff --git a/Bitkit/Utilities/NodeRestarter.swift b/Bitkit/Utilities/NodeRestarter.swift new file mode 100644 index 000000000..031a51b6d --- /dev/null +++ b/Bitkit/Utilities/NodeRestarter.swift @@ -0,0 +1,68 @@ +import SwiftUI + +/// Tells a return from the background apart from a brief `.inactive` phase (Control Center, notification shade, +/// Face ID prompt) that never left the foreground. +struct ForegroundReturnTracker { + private var wasInBackground = false + + /// Records the phase and returns true when it is an `.active` phase that follows a `.background` one. + mutating func scenePhaseChanged(to phase: ScenePhase) -> Bool { + switch phase { + case .background: + wasInBackground = true + return false + case .active: + defer { wasInBackground = false } + return wasInBackground + default: + return false + } + } +} + +/// Restarts a node that failed to start on behalf of the app lifecycle, without leaving it running under recovery mode. +@MainActor +struct NodeRestarter { + var nodeState: () -> NodeLifecycleState + var isConnected: () -> Bool + var walletExists: () -> Bool? + var isRecoveryShown: () -> Bool + /// Starts the wallet; the flag says whether a failed start plays the error haptic. + var start: (_ playsErrorHaptic: Bool) async -> Void + var stop: () async throws -> Void + + /// Schedules a restart when the app returned from the background while a wallet exists, the network is connected, + /// the node is in the error-starting state and the Recovery screen is not shown. + @discardableResult + func retryOnForeground(returnedFromBackground: Bool) -> Task? { + guard returnedFromBackground, isConnected(), walletExists() == true, !isRecoveryShown(), case .errorStarting = nodeState() else { + return nil + } + return restart(reason: "App returned to foreground") + } + + /// Starts the wallet unless Recovery is shown. A start the user did not trigger is silent on failure, and a node it + /// started while Recovery opened is stopped once the start completes. + @discardableResult + func restart(reason: String) -> Task { + Task { + // Checked when the task runs, because the Recovery quick action can be handled after the caller decided to restart. + guard !isRecoveryShown() else { + Logger.info("\(reason), skipping wallet start in recovery mode", context: "NodeRestarter") + return + } + Logger.info("\(reason), retrying wallet start...", context: "NodeRestarter") + await start(false) + + // Recovery can open while the start is in flight; stop the node this restart started instead of leaving it running. + if isRecoveryShown(), nodeState() == .running { + Logger.info("\(reason), stopping the node started while recovery mode opened", context: "NodeRestarter") + do { + try await stop() + } catch { + Logger.warn("Failed to stop the node under recovery mode: \(error)", context: "NodeRestarter") + } + } + } + } +} diff --git a/BitkitTests/NodeStartRetryTests.swift b/BitkitTests/NodeStartRetryTests.swift index 9196209d2..e704e0157 100644 --- a/BitkitTests/NodeStartRetryTests.swift +++ b/BitkitTests/NodeStartRetryTests.swift @@ -1,63 +1,159 @@ @testable import Bitkit +import SwiftUI import XCTest +@MainActor final class NodeStartRetryTests: XCTestCase { private struct StartFailure: Error {} - private let failedStart = NodeLifecycleState.errorStarting(cause: StartFailure()) + /// Stands in for the wallet, the network and the Recovery screen behind a `NodeRestarter`. + private final class Harness { + var state = NodeLifecycleState.errorStarting(cause: StartFailure()) + var isConnected = true + var walletExists: Bool? = true + var isRecoveryShown = false + var startHaptics: [Bool] = [] + var stopCalls = 0 + var stopError: Error? + /// Runs inside the start, to change the world while the start is in flight. + var duringStart: (() -> Void)? - private func shouldRetry( - _ state: NodeLifecycleState, - isConnected: Bool = true, - walletExists: Bool? = true, - isRecoveryShown: Bool = false, - returnedFromBackground: Bool = true - ) -> Bool { - AppScene.shouldRetryNodeStart( - state: state, - isConnected: isConnected, - walletExists: walletExists, - isRecoveryShown: isRecoveryShown, - returnedFromBackground: returnedFromBackground - ) + func makeRestarter() -> NodeRestarter { + NodeRestarter( + nodeState: { self.state }, + isConnected: { self.isConnected }, + walletExists: { self.walletExists }, + isRecoveryShown: { self.isRecoveryShown }, + start: { playsErrorHaptic in + self.startHaptics.append(playsErrorHaptic) + self.duringStart?() + }, + stop: { + self.stopCalls += 1 + if let error = self.stopError { + throw error + } + } + ) + } } - func testRetriesAFailedStartWhenConnectedWithAWallet() { - XCTAssertTrue(shouldRetry(failedStart)) + // MARK: Retry on returning to the foreground + + func testRetriesAFailedStartAfterReturningFromTheBackground() async { + let harness = Harness() + await harness.makeRestarter().retryOnForeground(returnedFromBackground: true)?.value + XCTAssertEqual(harness.startHaptics.count, 1) } - func testDoesNotRetryWhileOffline() { - XCTAssertFalse(shouldRetry(failedStart, isConnected: false)) + func testDoesNotRetryOnAnInactiveBlipThatNeverEnteredTheBackground() async { + let harness = Harness() + let task = harness.makeRestarter().retryOnForeground(returnedFromBackground: false) + await task?.value + XCTAssertNil(task) + XCTAssertTrue(harness.startHaptics.isEmpty) } - func testDoesNotRetryWhileTheRecoveryScreenIsShown() { - XCTAssertFalse(shouldRetry(failedStart, isRecoveryShown: true)) + func testDoesNotRetryWhileOffline() async { + let harness = Harness() + harness.isConnected = false + await harness.makeRestarter().retryOnForeground(returnedFromBackground: true)?.value + XCTAssertTrue(harness.startHaptics.isEmpty) } - func testDoesNotRetryOnAnInactiveBlipThatNeverEnteredTheBackground() { - XCTAssertFalse(shouldRetry(failedStart, returnedFromBackground: false)) + func testDoesNotRetryWithoutAWallet() async { + for walletExists in [false, nil] { + let harness = Harness() + harness.walletExists = walletExists + await harness.makeRestarter().retryOnForeground(returnedFromBackground: true)?.value + XCTAssertTrue(harness.startHaptics.isEmpty, "\(String(describing: walletExists))") + } } - func testStopsANodeThatStartedWhileRecoveryOpened() { - XCTAssertTrue(AppScene.shouldStopNodeStartedUnderRecovery(state: .running, isRecoveryShown: true)) + func testDoesNotRetryWhileTheRecoveryScreenIsShown() async { + let harness = Harness() + harness.isRecoveryShown = true + await harness.makeRestarter().retryOnForeground(returnedFromBackground: true)?.value + XCTAssertTrue(harness.startHaptics.isEmpty) } - func testLeavesTheNodeAloneOutsideRecoveryOrBeforeItRuns() { - XCTAssertFalse(AppScene.shouldStopNodeStartedUnderRecovery(state: .running, isRecoveryShown: false)) - for state in [NodeLifecycleState.stopped, .starting, .stopping, .initializing, failedStart] { - XCTAssertFalse(AppScene.shouldStopNodeStartedUnderRecovery(state: state, isRecoveryShown: true), "\(state)") + func testDoesNotRetryStatesThatAreNotAFailedStart() async { + for state in [NodeLifecycleState.stopped, .starting, .running, .stopping, .initializing] { + let harness = Harness() + harness.state = state + await harness.makeRestarter().retryOnForeground(returnedFromBackground: true)?.value + XCTAssertTrue(harness.startHaptics.isEmpty, "\(state)") } } - func testDoesNotRetryWithoutAWallet() { - for walletExists in [false, nil] { - XCTAssertFalse(shouldRetry(failedStart, walletExists: walletExists)) + // MARK: Error haptic + + func testRestartsTriggeredByTheLifecycleDoNotPlayTheErrorHaptic() async { + let foreground = Harness() + await foreground.makeRestarter().retryOnForeground(returnedFromBackground: true)?.value + XCTAssertEqual(foreground.startHaptics, [false]) + + let networkRestored = Harness() + await networkRestored.makeRestarter().restart(reason: "Network restored").value + XCTAssertEqual(networkRestored.startHaptics, [false]) + } + + // MARK: Recovery + + func testRestartDoesNotStartTheNodeWhileTheRecoveryScreenIsShown() async { + let harness = Harness() + harness.isRecoveryShown = true + await harness.makeRestarter().restart(reason: "Network restored").value + XCTAssertTrue(harness.startHaptics.isEmpty) + XCTAssertEqual(harness.stopCalls, 0) + } + + func testStopsANodeThatStartedWhileRecoveryOpened() async { + let harness = Harness() + harness.duringStart = { + harness.state = .running + harness.isRecoveryShown = true } + await harness.makeRestarter().retryOnForeground(returnedFromBackground: true)?.value + XCTAssertEqual(harness.stopCalls, 1) } - func testDoesNotRetryStatesThatAreNotAFailedStart() { - for state in [NodeLifecycleState.stopped, .starting, .running, .stopping, .initializing] { - XCTAssertFalse(shouldRetry(state), "\(state)") + func testLeavesTheNodeAloneWhenRecoveryOpenedButTheStartFailed() async { + let harness = Harness() + harness.duringStart = { harness.isRecoveryShown = true } + await harness.makeRestarter().restart(reason: "Network restored").value + XCTAssertEqual(harness.stopCalls, 0) + } + + func testLeavesARunningNodeAloneOutsideRecovery() async { + let harness = Harness() + harness.duringStart = { harness.state = .running } + await harness.makeRestarter().restart(reason: "Network restored").value + XCTAssertEqual(harness.stopCalls, 0) + } + + func testAFailedStopDoesNotEndTheRestartWithAnError() async { + let harness = Harness() + harness.stopError = StartFailure() + harness.duringStart = { + harness.state = .running + harness.isRecoveryShown = true } + await harness.makeRestarter().restart(reason: "Network restored").value + XCTAssertEqual(harness.stopCalls, 1) + } + + // MARK: Scene phases + + func testOnlyAnActivePhaseAfterTheBackgroundIsAReturnFromTheBackground() { + var tracker = ForegroundReturnTracker() + XCTAssertFalse(tracker.scenePhaseChanged(to: .active)) + XCTAssertFalse(tracker.scenePhaseChanged(to: .inactive)) + XCTAssertFalse(tracker.scenePhaseChanged(to: .active), "an inactive blip is not a return") + XCTAssertFalse(tracker.scenePhaseChanged(to: .inactive)) + XCTAssertFalse(tracker.scenePhaseChanged(to: .background)) + XCTAssertFalse(tracker.scenePhaseChanged(to: .inactive)) + XCTAssertTrue(tracker.scenePhaseChanged(to: .active)) + XCTAssertFalse(tracker.scenePhaseChanged(to: .active), "the return counts once") } }