119 lines
5.0 KiB
Swift
119 lines
5.0 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import ETSession
|
|
import Foundation
|
|
import MoshCore
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import Testing
|
|
|
|
@testable import TermindRemoteInfrastructure
|
|
|
|
@Suite
|
|
struct ResumableTerminalExitTests {
|
|
@Test func moshStoppedDrainsFinalBytesAndEndsOnlyOnce() async throws {
|
|
let events = Events()
|
|
let client = events.client(.mosh)
|
|
let engine = MoshTerminalEngine { await client.receiveEngineEvent($0) }
|
|
let tail = Data("logout\r\n".utf8)
|
|
|
|
#expect(await !engine.receivePoll(state: .stopped, operations: [.hostBytes(tail)]))
|
|
#expect(events.values == [.state(.connected), .output(tail), .state(.ended)])
|
|
#expect(await !engine.receivePoll(state: .stopped, operations: [.hostBytes(tail)]))
|
|
await client.close()
|
|
await engine.close()
|
|
#expect(events.values == [.state(.connected), .output(tail), .state(.ended)])
|
|
await #expect(throws: CancellationError.self) { try await client.write(Data("pwd\n".utf8)) }
|
|
await #expect(throws: CancellationError.self) { try await client.suspend() }
|
|
}
|
|
|
|
@Test(arguments: [MoshSessionState.running, .suspended, .failed(.timeout(timeoutMs: 1000))])
|
|
func moshSilenceSuspensionAndFailureAreNotRemoteExit(_ state: MoshSessionState) async {
|
|
let events = Events()
|
|
let client = events.client(.mosh)
|
|
let engine = MoshTerminalEngine { await client.receiveEngineEvent($0) }
|
|
_ = await engine.receivePoll(state: state, operations: [])
|
|
#expect(!events.values.contains(.state(.ended)))
|
|
await client.close()
|
|
await engine.close()
|
|
_ = await engine.receivePoll(state: .stopped, operations: [])
|
|
#expect(!events.values.contains(.state(.ended)))
|
|
}
|
|
|
|
@Test func eternalEndWaitsForTheOutputConsumerBeforeClosing() async {
|
|
let events = Events()
|
|
let client = events.client(.eternalTerminal)
|
|
let engine = EternalTerminalEngine { await client.receiveEngineEvent($0) }
|
|
let output = AsyncStream<Data>.makeStream()
|
|
let states = AsyncStream<ETConnectionState>.makeStream()
|
|
await engine.startReaders(output: output.stream, states: states.stream)
|
|
states.continuation.yield(.connected)
|
|
#expect(await eventually { events.values.contains(.state(.connected)) })
|
|
|
|
states.continuation.yield(.sessionEnded)
|
|
let tail = Data("last terminal output\r\n".utf8)
|
|
output.continuation.yield(tail)
|
|
#expect(await eventually { events.values.contains(.output(tail)) })
|
|
#expect(!events.values.contains(.state(.ended)))
|
|
output.continuation.finish()
|
|
|
|
#expect(await eventually { events.values.contains(.state(.ended)) })
|
|
#expect(events.values == [.state(.connected), .output(tail), .state(.ended)])
|
|
states.continuation.yield(.connected)
|
|
states.continuation.yield(.sessionEnded)
|
|
states.continuation.finish()
|
|
await client.close()
|
|
await engine.close()
|
|
#expect(events.values == [.state(.connected), .output(tail), .state(.ended)])
|
|
}
|
|
|
|
@Test func eternalDisconnectAndOutputEOFAloneDoNotCloseTheSession() async {
|
|
let events = Events()
|
|
let client = events.client(.eternalTerminal)
|
|
let engine = EternalTerminalEngine { await client.receiveEngineEvent($0) }
|
|
let output = AsyncStream<Data>.makeStream()
|
|
let states = AsyncStream<ETConnectionState>.makeStream()
|
|
await engine.startReaders(output: output.stream, states: states.stream)
|
|
states.continuation.yield(.disconnected)
|
|
states.continuation.yield(.reconnecting)
|
|
output.continuation.finish()
|
|
#expect(await eventually { events.values.contains(.state(.recovering)) })
|
|
states.continuation.yield(.connected)
|
|
#expect(await eventually { events.values.contains(.state(.connected)) })
|
|
#expect(!events.values.contains(.state(.ended)))
|
|
await client.close()
|
|
await engine.close()
|
|
states.continuation.yield(.sessionEnded)
|
|
states.continuation.finish()
|
|
#expect(!events.values.contains(.state(.ended)))
|
|
}
|
|
|
|
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 nonisolated final class Events: @unchecked Sendable {
|
|
enum Event: Equatable {
|
|
case output(Data)
|
|
case state(ResumableTerminalState)
|
|
}
|
|
|
|
private let lock = NSLock()
|
|
private var recorded: [Event] = []
|
|
var values: [Event] { lock.withLock { recorded } }
|
|
|
|
func client(_ transport: HostTerminalProtocol) -> ResumableTerminalClient {
|
|
ResumableTerminalClient(
|
|
transport: transport,
|
|
output: { [self] data in lock.withLock { recorded.append(.output(data)) } },
|
|
stateChanged: { [self] state in lock.withLock { recorded.append(.state(state)) } },
|
|
shellIntegration: { _ in })
|
|
}
|
|
}
|