Files
Termind/FrameworkTests/ConnectionJourneyRouteStyleTests.swift

366 lines
18 KiB
Swift

// 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)
}
}
}
}