// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import SwiftUI import TermindApplication import TermindDomain import Testing import struct TermindDomain.Host @testable import TermindPresentation @Suite @MainActor struct ConnectionJourneyRouteStyleTests { @Test( arguments: [ ConnectionPurpose.sshSession, .moshSession, .eternalTerminalSession, .sftpChannel, .metrics, .portForwarding, .keyExport, ], [1, 2, 3] ) func failureIncludesTheFailingEndpointAndIncomingLineButNotUntouchedRoute( purpose: ConnectionPurpose, failedIndex: Int ) { let (journey, hosts) = fixture(purpose) for host in hosts.prefix(failedIndex - 1) { journey.receive(.connected(host.id)) } let failedHost = hosts[failedIndex - 1] journey.receive(.connecting(failedHost.id)) journey.fail("Transport failed", at: failedHost.id) let style = ConnectionJourneyRouteStyle(journey: journey) // The failed endpoint was never completed; it and its incoming line must still be red. #expect(journey.completedEndpointIndex < failedIndex) #expect(style.state(for: .endpoint, at: failedIndex) == .failed) #expect(style.state(for: .segment, at: failedIndex) == .failed) expectRoute( journey, states: Array(repeating: .failed, count: failedIndex + 1) + Array(repeating: .pending, count: hosts.count - failedIndex)) } @Test func dataConnectionFailureAfterSSHBootstrapRecolorsTheWholeRouteWithoutMovingFailureAttribution() { let (journey, hosts) = fixture(.eternalTerminalSession) completeBootstrap(journey, hosts: hosts) expectRoute(journey, states: [.complete, .complete, .complete, .complete]) journey.fail("ET data connection failed", at: hosts[0].id) #expect(journey.activeEndpointIndex == 1) #expect(journey.logs.last?.endpoint == hosts[0].name) #expect(journey.completedCheckpoints[1] == .authentication) #expect(journey.completedCheckpoints[2] == .hostKey) #expect(journey.completedCheckpoints[3] == .authentication) expectRoute(journey, states: [.failed, .failed, .failed, .failed]) } @Test func etDataRouteAuthenticationFailureStillRecolorsTheWholeBootstrappedRoute() { let (journey, hosts) = fixture(.eternalTerminalSession) completeBootstrap(journey, hosts: hosts) let attempt = UUID() journey.receiveTransportRoute( .init(attemptID: attempt, revision: 1, stage: .opening, activeHostID: hosts[0].id)) #expect(ConnectionJourneyRouteStyle(journey: journey).state(for: .endpoint, at: 3) == .pending) journey.receiveTransportRoute(.init(attemptID: attempt, revision: 2, stage: .unavailable)) journey.fail("ET data route authentication rejected", at: hosts[0].id) #expect(journey.activeEndpointIndex == 1) #expect(journey.logs.last?.endpoint == hosts[0].name) expectRoute(journey, states: [.failed, .failed, .failed, .failed]) #expect(ConnectionJourneyRouteStyle(journey: journey).activityEndpointIndex == nil) journey.receiveTransportRoute(.init(attemptID: attempt, revision: 3, stage: .unavailable)) expectRoute(journey, states: [.failed, .failed, .failed, .failed]) } @Test func completedCheckpointStillCountsAsProgressBeforeItsEndpointFinishesConnecting() { let (journey, hosts) = fixture(.sshSession) journey.receive(.connected(hosts[0].id)) journey.waitForHostKey(at: hosts[1].id, fingerprint: "SHA256:test") journey.resume() journey.fail("Jump transport lost", at: hosts[0].id) #expect(journey.completedEndpointIndex == 1) #expect(journey.completedCheckpoints[2] == .hostKey) expectRoute(journey, states: [.failed, .failed, .failed, .pending]) } @Test(arguments: [ConnectionPurpose.moshSession, .eternalTerminalSession]) func recoveryFailureRecolorsLocalDeviceAllHopsAndAllLines(_ purpose: ConnectionPurpose) { let (journey, hosts) = fixture(purpose) completeBootstrap(journey, hosts: hosts) journey.succeed() journey.hide() journey.updateTransportRecovery(.suspended, message: "Paused") journey.updateTransportRecovery(.recovering, message: "Restoring") journey.updateTransportRecovery(.failed, message: "Recovery failed") expectRoute(journey, states: [.failed, .failed, .failed, .failed]) #expect(ConnectionJourneyRouteStyle(journey: journey).activityEndpointIndex == nil) } @Test func localFailureDoesNotColorAnyRemoteEndpointOrLine() { let (journey, hosts) = fixture(.sshSession) journey.waitForLocalIdentity(at: hosts[0].id) journey.fail("No local identity") expectRoute(journey, states: [.failed, .pending, .pending, .pending]) } @Test func proxyFailureIncludesItsSourceHopAndPreviouslyCompletedRoute() { let (journey, hosts) = fixture(.eternalTerminalSession) journey.receive(.connected(hosts[0].id)) journey.waitForProxyAuthentication(at: hosts[1].id) journey.fail("Proxy rejected authentication") expectRoute(journey, states: [.failed, .failed, .pending, .pending]) } @Test func directSerialAndTelnetFailuresUseTheSameInclusiveRule() { let journey = ConnectionJourney() journey.beginSerial(name: "Serial", address: "/dev/cu.test") journey.fail("Port unavailable") expectRoute(journey, states: [.failed, .failed]) let host = Host(name: "Telnet", host: "telnet.example.invalid") journey.beginTelnet(host: host, username: nil) journey.receive(.openingTransport) journey.fail("Transport unavailable") expectRoute(journey, states: [.failed, .failed]) } @Test(arguments: [ConnectionPurpose.moshSession, .eternalTerminalSession]) func retryAndSuccessRemoveFailureColorsWithoutChangingRecoveryActivity(_ purpose: ConnectionPurpose) { let (journey, hosts) = fixture(purpose) completeBootstrap(journey, hosts: hosts) journey.updateTransportRecovery(.failed, message: "Recovery failed") expectRoute(journey, states: [.failed, .failed, .failed, .failed]) journey.updateTransportRecovery(.recovering, message: "Restoring") let style = ConnectionJourneyRouteStyle(journey: journey) #expect(style.state(for: .endpoint, at: 0) == .complete) #expect(style.state(for: .endpoint, at: 1) == .pending) #expect(style.state(for: .endpoint, at: 2) == .pending) #expect(style.state(for: .endpoint, at: 3) == .active) #expect(style.state(for: .segment, at: 1) == .pending) #expect(style.state(for: .segment, at: 2) == .pending) #expect(style.state(for: .segment, at: 3) == .active) #expect(style.activityEndpointIndex == 3) journey.updateTransportRecovery(.suspended, message: "Paused") #expect(style.state(for: .segment, at: 3) == .pending) #expect(style.activityEndpointIndex == nil) journey.succeed() expectRoute(journey, states: [.complete, .complete, .complete, .complete]) #expect(style.activityEndpointIndex == nil) journey.hide() } @Test func newConnectionAttemptClearsFailedRouteAndCheckpoints() { let (journey, hosts) = fixture(.eternalTerminalSession) completeBootstrap(journey, hosts: hosts) journey.fail("Data connection failed") journey.begin(host: hosts[2], route: hosts, purpose: .eternalTerminalSession, username: "user") expectRoute(journey, states: [.complete, .pending, .pending, .pending]) #expect(journey.completedCheckpoints.isEmpty) #expect(ConnectionJourneyRouteStyle(journey: journey).activityEndpointIndex == 0) } @Test(arguments: [2, 3]) func etRebuildHighlightsOnlyTheEstablishedPrefixAndMovesActivityWithIt(jumpCount: Int) { let hosts = (0...jumpCount).map { Host(name: "Host \($0)", host: "host-\($0).invalid") } let journey = ConnectionJourney() let destination = hosts[jumpCount] journey.begin(host: destination, route: hosts, purpose: .eternalTerminalSession, username: "user") completeBootstrap(journey, hosts: hosts) journey.succeed() journey.hide() let attempt = UUID() journey.receiveTransportRoute(.init(attemptID: attempt, revision: 1, stage: .opening)) journey.updateTransportRecovery(.recovering, message: "Recovering") let style = ConnectionJourneyRouteStyle(journey: journey) #expect(style.activityEndpointIndex == 0) #expect(style.state(for: .endpoint, at: 1) == .pending) for establishedCount in 1...jumpCount { journey.receiveTransportRoute( .init( attemptID: attempt, revision: UInt64(establishedCount + 1), stage: .opening, establishedHostIDs: hosts.prefix(establishedCount).map(\.id), activeHostID: hosts[establishedCount].id, event: .authenticated(hosts[establishedCount - 1].id))) for index in 0...establishedCount { #expect(style.state(for: .endpoint, at: index) == .complete) if index > 0 { #expect(style.state(for: .segment, at: index) == .complete) } } #expect(style.state(for: .endpoint, at: establishedCount + 1) == .pending) #expect(style.state(for: .segment, at: establishedCount + 1) == .active) #expect(style.activityEndpointIndex == establishedCount) #expect(style.scrollTarget == "endpoint-\(establishedCount)") } journey.receiveTransportRoute( .init(attemptID: attempt, revision: 10, stage: .ready, establishedHostIDs: hosts.dropLast().map(\.id))) #expect(journey.phase == .connecting) expectRoute(journey, states: Array(repeating: .complete, count: hosts.count + 1)) #expect(style.activityEndpointIndex == hosts.count) journey.succeed() #expect(style.activityEndpointIndex == nil) } @Test func etRetainedRouteSurvivesSuspensionButRetiredRouteCannotReturnFromLateProgress() { let (journey, hosts) = fixture(.eternalTerminalSession) completeBootstrap(journey, hosts: hosts) let attempt = UUID() let ready = TerminalRouteSnapshot( attemptID: attempt, revision: 4, stage: .ready, establishedHostIDs: hosts.dropLast().map(\.id)) journey.receiveTransportRoute(ready) journey.succeed() journey.hide() journey.updateTransportRecovery(.suspended, message: "Paused") let style = ConnectionJourneyRouteStyle(journey: journey) expectRoute(journey, states: [.complete, .complete, .complete, .complete]) #expect(style.activityEndpointIndex == nil) journey.updateTransportRecovery(.recovering, message: "Recovering") #expect(style.activityEndpointIndex == 3) journey.receiveTransportRoute(.init(attemptID: attempt, revision: 5, stage: .unavailable)) journey.receiveTransportRoute(ready) expectRoute(journey, states: [.complete, .pending, .pending, .pending]) #expect(style.activityEndpointIndex == nil) journey.receiveTransportRoute(.init(attemptID: UUID(), revision: 6, stage: .opening)) #expect(style.activityEndpointIndex == 0) #expect(style.state(for: .segment, at: 1) == .active) #expect(style.state(for: .endpoint, at: 1) == .pending) } @Test func etWaitingCheckpointAndSuspensionStopActivityWithoutGrayingTheEstablishedPrefix() { let (journey, hosts) = fixture(.eternalTerminalSession) journey.receiveTransportRoute( .init( attemptID: UUID(), revision: 1, stage: .opening, establishedHostIDs: [hosts[0].id], activeHostID: hosts[1].id)) journey.waitForHostKey(at: hosts[1].id, fingerprint: "SHA256:test") journey.updateTransportRecovery(.recovering, message: "Recovering") let style = ConnectionJourneyRouteStyle(journey: journey) #expect(style.activityEndpointIndex == nil) #expect(style.scrollTarget == "segment-2") #expect(style.state(for: .endpoint, at: 1) == .complete) #expect(style.state(for: .segment, at: 1) == .complete) journey.updateTransportRecovery(.suspended, message: "Paused") #expect(style.state(for: .segment, at: 2) == .pending) #expect(journey.checkpoint?.endpointIndex == 2) journey.updateTransportRecovery(.recovering, message: "Recovering") journey.resume() #expect(style.activityEndpointIndex == 1) #expect(style.state(for: .segment, at: 2) == .active) } @Test(arguments: [false, true]) func moshRecoveryDisplaysOnlyTheDirectPathIncludingAfterSuccess(coldRestore: Bool) { let (journey, hosts) = fixture(.moshSession) if !coldRestore { completeBootstrap(journey, hosts: hosts) journey.succeed() journey.hide() } let endpoints = journey.endpoints let bootstrapLogs = journey.logs let style = ConnectionJourneyRouteStyle(journey: journey) #expect(style.endpointIndices == [0, 1, 2, 3]) journey.updateTransportRecovery(.recovering, message: "Recovering UDP") #expect(style.endpointIndices == [0, 3]) #expect(style.endpointIndices.dropFirst().count == 1) #expect(style.activityEndpointIndex == 3) #expect(style.scrollTarget == "endpoint-3") #expect(style.state(for: .segment, at: 3) == .active) #expect(journey.endpoints == endpoints) #expect(Array(journey.logs.prefix(bootstrapLogs.count)) == bootstrapLogs) journey.updateTransportRecovery(.suspended, message: "Paused") #expect(style.endpointIndices == [0, 3]) #expect(style.activityEndpointIndex == nil) #expect(style.state(for: .segment, at: 3) == .pending) journey.updateTransportRecovery(.failed, message: "Unavailable") #expect(style.endpointIndices == [0, 3]) #expect(style.state(for: .segment, at: 3) == .failed) journey.updateTransportRecovery(.recovering, message: "Retry") journey.succeed() #expect(style.endpointIndices == [0, 3]) #expect(style.state(for: .segment, at: 3) == .complete) #expect(style.activityEndpointIndex == nil) journey.hide() journey.begin(host: hosts[2], route: hosts, purpose: .moshSession, username: "user") #expect(style.endpointIndices == [0, 1, 2, 3]) #expect(style.activityEndpointIndex == 0) } @Test(arguments: [ConnectionJourney.TransportRecovery.recovering, .suspended, .failed]) func moshFullReconnectRebuildsTheSSHRouteRatherThanReusingUDPRecovery(recovery: ConnectionJourney.TransportRecovery) { let (journey, hosts) = fixture(.moshSession) completeBootstrap(journey, hosts: hosts) journey.updateTransportRecovery(recovery, message: "Old Mosh session") let style = ConnectionJourneyRouteStyle(journey: journey) #expect(style.endpointIndices == [0, 3]) journey.begin( host: hosts[2], route: hosts, purpose: .moshSession, username: "user", mode: .reconnection) #expect(journey.mode == .reconnection) #expect(journey.transportRecovery == nil) #expect(!journey.usesDirectRecoveryRoute) #expect(style.endpointIndices == [0, 1, 2, 3]) expectRoute(journey, states: [.complete, .pending, .pending, .pending]) #expect(style.activityEndpointIndex == 0) journey.waitForAuthentication(at: hosts[0].id) #expect(style.scrollTarget == "segment-1") #expect(style.activityEndpointIndex == nil) journey.resume() journey.receive(.connected(hosts[0].id)) journey.receive(.connecting(hosts[1].id)) #expect(style.state(for: .endpoint, at: 1) == .complete) #expect(style.state(for: .segment, at: 1) == .complete) #expect(style.state(for: .segment, at: 2) == .active) #expect(style.activityEndpointIndex == 1) #expect(style.endpointIndices == [0, 1, 2, 3]) } private func fixture(_ purpose: ConnectionPurpose) -> (ConnectionJourney, [Host]) { let hosts = [ Host(name: "Jump One", host: "jump-one.example.invalid"), Host(name: "Jump Two", host: "jump-two.example.invalid"), Host(name: "Destination", host: "destination.example.invalid"), ] let journey = ConnectionJourney() journey.begin(host: hosts[2], route: hosts, purpose: purpose, username: "user") return (journey, hosts) } private func completeBootstrap(_ journey: ConnectionJourney, hosts: [Host]) { for (index, host) in hosts.enumerated() { if index == 1 { journey.waitForHostKey(at: host.id, fingerprint: "SHA256:test") } else { journey.waitForAuthentication(at: host.id) } journey.resume() journey.receive(.connected(host.id)) } } private func expectRoute(_ journey: ConnectionJourney, states: [ConnectionJourneyRouteStyle.State]) { let style = ConnectionJourneyRouteStyle(journey: journey) #expect(journey.endpoints.count == states.count) for (index, expected) in states.enumerated() { #expect(style.state(for: .endpoint, at: index) == expected) if index > 0 { // Lines and retained checkpoint badges consume this same segment state and color. #expect(style.state(for: .segment, at: index) == expected) } } } }