Files
Termind/FrameworkTests/NativeTerminalContextTests.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")
}
}
}