220 lines
11 KiB
Swift
220 lines
11 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Foundation
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import Testing
|
|
|
|
@testable import TermindRemoteInfrastructure
|
|
|
|
@Suite
|
|
struct TerminalContextWireTests {
|
|
private let token = "4EA6E90A-81DA-4879-ACAF-D91CA84BD817"
|
|
|
|
@Test func managedNamesAreCompactAndNeverGrantOwnershipByName() throws {
|
|
let owner = try #require(UUID(uuidString: token))
|
|
let name = RemoteTmuxCommands.managedSessionName(token: owner)
|
|
#expect(name.hasPrefix("termind-"))
|
|
#expect(name.utf8.count == 24)
|
|
#expect(!name.contains(owner.uuidString))
|
|
#expect(RemoteTmuxCommands.managedSessionName(token: owner) == name)
|
|
#expect(RemoteTmuxCommands.managedSessionName(token: UUID()) != name)
|
|
let command = try RemoteTmuxCommands.create(token: owner, startupCommand: nil, shellIntegration: false)
|
|
#expect(command.contains("-s '\(name)'"))
|
|
#expect(command.contains("TERMIND_TMUX_OWNER=\(owner.uuidString)' ] || exit 79"))
|
|
}
|
|
|
|
private func record(pane: String = "%1", sequence: Int = 2) -> String {
|
|
"""
|
|
M1|\(token)|\(pane)|ctx.ABCDEF|bash|%2Fhome%2Ftest|123
|
|
R1|\(sequence)|1|%2Ftmp%2Fa%7Cb
|
|
E1|1|directory|%2Ftmp%2Fa%7Cb|
|
|
E1|2|prompt||
|
|
|
|
"""
|
|
}
|
|
|
|
@Test func recordDecodesNamesAsDataAndRejectsBrokenSequences() throws {
|
|
let parsed = try RemoteShellContextRecord(record())
|
|
#expect(parsed.directory == "/tmp/a|b")
|
|
#expect(try RemoteShellContextRecord(record().replacingOccurrences(of: "\n", with: "\r\n")).ready)
|
|
#expect(throws: (any Error).self) { try RemoteShellContextRecord(record(sequence: 1)) }
|
|
#expect(throws: (any Error).self) { try RemoteShellContextRecord(record(pane: "%1;touch /tmp/x")) }
|
|
}
|
|
|
|
@Test func activePaneIsBoundToTheClientAndUninstrumentedPanesHaveNoDraftCapability() throws {
|
|
let prefix = """
|
|
tmux 3.3a
|
|
C|/dev/pts/1|555|100|777|$0|99|%2||root
|
|
P|%1|123|0|0|bash
|
|
P|%2|456|0|0|unknown
|
|
BEGIN
|
|
"""
|
|
let text = prefix + "\n" + record() + "END\nDONE\n"
|
|
let update = try RemoteTmuxRuntimeDialect.parse(text, terminal: "/dev/pts/1")
|
|
#expect(update.active?.pane == "%2")
|
|
#expect(update.contexts.first { $0.identity == update.active }?.capabilities.isEmpty == true)
|
|
#expect(throws: (any Error).self) {
|
|
try RemoteTmuxRuntimeDialect.parse(text, terminal: "/dev/pts/other")
|
|
}
|
|
let independent = text.replacingOccurrences(of: "|%2||root", with: "|%2|active-pane|root")
|
|
#expect(try RemoteTmuxRuntimeDialect.parse(independent, terminal: "/dev/pts/1").status == .unavailable)
|
|
}
|
|
|
|
@Test func connectionScopedFramesSurviveEverySplitWithoutLeakingControlText() throws {
|
|
let frame = Data("\u{1B}]777;TermindContext=\(token);\(record(pane: "shell"))\u{7}".utf8)
|
|
for split in 0...frame.count {
|
|
var filter = RemoteTerminalContextFilter(token: token)
|
|
let first = filter.receive(Data(frame.prefix(split)))
|
|
let second = filter.receive(Data(frame.dropFirst(split)))
|
|
#expect(first.output.isEmpty && second.output.isEmpty)
|
|
let updates = first.contexts + second.contexts
|
|
#expect(updates.count == 1)
|
|
#expect(updates.first?.active?.pane == "shell")
|
|
}
|
|
}
|
|
|
|
@Test @MainActor
|
|
func ordinaryCommandsRetainCwdWhileProbingForManualAttach() throws {
|
|
var filter = RemoteTerminalContextFilter(token: token)
|
|
let coordinator = TerminalContextCoordinator()
|
|
func receive(_ record: String) throws -> TerminalRuntimeUpdate {
|
|
let frame = Data("\u{1B}]777;TermindContext=\(token);\(record)\u{7}".utf8)
|
|
let update = try #require(filter.receive(frame).contexts.first)
|
|
coordinator.receive(update, generation: coordinator.generation)
|
|
return update
|
|
}
|
|
let prompt = try receive(record(pane: "shell"))
|
|
#expect(!RemoteTerminalContextFilter.needsObservation(prompt))
|
|
let draft = try #require(coordinator.ticket(requiring: .replaceDraft))
|
|
let running = try receive(
|
|
record(pane: "shell").replacingOccurrences(of: "R1|2|1|", with: "R1|3|0|")
|
|
+ "E1|3|start|sleep%2010|\n")
|
|
#expect(RemoteTerminalContextFilter.needsObservation(running))
|
|
#expect(running.status == .ordinary)
|
|
#expect(coordinator.directory == "/tmp/a|b")
|
|
#expect(!coordinator.isCurrent(draft, requiring: .replaceDraft))
|
|
#expect(coordinator.ticket(requiring: .directory) == nil)
|
|
for status in [TerminalRuntimeUpdate.Status.unknown, .unavailable] {
|
|
let observed = RemoteTerminalContextFilter.reconcileObservation(.init(status: status), current: running)
|
|
#expect(observed.events.isEmpty)
|
|
coordinator.receive(observed, generation: coordinator.generation)
|
|
#expect(coordinator.directory == "/tmp/a|b")
|
|
#expect(coordinator.active?.promptReady == false)
|
|
#expect(coordinator.ticket(requiring: .replaceDraft) == nil)
|
|
}
|
|
|
|
let attached = TerminalRuntimeUpdate(status: .multiplexer)
|
|
let switched = RemoteTerminalContextFilter.reconcileObservation(attached, current: running)
|
|
coordinator.receive(switched, generation: coordinator.generation)
|
|
#expect(coordinator.directory == nil)
|
|
let unavailable = RemoteTerminalContextFilter.reconcileObservation(
|
|
.init(status: .unavailable), current: attached)
|
|
#expect(unavailable.status == .unavailable)
|
|
#expect(unavailable.contexts.isEmpty)
|
|
let detached = try receive(
|
|
record(pane: "shell", sequence: 5) + "E1|3|start|sleep%2010|\nE1|4|finish|0|10000\nE1|5|prompt||\n")
|
|
#expect(!RemoteTerminalContextFilter.needsObservation(detached))
|
|
#expect(coordinator.directory == "/tmp/a|b")
|
|
#expect(coordinator.ticket(requiring: .replaceDraft) != nil)
|
|
}
|
|
|
|
@Test func runtimeBudgetNeverQueuesBehindATerminal() async throws {
|
|
let budget = SSHChannelBudget(maximum: 1)
|
|
let terminal = try await budget.acquire()
|
|
#expect(budget.tryAcquire() == nil)
|
|
terminal.release()
|
|
let observation = try #require(budget.tryAcquire())
|
|
observation.release()
|
|
budget.close()
|
|
#expect(budget.tryAcquire() == nil)
|
|
}
|
|
|
|
@Test func malformedAndOversizedFramesCannotEnableCapabilities() throws {
|
|
var filter = RemoteTerminalContextFilter(token: token)
|
|
let bad = record(pane: "shell").replacingOccurrences(of: "%2Ftmp", with: "%GGtmp")
|
|
let update = filter.receive(Data("\u{1B}]777;TermindContext=\(token);\(bad)\u{7}".utf8))
|
|
#expect(update.contexts.map(\.status) == [.unavailable])
|
|
#expect(update.output.isEmpty)
|
|
let oversized = Data(
|
|
("\u{1B}]777;TermindContext=\(token);" + String(repeating: "x", count: 70_000) + "\u{7}visible").utf8)
|
|
let large = filter.receive(oversized)
|
|
#expect(large.contexts.map(\.status) == [.unavailable])
|
|
#expect(String(decoding: large.output, as: UTF8.self) == "visible")
|
|
}
|
|
|
|
@Test func shellRestartAndUnsafeModesCannotReuseTheOldPrompt() throws {
|
|
let base = """
|
|
tmux 3.3a
|
|
C|/dev/pts/1|555|100|777|$0|99|%1||root
|
|
P|%1|123|0|0|bash
|
|
BEGIN
|
|
"""
|
|
let text = base + "\n" + record() + "END\nDONE\n"
|
|
for unsafe in [
|
|
text.replacingOccurrences(of: "P|%1|123|0|0|bash", with: "P|%1|123|1|0|bash"),
|
|
text.replacingOccurrences(of: "P|%1|123|0|0|bash", with: "P|%1|123|0|1|bash"),
|
|
text.replacingOccurrences(of: "P|%1|123|0|0|bash", with: "P|%1|456|0|0|bash"),
|
|
text.replacingOccurrences(of: "P|%1|123|0|0|bash", with: "P|%1|123|0|0|unknown"),
|
|
] {
|
|
let update = try RemoteTmuxRuntimeDialect.parse(unsafe, terminal: "/dev/pts/1")
|
|
let active = try #require(update.contexts.first { $0.identity == update.active })
|
|
#expect(!active.capabilities.contains(.directory))
|
|
#expect(!active.capabilities.contains(.replaceDraft))
|
|
#expect(active.directory == (active.identity.shellInstance.hasPrefix("unintegrated:") ? nil : "/tmp/a|b"))
|
|
}
|
|
}
|
|
|
|
@Test @MainActor
|
|
func tmuxForegroundCommandsKeepTheShellDirectoryButNotDraftCapabilities() throws {
|
|
let header = """
|
|
tmux 3.3a
|
|
C|/dev/pts/1|555|100|777|$0|99|%1||root
|
|
P|%1|123|0|0|unknown
|
|
BEGIN
|
|
"""
|
|
let journal = record().replacingOccurrences(of: "R1|2|1|", with: "R1|3|0|") + "E1|3|start|sleep%2010|\n"
|
|
let update = try RemoteTmuxRuntimeDialect.parse(header + "\n" + journal + "END\nDONE\n", terminal: "/dev/pts/1")
|
|
let coordinator = TerminalContextCoordinator()
|
|
coordinator.receive(update, generation: coordinator.generation)
|
|
#expect(coordinator.directory == "/tmp/a|b")
|
|
#expect(coordinator.active?.promptReady == false)
|
|
#expect(coordinator.ticket(requiring: .readDraft) == nil)
|
|
#expect(coordinator.ticket(requiring: .replaceDraft) == nil)
|
|
#expect(coordinator.ticket(requiring: .directory) == nil)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func prefixModePreservesDirectoryButInvalidatesDraftOperations() throws {
|
|
let header = """
|
|
tmux 3.3a
|
|
C|/dev/pts/1|555|100|777|$0|99|%1||root
|
|
P|%1|123|0|0|bash
|
|
BEGIN
|
|
"""
|
|
let text = header + "\n" + record() + "END\nDONE\n"
|
|
let coordinator = TerminalContextCoordinator()
|
|
let ordinary = try RemoteTmuxRuntimeDialect.parse(text, terminal: "/dev/pts/1")
|
|
coordinator.receive(ordinary, generation: coordinator.generation)
|
|
let draft = try #require(coordinator.ticket(requiring: .replaceDraft))
|
|
let identity = coordinator.active?.identity
|
|
let prefix = try RemoteTmuxRuntimeDialect.parse(
|
|
text.replacingOccurrences(of: "|%1||root", with: "|%1||prefix"), terminal: "/dev/pts/1")
|
|
|
|
coordinator.receive(prefix, generation: coordinator.generation)
|
|
|
|
#expect(coordinator.active?.identity == identity)
|
|
#expect(coordinator.directory == "/tmp/a|b")
|
|
#expect(coordinator.ticket(requiring: .directory) != nil)
|
|
#expect(coordinator.ticket(requiring: .readDraft) == nil)
|
|
#expect(coordinator.ticket(requiring: .replaceDraft) == nil)
|
|
#expect(!coordinator.isCurrent(draft, requiring: .replaceDraft))
|
|
|
|
coordinator.receive(ordinary, generation: coordinator.generation)
|
|
#expect(coordinator.directory == "/tmp/a|b")
|
|
#expect(coordinator.ticket(requiring: [.readDraft, .replaceDraft]) != nil)
|
|
#expect(!coordinator.isCurrent(draft, requiring: .replaceDraft))
|
|
}
|
|
}
|