169 lines
6.5 KiB
Swift
169 lines
6.5 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import ETSession
|
|
import Foundation
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import Testing
|
|
|
|
@testable import TermindRemoteInfrastructure
|
|
|
|
@Suite
|
|
struct EternalTerminalResumeTests {
|
|
@Test func initialDataRouteFailureReportsRetirementAndPreservesTheOriginalError() async throws {
|
|
let fixture = Fixture()
|
|
let hostID = UUID()
|
|
let request = SSHConnectionRequest(
|
|
configuration: .init(
|
|
hostID: hostID, host: "unused.invalid", port: 22, username: "unused", credentials: [],
|
|
expectedHostKeys: []))
|
|
do {
|
|
_ = try await fixture.engine.prepare(
|
|
.init(
|
|
request: request, options: .init(), environment: [], shellIntegrationEnabled: false,
|
|
checkpoint: nil, startup: .init(plan: nil), bootstrap: SSHConnection(),
|
|
route: { progress in
|
|
progress?(.connecting(hostID))
|
|
throw TerminalTransportError.unreachable
|
|
}))
|
|
Issue.record("The original route failure must escape prepare")
|
|
} catch TerminalTransportError.unreachable {
|
|
}
|
|
#expect(await eventually { fixture.events.routes.count == 3 })
|
|
let snapshots = fixture.events.routes.sorted { $0.revision < $1.revision }
|
|
#expect(snapshots.map(\.stage) == [.opening, .opening, .unavailable])
|
|
#expect(snapshots.first?.activeHostID == hostID)
|
|
#expect(snapshots[1].event == .connecting(hostID))
|
|
#expect(snapshots.last?.establishedHostIDs == [])
|
|
#expect(Set(snapshots.map(\.attemptID)).count == 1)
|
|
#expect(fixture.events.connectionConfirmations == 0)
|
|
await fixture.close()
|
|
}
|
|
|
|
@Test func foregroundResumeConfirmsAConnectionThatStayedAliveWithoutANewVendorEvent() async throws {
|
|
let fixture = Fixture()
|
|
await fixture.start()
|
|
fixture.states.continuation.yield(.connected)
|
|
#expect(await eventually { fixture.events.values == [.connected] })
|
|
|
|
// The coordinator enters recovery even when ET kept the same connection during suspension.
|
|
await fixture.client.receiveEngineEvent(.recovering)
|
|
try await fixture.engine.resume()
|
|
#expect(fixture.events.values == [.connected, .recovering, .connected])
|
|
|
|
await fixture.close()
|
|
}
|
|
|
|
@Test(arguments: [ETConnectionState.disconnected, .reconnecting, .failed(.transportFailure("offline"))])
|
|
func foregroundResumeDoesNotConfirmALostConnection(_ state: ETConnectionState) async throws {
|
|
let fixture = Fixture()
|
|
await fixture.start()
|
|
fixture.states.continuation.yield(.connected)
|
|
#expect(await eventually { fixture.events.values == [.connected] })
|
|
fixture.states.continuation.yield(state)
|
|
#expect(await eventually { fixture.events.values.count == 2 })
|
|
let beforeResume = fixture.events.values
|
|
|
|
try await fixture.engine.resume()
|
|
#expect(fixture.events.values == beforeResume)
|
|
|
|
fixture.states.continuation.yield(.connected)
|
|
#expect(await eventually { fixture.events.values == beforeResume + [.connected] })
|
|
await fixture.close()
|
|
}
|
|
|
|
@Test func foregroundResumeCannotReviveAnEndedOrLocallyClosedSession() async throws {
|
|
let fixture = Fixture()
|
|
await fixture.start()
|
|
fixture.states.continuation.yield(.connected)
|
|
#expect(await eventually { fixture.events.values == [.connected] })
|
|
fixture.states.continuation.yield(.sessionEnded)
|
|
fixture.output.continuation.finish()
|
|
#expect(await eventually { fixture.events.values.last == .ended })
|
|
let endedEvents = fixture.events.values
|
|
|
|
try await fixture.engine.resume()
|
|
#expect(fixture.events.values == endedEvents)
|
|
#expect(fixture.events.connectionConfirmations == 1)
|
|
await fixture.close()
|
|
await #expect(throws: CancellationError.self) { try await fixture.engine.resume() }
|
|
#expect(fixture.events.values == endedEvents)
|
|
#expect(fixture.events.connectionConfirmations == 1)
|
|
}
|
|
|
|
@Test func foregroundResumeBeforeConnectionDoesNotReportSuccess() async throws {
|
|
let fixture = Fixture()
|
|
await fixture.start()
|
|
try await fixture.engine.resume()
|
|
#expect(fixture.events.values.isEmpty)
|
|
#expect(fixture.events.connectionConfirmations == 0)
|
|
await fixture.close()
|
|
}
|
|
|
|
private func eventually(_ condition: () -> Bool) async -> Bool {
|
|
for _ in 0..<200 {
|
|
if condition() { return true }
|
|
try? await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
return condition()
|
|
}
|
|
}
|
|
|
|
private struct Fixture {
|
|
let events: Events
|
|
let client: ResumableTerminalClient
|
|
let engine: EternalTerminalEngine
|
|
let output = AsyncStream<Data>.makeStream()
|
|
let states = AsyncStream<ETConnectionState>.makeStream()
|
|
|
|
init() {
|
|
let events = Events()
|
|
let client = ResumableTerminalClient(
|
|
transport: .eternalTerminal,
|
|
output: { _ in },
|
|
stateChanged: { events.record($0) },
|
|
shellIntegration: { _ in })
|
|
self.events = events
|
|
self.client = client
|
|
engine = EternalTerminalEngine {
|
|
if case .connected = $0 { events.recordConnectionConfirmation() }
|
|
if case .routeChanged(let snapshot) = $0 { events.recordRoute(snapshot) }
|
|
await client.receiveEngineEvent($0)
|
|
}
|
|
}
|
|
|
|
func start() async {
|
|
await engine.startReaders(output: output.stream, states: states.stream)
|
|
}
|
|
|
|
func close() async {
|
|
await client.close()
|
|
await engine.close()
|
|
output.continuation.finish()
|
|
states.continuation.finish()
|
|
}
|
|
}
|
|
|
|
private nonisolated final class Events: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var recorded: [ResumableTerminalState] = []
|
|
private var confirmations = 0
|
|
private var routeSnapshots: [TerminalRouteSnapshot] = []
|
|
var routes: [TerminalRouteSnapshot] { lock.withLock { routeSnapshots } }
|
|
var values: [ResumableTerminalState] { lock.withLock { recorded } }
|
|
var connectionConfirmations: Int { lock.withLock { confirmations } }
|
|
|
|
func recordRoute(_ snapshot: TerminalRouteSnapshot) {
|
|
lock.withLock { routeSnapshots.append(snapshot) }
|
|
}
|
|
|
|
func record(_ state: ResumableTerminalState) {
|
|
lock.withLock { recorded.append(state) }
|
|
}
|
|
|
|
func recordConnectionConfirmation() {
|
|
lock.withLock { confirmations += 1 }
|
|
}
|
|
}
|