257 lines
12 KiB
Swift
257 lines
12 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
|
|
nonisolated struct NativeTerminalContextTests {
|
|
private static func hex(_ value: String) -> String {
|
|
value.utf8.map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
|
|
private static func client(_ backend: SessionPersistenceBackendID) -> RemoteTerminalProcessProbe.Client {
|
|
.init(
|
|
backend: backend, process: "42", started: "Fri Oct 16 12:00:00 2026",
|
|
endpoint: backend == .herdr ? "/tmp/herdr/herdr-client.sock" : "/tmp/\(backend.rawValue)/work")
|
|
}
|
|
|
|
private static func frame(
|
|
_ backend: SessionPersistenceBackendID, native: String, focus: String = "", directory: String = ""
|
|
) -> String {
|
|
let version = backend == .zellij ? "0.45.1" : backend == .zmx ? "0.8.1" : "0.9.5"
|
|
return """
|
|
V|\(hex("\(backend.rawValue) \(version)"))
|
|
I|1:2:3
|
|
N|\(hex(native))
|
|
F|\(hex(focus))
|
|
D|\(hex(directory))
|
|
DONE
|
|
|
|
"""
|
|
}
|
|
|
|
@Test func linuxSocketPeersBindTheActualSessionRatherThanOtherClientsOrArgv() {
|
|
let sockets = """
|
|
u_str ESTAB 0 0 * 100 * 200 users:(("zmx",pid=42,fd=3))
|
|
u_str ESTAB 0 0 /tmp/zmx/work 200 * 100 users:(("zmx",pid=50,fd=4))
|
|
u_str ESTAB 0 0 * 300 * 400 users:(("zmx",pid=142,fd=3))
|
|
u_str ESTAB 0 0 /tmp/zmx/other 400 * 300 users:(("zmx",pid=60,fd=4))
|
|
"""
|
|
#expect(RemoteTerminalProcessProbe.linuxEndpoint(sockets, process: "42") == "/tmp/zmx/work")
|
|
#expect(RemoteTerminalProcessProbe.linuxEndpoint(sockets, process: "2") == nil)
|
|
#expect(
|
|
RemoteTerminalProcessProbe.linuxEndpoint(
|
|
sockets.replacingOccurrences(of: "200 * 100", with: "200 * 999"), process: "42") == nil)
|
|
#expect(
|
|
RemoteTerminalProcessProbe.linuxEndpoint(
|
|
sockets.replacingOccurrences(of: "pid=142,", with: "pid=42,"), process: "42") == nil)
|
|
}
|
|
|
|
@Test func darwinUsesTheSocketDeviceAddressNotTheMissingBlockDeviceField() {
|
|
let sockets = """
|
|
p50
|
|
f3
|
|
d0x111
|
|
n/tmp/zmx/work
|
|
f4
|
|
d0x222
|
|
n/tmp/zmx/work
|
|
p42
|
|
f3
|
|
d0x333
|
|
n->0x222
|
|
"""
|
|
#expect(RemoteTerminalProcessProbe.darwinEndpoint(sockets, process: "42") == "/tmp/zmx/work")
|
|
#expect(
|
|
RemoteTerminalProcessProbe.darwinEndpoint(
|
|
sockets.replacingOccurrences(of: "->0x222", with: "->0x999"), process: "42") == nil)
|
|
}
|
|
|
|
@Test func socketDiagnosticEvidenceRequiresABoundedAbsoluteEndpoint() throws {
|
|
let client = Self.client(.zellij)
|
|
func frame(_ path: String) -> String {
|
|
"M|zellij|42|\(Self.hex(client.started))\nP|\(Self.hex(path))\nDONE\n"
|
|
}
|
|
#expect(try RemoteTerminalProcessProbe.parse(frame(client.endpoint!)) == client)
|
|
for path in ["", "relative/session", "/tmp/../session", "/tmp/\0session"] {
|
|
#expect(try RemoteTerminalProcessProbe.parse(frame(path))?.endpoint == nil)
|
|
}
|
|
#expect(throws: RemoteSessionFailure.invalidResponse) {
|
|
try RemoteTerminalProcessProbe.parse(frame("/" + String(repeating: "x", count: 262144)))
|
|
}
|
|
#expect(throws: TerminalRuntimeError.invalidResponse) {
|
|
try RemoteTerminalProcessProbe.parse(String(frame(client.endpoint!).dropLast()))
|
|
}
|
|
}
|
|
|
|
@Test(arguments: [SessionPersistenceBackendID.zellij, .zmx, .herdr])
|
|
func missingEndpointDoesNotBecomeAnOrdinaryShell(backend: SessionPersistenceBackendID) async throws {
|
|
let client = Self.client(backend)
|
|
let response = "M|\(backend.rawValue)|42|\(Self.hex(client.started))\nS|\nDONE\n"
|
|
let runtime = RemoteMultiplexerRuntimeAdapter { command in
|
|
.init(
|
|
stdout: command.contains("ss -xnpH") ? response : "tmux 3.3a\nNONE\n",
|
|
stderr: "", exitCode: 0)
|
|
}
|
|
let update = try await runtime.inspect(terminal: "/dev/pts/1")
|
|
#expect(update.status == .multiplexer)
|
|
#expect(update.backend == backend)
|
|
#expect(update.issue == .clientUnbound)
|
|
#expect(update.active == nil)
|
|
}
|
|
|
|
@Test func unavailableManagedHelperStillFallsBackToTheBoundNativePane() async throws {
|
|
let client = Self.client(.zellij)
|
|
let token = UUID()
|
|
let identity = RemoteMultiplexerIdentity(
|
|
kind: .zellij, root: "/tmp/zellij", socket: try #require(client.endpoint), fingerprint: "1:2:3",
|
|
process: nil, created: nil, owner: token, helper: "/tmp/termind-shell.testing", isolated: true)
|
|
let reference = RemoteSessionReference(
|
|
backend: .zellij,
|
|
session: .init(id: "managed:work", name: "work", identity: try JSONEncoder().encode(identity)),
|
|
hostID: UUID(), endpoint: "fixture", username: "user", ownershipToken: token)
|
|
let runtime = RemoteMultiplexerRuntimeAdapter(attachment: reference) { command in
|
|
if command.contains("ss -xnpH") {
|
|
return .init(
|
|
stdout: "M|zellij|42|\(Self.hex(client.started))\nP|\(Self.hex(client.endpoint!))\nDONE\n",
|
|
stderr: "", exitCode: 0)
|
|
}
|
|
if command.contains("action list-panes --json") {
|
|
return .init(
|
|
stdout: Self.frame(
|
|
.zellij, native: #"[{"id":1,"is_plugin":false,"pane_cwd":"/native","pane_command":"bash"}]"#,
|
|
focus: "CLIENT_ID ZELLIJ_PANE_ID RUNNING_COMMAND\n1 terminal_1 bash"),
|
|
stderr: "", exitCode: 0)
|
|
}
|
|
return .init(stdout: "", stderr: "", exitCode: 79)
|
|
}
|
|
let update = try await runtime.inspect(terminal: "/dev/pts/1")
|
|
#expect(update.active?.pane == "1")
|
|
#expect(update.contexts.first?.directory == "/native")
|
|
#expect(update.contexts.first?.directorySource == .nativePane)
|
|
#expect(update.contexts.first?.capabilities.isEmpty == true)
|
|
}
|
|
|
|
@Test @MainActor func detectedMultiplexerNeverRetainsTheOuterDirectoryOnFailure() {
|
|
let model = TerminalContextCoordinator()
|
|
let shell = TerminalShellContext(
|
|
identity: .init(backend: "shell", server: "s", session: "s", pane: "shell", shellInstance: "outer"),
|
|
shell: "bash", directory: "/outer", home: nil, promptReady: false, sequence: 1, capabilities: [])
|
|
let current = TerminalRuntimeUpdate(status: .ordinary, active: shell.identity, contexts: [shell])
|
|
model.receive(current, generation: model.generation)
|
|
let failed = TerminalRuntimeUpdate(status: .unavailable, backend: .zellij, issue: .clientUnbound)
|
|
let reconciled = RemoteTerminalContextFilter.reconcileObservation(failed, current: current)
|
|
model.receive(reconciled, generation: model.generation)
|
|
#expect(model.directory == nil)
|
|
#expect(model.backend == .zellij)
|
|
#expect(model.issue == .clientUnbound)
|
|
#expect(model.ticket(requiring: .directory) == nil)
|
|
}
|
|
|
|
@Test @MainActor func zellijNativeDirectoryDoesNotGrantShellOrInputPermissions() throws {
|
|
let native = """
|
|
[{"id":1,"is_plugin":false,"pane_cwd":"/tmp/a|b","pane_command":"bash"},
|
|
{"id":2,"is_plugin":false,"pane_cwd":"/other","pane_command":"zsh"}]
|
|
"""
|
|
let update = try RemoteNativeMultiplexerRuntime.parse(
|
|
Self.frame(.zellij, native: native, focus: "CLIENT_ID ZELLIJ_PANE_ID RUNNING_COMMAND\n7 terminal_1 bash"),
|
|
client: Self.client(.zellij))
|
|
let model = TerminalContextCoordinator()
|
|
model.receive(update, generation: model.generation)
|
|
#expect(model.directory == "/tmp/a|b")
|
|
#expect(model.active?.directorySource == .nativePane)
|
|
#expect(model.active?.historySource == nil)
|
|
#expect(model.active?.promptReady == false)
|
|
for capability in [
|
|
TerminalContextCapabilities.directory, .commandLifecycle, .readDraft, .replaceDraft,
|
|
] {
|
|
#expect(model.ticket(requiring: capability) == nil)
|
|
}
|
|
}
|
|
|
|
@Test func zellijOnlyUsesUnanimousFocusWithoutATtyToNativeClientIDMapping() {
|
|
let first = "CLIENT_ID ZELLIJ_PANE_ID RUNNING_COMMAND\n1 terminal_2 bash"
|
|
#expect(RemoteNativeMultiplexerRuntime.zellijPane(first + "\n2 terminal_2 zsh") == "2")
|
|
#expect(RemoteNativeMultiplexerRuntime.zellijPane(first + "\n2 terminal_3 zsh") == nil)
|
|
#expect(RemoteNativeMultiplexerRuntime.zellijPane(first + "\n2 plugin_3 menu") == nil)
|
|
#expect(RemoteNativeMultiplexerRuntime.zellijPane(first + "\n1 terminal_2 bash") == nil)
|
|
}
|
|
|
|
@Test func zmxReadsTheBoundShellDirectoryNotTheCachedListDirectory() throws {
|
|
let native = """
|
|
name=other\tpid=222\tclients=1\tcreated=123\tcwd=/other
|
|
name=work\tpid=123\tclients=2\tcreated=456\tcwd=/stale
|
|
"""
|
|
let update = try RemoteNativeMultiplexerRuntime.parse(
|
|
Self.frame(.zmx, native: native, focus: "123:process-start", directory: "/actual"),
|
|
client: Self.client(.zmx))
|
|
#expect(update.contexts.first?.directory == "/actual")
|
|
#expect(update.contexts.first?.directorySource == .process)
|
|
#expect(update.contexts.first?.capabilities.isEmpty == true)
|
|
let replaced = try RemoteNativeMultiplexerRuntime.parse(
|
|
Self.frame(.zmx, native: native, focus: "124:process-start", directory: "/wrong"),
|
|
client: Self.client(.zmx))
|
|
#expect(replaced.active == nil)
|
|
}
|
|
|
|
@Test func herdrDoesNotSubstituteGlobalFocusOrSavedWorkspaceCwd() throws {
|
|
let client = Self.client(.herdr)
|
|
let cached = try RemoteNativeMultiplexerRuntime.parse(
|
|
Self.frame(.herdr, native: #"{"result":{"panes":[{"pane_id":"w1:p1","cwd":"/saved"}]}}"#),
|
|
client: client)
|
|
#expect(cached.active == nil)
|
|
#expect(cached.issue == .directoryUnavailable)
|
|
let live = try RemoteNativeMultiplexerRuntime.parse(
|
|
Self.frame(
|
|
.herdr,
|
|
native: #"{"result":{"panes":[{"pane_id":"w1:p1","cwd":"/saved","foreground_cwd":"/actual"}]}}"#),
|
|
client: client)
|
|
#expect(live.contexts.first?.directory == "/actual")
|
|
let ambiguous = try RemoteNativeMultiplexerRuntime.parse(
|
|
Self.frame(
|
|
.herdr,
|
|
native:
|
|
#"{"result":{"panes":[{"pane_id":"w1:p1","foreground_cwd":"/a","focused":true},{"pane_id":"w2:p1","foreground_cwd":"/b"}]}}"#
|
|
),
|
|
client: client)
|
|
#expect(ambiguous.active == nil)
|
|
#expect(ambiguous.issue == .paneAmbiguous)
|
|
}
|
|
|
|
@Test func tmuxUnintegratedPaneGetsOnlyANativeDisplayDirectory() throws {
|
|
let frame = """
|
|
tmux 3.3a
|
|
C|/dev/pts/1|555|100|777|$0|99|%2||root
|
|
P|%2|456|0|0|bash
|
|
D|%2|\(Self.hex("/native"))|\(Self.hex("bash"))
|
|
DONE
|
|
|
|
"""
|
|
let update = try RemoteTmuxRuntimeDialect.parse(frame, terminal: "/dev/pts/1")
|
|
#expect(update.contexts.first?.directory == "/native")
|
|
#expect(update.contexts.first?.directorySource == .nativePane)
|
|
#expect(update.contexts.first?.capabilities.isEmpty == true)
|
|
let nested = try RemoteTmuxRuntimeDialect.parse(
|
|
frame.replacingOccurrences(of: Self.hex("bash") + "\n", with: Self.hex("zellij") + "\n"),
|
|
terminal: "/dev/pts/1")
|
|
#expect(nested.active == nil)
|
|
#expect(nested.issue == .nestedMultiplexer)
|
|
}
|
|
|
|
@Test func nativeResponsesRejectTruncationAndNonPaths() throws {
|
|
let text = Self.frame(.zmx, native: "name=work\tpid=123\tclients=1\tcreated=456", focus: "123:start")
|
|
#expect(throws: (any Error).self) {
|
|
try RemoteNativeMultiplexerRuntime.parse(String(text.dropLast()), client: Self.client(.zmx))
|
|
}
|
|
#expect(try RemoteNativeMultiplexerRuntime.parse(text, client: Self.client(.zmx)).active == nil)
|
|
#expect(throws: (any Error).self) {
|
|
try RemoteTerminalProcessProbe.parse("M|zmx|42|00\nS|\nDONE\n")
|
|
}
|
|
}
|
|
}
|