545 lines
29 KiB
Swift
545 lines
29 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(.enabled(if: ProcessInfo.processInfo.environment["TERMIND_TMUX_FIXTURE"] != nil), .serialized)
|
|
@MainActor
|
|
struct TerminalContextRuntimeTests {
|
|
private struct Fixture: Decodable {
|
|
let port: Int
|
|
let hostFingerprint: String
|
|
let privateKeyPath: String
|
|
let maxSessions: Int?
|
|
}
|
|
|
|
private final class State {
|
|
let coordinator = TerminalContextCoordinator()
|
|
var events: [TerminalContextEvent] = []
|
|
var output = Data()
|
|
var reference: RemoteSessionReference?
|
|
var directoryInvalidations = 0
|
|
|
|
func receive(_ event: SSHShellIntegrationEvent) {
|
|
if case .runtime(let update) = event {
|
|
let hadDirectory = coordinator.directory != nil
|
|
events += coordinator.receive(update, generation: coordinator.generation)
|
|
if hadDirectory, coordinator.directory == nil { directoryInvalidations += 1 }
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test(.timeLimit(.minutes(2)), arguments: ["bash", "zsh", "fish"])
|
|
func managedHistoryReadsTheConfirmedPaneFileWithoutTerminalInput(shell: String) async throws {
|
|
let fixture = try loadFixture()
|
|
guard (fixture.maxSessions ?? 1) > 1 else { return }
|
|
let state = State()
|
|
let channel = makeChannel(state)
|
|
let root = "/tmp/termind-history-\(UUID().uuidString)"
|
|
let firstPath = shell == "fish" ? root + "/fish/pane-a_history" : root + "/a ' history"
|
|
let secondPath = shell == "fish" ? root + "/fish/pane-b_history" : root + "/b ' history"
|
|
let setup = """
|
|
mkdir -p \(try RemoteTmuxCommands.quote(root + "/fish"))
|
|
printf %s \(try RemoteTmuxCommands.quote(historyContents("echo from-pane-a", shell: shell))) > \(try RemoteTmuxCommands.quote(firstPath))
|
|
printf %s \(try RemoteTmuxCommands.quote(historyContents("echo from-pane-b", shell: shell))) > \(try RemoteTmuxCommands.quote(secondPath))
|
|
"""
|
|
await channel.configureStartup { execute in
|
|
_ = try await execute(setup)
|
|
let backend = try RemoteSessionBackendRegistry.live.backend(.tmux, execute: execute)
|
|
let owner = UUID()
|
|
let session = try await backend.create(
|
|
token: owner, startupCommand: nil, shellIntegration: true,
|
|
environment: [
|
|
.init(variable: "SHELL", value: "/bin/\(shell)"),
|
|
.init(variable: "HISTFILE", value: firstPath),
|
|
.init(variable: "XDG_DATA_HOME", value: root),
|
|
.init(variable: "fish_history", value: "pane-a"),
|
|
])
|
|
let reference = RemoteSessionReference(
|
|
backend: .tmux, session: session, hostID: UUID(), endpoint: "fixture", username: "root",
|
|
ownershipToken: owner)
|
|
await MainActor.run { state.reference = reference }
|
|
return try SSHPTYStartupPlan(command: backend.launch(reference, created: true).command)
|
|
}
|
|
do {
|
|
try await connect(channel, fixture)
|
|
try await wait { state.coordinator.active?.promptReady == true }
|
|
let first = try #require(state.coordinator.active)
|
|
#expect(first.historySource?.path == firstPath)
|
|
let firstLoader = RemoteShellHistoryLoader(channel: channel, charset: .utf8, context: first)
|
|
#expect(try await firstLoader.load(source: nil).map(\.command) == ["echo from-pane-a"])
|
|
let execute = try await channel.runtimeCommandExecutor()
|
|
_ = try await execute(
|
|
"tmux split-window -h -t \(try RemoteTmuxCommands.quote(first.identity.pane))"
|
|
+ " -e \(try RemoteTmuxCommands.quote("HISTFILE=" + secondPath)) -e fish_history=pane-b")
|
|
try await wait {
|
|
state.coordinator.active?.identity != first.identity && state.coordinator.active?.promptReady == true
|
|
}
|
|
let second = try #require(state.coordinator.active)
|
|
#expect(second.historySource?.path == secondPath)
|
|
await #expect(throws: TerminalRuntimeError.self) { try await firstLoader.load(source: nil) }
|
|
let secondLoader = RemoteShellHistoryLoader(channel: channel, charset: .utf8, context: second)
|
|
#expect(try await secondLoader.load(source: nil).map(\.command) == ["echo from-pane-b"])
|
|
_ = try await execute("rm -rf \(try RemoteTmuxCommands.quote(root))")
|
|
try await cleanup(channel, state)
|
|
} catch {
|
|
if let execute = try? await channel.sessionCommandExecutor() {
|
|
_ = try? await execute("rm -rf \(try RemoteTmuxCommands.quote(root))")
|
|
}
|
|
try? await cleanup(channel, state)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
@Test(.timeLimit(.minutes(2)), arguments: ["bash", "zsh", "fish"])
|
|
func managedContextsDraftsAndInvisibleCompletions(shell: String) async throws {
|
|
let fixture = try loadFixture()
|
|
guard (fixture.maxSessions ?? 1) > 1 else { return }
|
|
let state = State()
|
|
let channel = makeChannel(state)
|
|
var stage = "connect"
|
|
await channel.configureStartup { execute in
|
|
let backend = try RemoteSessionBackendRegistry.live.backend(.tmux, execute: execute)
|
|
let owner = UUID()
|
|
let session = try await backend.create(
|
|
token: owner, startupCommand: "cd /tmp", shellIntegration: true,
|
|
environment: [
|
|
.init(variable: "SHELL", value: "/bin/\(shell)"),
|
|
.init(variable: "LC_ALL", value: "C.UTF-8"),
|
|
])
|
|
let reference = RemoteSessionReference(
|
|
backend: .tmux, session: session, hostID: UUID(), endpoint: "fixture", username: "root",
|
|
ownershipToken: owner)
|
|
await MainActor.run { state.reference = reference }
|
|
return try SSHPTYStartupPlan(command: backend.launch(reference, created: true).command)
|
|
}
|
|
do {
|
|
try await connect(channel, fixture)
|
|
stage = "initial prompt"
|
|
try await wait { state.coordinator.active?.capabilities.contains(.readDraft) == true }
|
|
let first = try #require(state.coordinator.active)
|
|
#expect(first.shell == shell)
|
|
#expect(first.directory == "/tmp")
|
|
let execute = try await channel.runtimeCommandExecutor()
|
|
stage = "read draft"
|
|
let beforeInput = state.coordinator.revision
|
|
let invalidations = state.directoryInvalidations
|
|
state.coordinator.noteInput()
|
|
await channel.write(Data("echo 'draft | 中文'".utf8))
|
|
try await wait {
|
|
state.coordinator.revision > beforeInput && state.coordinator.active?.identity == first.identity
|
|
}
|
|
let draftContext = try #require(state.coordinator.active)
|
|
#expect(try await channel.contextDraft(draftContext) == "echo 'draft | 中文'")
|
|
try await wait { (state.coordinator.active?.sequence ?? 0) > draftContext.sequence }
|
|
#expect(state.coordinator.directory == "/tmp")
|
|
#expect(state.directoryInvalidations == invalidations)
|
|
stage = "prefix mode"
|
|
state.coordinator.noteInput()
|
|
await channel.write(Data([0x02]))
|
|
try await wait { state.coordinator.active?.capabilities.contains(.readDraft) == false }
|
|
#expect(state.coordinator.directory == "/tmp")
|
|
#expect(state.directoryInvalidations == invalidations)
|
|
state.coordinator.noteInput()
|
|
await channel.write(Data([0x1B]))
|
|
try await wait { state.coordinator.active?.capabilities.contains(.readDraft) == true }
|
|
#expect(state.directoryInvalidations == invalidations)
|
|
let resumedContext = try #require(state.coordinator.active)
|
|
#expect(try await channel.contextDraft(resumedContext) == "echo 'draft | 中文'")
|
|
try await wait { (state.coordinator.active?.sequence ?? 0) > resumedContext.sequence }
|
|
stage = "replace draft"
|
|
let replacementContext = try #require(state.coordinator.active)
|
|
#expect(
|
|
try await channel.contextDraft(replacementContext, replacing: "printf 'replacement'")
|
|
== "printf 'replacement'")
|
|
try await wait { (state.coordinator.active?.sequence ?? 0) > replacementContext.sequence }
|
|
let editedElsewhere = try #require(state.coordinator.active)
|
|
_ = try await execute("tmux send-keys -t '\(first.identity.pane)' -l 'remote-edit'")
|
|
do {
|
|
_ = try await channel.contextDraft(editedElsewhere, replacing: "MUST_NOT_REPLACE_REMOTE_EDIT")
|
|
Issue.record("A draft changed by another client must not be replaced")
|
|
} catch {}
|
|
let beforeClear = state.coordinator.revision
|
|
state.coordinator.noteInput()
|
|
await channel.write(Data([0x15]))
|
|
try await wait {
|
|
state.coordinator.revision > beforeClear && state.coordinator.active?.identity == first.identity
|
|
}
|
|
stage = "split pane"
|
|
let target = try RemoteTmuxCommands.quote(first.identity.pane)
|
|
let split = try await execute("tmux split-window -h -P -F '#{pane_id}' -t \(target)")
|
|
let second = split.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
try #require(RemoteShellContextRecord.validPane(second))
|
|
try await wait {
|
|
state.coordinator.active?.identity.pane == second && state.coordinator.active?.promptReady == true
|
|
}
|
|
#expect(state.coordinator.contexts.count == 2)
|
|
stage = "background command"
|
|
_ = try await execute("tmux send-keys -t \(target) 'sleep 2; false' Enter")
|
|
try await wait {
|
|
state.events.contains {
|
|
$0.context == first.identity
|
|
&& {
|
|
if case .commandStarted = $0.payload { return true }
|
|
return false
|
|
}($0)
|
|
}
|
|
}
|
|
try await wait {
|
|
state.events.contains {
|
|
$0.context == first.identity
|
|
&& {
|
|
if case .commandFinished(1, _) = $0.payload { return true }
|
|
return false
|
|
}($0)
|
|
}
|
|
}
|
|
#expect(state.coordinator.active?.identity.pane == second)
|
|
stage = "unsafe modes"
|
|
_ = try await execute("tmux copy-mode -t '\(second)'")
|
|
try await wait { state.coordinator.active?.capabilities.contains(.readDraft) == false }
|
|
#expect(state.coordinator.directory == state.coordinator.active?.directory)
|
|
#expect(state.coordinator.ticket(requiring: .directory) == nil)
|
|
_ = try await execute(
|
|
"tmux send-keys -t '\(second)' -X cancel; tmux set-window-option -t '\(second)' synchronize-panes on")
|
|
try await Task.sleep(for: .seconds(1))
|
|
#expect(state.coordinator.active?.capabilities.contains(.replaceDraft) == false)
|
|
_ = try await execute("tmux set-window-option -t '\(second)' synchronize-panes off")
|
|
try await wait { state.coordinator.active?.capabilities.contains(.readDraft) == true }
|
|
let terminal = try #require(state.coordinator.clientIdentity?.components(separatedBy: ":").first)
|
|
let client = try RemoteTmuxCommands.quote(terminal)
|
|
_ = try await execute("tmux refresh-client -t \(client) -f active-pane")
|
|
try await wait { state.coordinator.status == .unavailable }
|
|
_ = try await execute("tmux refresh-client -t \(client) -f '!active-pane'")
|
|
try await wait { state.coordinator.active?.capabilities.contains(.readDraft) == true }
|
|
let stale = try #require(state.coordinator.active)
|
|
_ = try await execute("tmux select-pane -t \(target)")
|
|
do {
|
|
_ = try await channel.contextDraft(stale, replacing: "MUST_NOT_APPEAR")
|
|
Issue.record("A silently switched pane must reject the old draft target")
|
|
} catch {}
|
|
#expect(
|
|
try await execute("tmux capture-pane -p -t '\(second)'").stdout.contains("MUST_NOT_APPEAR") == false)
|
|
stage = "window switch"
|
|
let sessionTarget = try RemoteTmuxCommands.quote(state.reference!.session.id)
|
|
let window = try await execute("tmux new-window -t \(sessionTarget) -P -F '#{pane_id}'")
|
|
let third = window.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
try #require(RemoteShellContextRecord.validPane(third))
|
|
try await wait {
|
|
state.coordinator.active?.identity.pane == third && state.coordinator.active?.promptReady == true
|
|
}
|
|
_ = try await execute("tmux send-keys -t '\(third)' 'cd /' Enter")
|
|
try await wait { state.coordinator.directory == "/" }
|
|
_ = try await execute("tmux select-window -t \(target); tmux select-pane -t \(target)")
|
|
try await wait {
|
|
state.coordinator.active?.identity == first.identity && state.coordinator.directory == "/tmp"
|
|
}
|
|
stage = "reconnect without hook reinstallation"
|
|
let reference = try #require(state.reference)
|
|
await channel.disconnect()
|
|
state.coordinator.reset()
|
|
state.events.removeAll()
|
|
await channel.configureStartup { execute in
|
|
let backend = try RemoteSessionBackendRegistry.live.backend(.tmux, execute: execute)
|
|
try backend.validate(reference)
|
|
return try SSHPTYStartupPlan(command: backend.launch(reference, created: false).command)
|
|
}
|
|
try await connect(channel, fixture)
|
|
try await wait { state.coordinator.active?.capabilities.contains(.readDraft) == true }
|
|
#expect(state.coordinator.contexts[first.identity] != nil)
|
|
#expect(state.events.isEmpty)
|
|
try await cleanup(channel, state)
|
|
} catch {
|
|
Issue.record("Runtime acceptance failed at \(stage) for \(shell): \(error)")
|
|
try? await cleanup(channel, state)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
@Test(.timeLimit(.minutes(1)), arguments: ["bash", "zsh", "fish"])
|
|
func ordinaryShellManualAttachAndDetachUseTheSameTransport(shell: String) async throws {
|
|
let fixture = try loadFixture()
|
|
guard (fixture.maxSessions ?? 1) > 1 else { return }
|
|
let state = State()
|
|
let channel = makeChannel(state)
|
|
let root = "/tmp/termind-manual-history-\(UUID().uuidString)"
|
|
let path = shell == "fish" ? root + "/fish/manual_history" : root + "/history"
|
|
var stage = "ordinary draft"
|
|
await channel.configureEnvironment([
|
|
.init(variable: "TERMIND_TMUX_SHELL", value: "/bin/\(shell)"), .init(variable: "LC_ALL", value: "C.UTF-8"),
|
|
])
|
|
do {
|
|
try await connect(channel, fixture)
|
|
try await wait { state.coordinator.status == .ordinary && state.coordinator.active?.promptReady == true }
|
|
let outer = try #require(state.coordinator.active)
|
|
#expect(outer.shell == shell)
|
|
await channel.write(Data("echo scratch".utf8))
|
|
#expect(try await channel.contextDraft(outer) == "echo scratch")
|
|
await channel.write(Data([0x15]))
|
|
stage = "ordinary history"
|
|
let outerHistory = RemoteShellHistoryLoader(channel: channel, charset: .utf8, context: outer)
|
|
_ = try await outerHistory.load(source: nil)
|
|
await #expect(throws: TerminalRuntimeError.self) {
|
|
try await RemoteShellHistoryLoader(channel: channel, charset: .utf8).load(source: nil)
|
|
}
|
|
stage = "ordinary running cwd"
|
|
let cwd = try #require(state.coordinator.directory)
|
|
let invalidations = state.directoryInvalidations
|
|
await channel.write(Data("sleep 2\n".utf8))
|
|
try await wait { state.coordinator.active?.promptReady == false }
|
|
#expect(state.coordinator.directory == cwd)
|
|
#expect(state.coordinator.ticket(requiring: .replaceDraft) == nil)
|
|
try await wait { state.coordinator.active?.promptReady == true }
|
|
#expect(state.directoryInvalidations == invalidations)
|
|
#expect(state.coordinator.directory == cwd)
|
|
let execute = try await channel.runtimeCommandExecutor()
|
|
_ = try await execute(
|
|
"mkdir -p \(try RemoteTmuxCommands.quote(root + "/fish")); printf %s "
|
|
+ "\(try RemoteTmuxCommands.quote(historyContents("echo manual-pane", shell: shell)))"
|
|
+ " > \(try RemoteTmuxCommands.quote(path))")
|
|
let backend = try RemoteSessionBackendRegistry.live.backend(.tmux, execute: execute)
|
|
let owner = UUID()
|
|
let session = try await backend.create(
|
|
token: owner, startupCommand: "cd /tmp", shellIntegration: true,
|
|
environment: [
|
|
.init(variable: "SHELL", value: "/bin/\(shell)"),
|
|
.init(variable: "HISTFILE", value: path),
|
|
.init(variable: "XDG_DATA_HOME", value: root),
|
|
.init(variable: "fish_history", value: "manual"),
|
|
])
|
|
let reference = RemoteSessionReference(
|
|
backend: .tmux, session: session, hostID: UUID(), endpoint: "fixture", username: "root",
|
|
ownershipToken: owner)
|
|
state.reference = reference
|
|
stage = "manual attach history"
|
|
await channel.write(Data("tmux attach-session -t '\(session.id)'\n".utf8))
|
|
try await wait { state.coordinator.status == .multiplexer && state.coordinator.active?.promptReady == true }
|
|
#expect(state.coordinator.directory == "/tmp")
|
|
let pane = try #require(state.coordinator.active)
|
|
#expect(pane.historySource?.path == path)
|
|
let paneHistory = RemoteShellHistoryLoader(channel: channel, charset: .utf8, context: pane)
|
|
#expect(try await paneHistory.load(source: nil).map(\.command) == ["echo manual-pane"])
|
|
if outer.historySource?.path == "" {
|
|
#expect(try await outerHistory.load(source: nil).isEmpty)
|
|
} else {
|
|
await #expect(throws: TerminalRuntimeError.self) { try await outerHistory.load(source: nil) }
|
|
}
|
|
await channel.write(Data([0x02, 0x64]))
|
|
stage = "detach history"
|
|
try await wait { state.coordinator.status == .ordinary }
|
|
#expect(state.coordinator.active?.identity == outer.identity)
|
|
_ = try await outerHistory.load(source: nil)
|
|
await #expect(throws: TerminalRuntimeError.self) { try await paneHistory.load(source: nil) }
|
|
#expect(
|
|
state.events.contains {
|
|
if case .commandFinished = $0.payload { return $0.context == outer.identity }
|
|
return false
|
|
})
|
|
_ = try await execute("rm -rf \(try RemoteTmuxCommands.quote(root))")
|
|
try await cleanup(channel, state)
|
|
} catch {
|
|
Issue.record("Manual history acceptance failed at \(stage) for \(shell): \(error)")
|
|
if let execute = try? await channel.sessionCommandExecutor() {
|
|
_ = try? await execute("rm -rf \(try RemoteTmuxCommands.quote(root))")
|
|
}
|
|
try? await cleanup(channel, state)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
@Test(.timeLimit(.minutes(1)))
|
|
func capacityFailureLeavesTheTerminalUsable() async throws {
|
|
let fixture = try loadFixture()
|
|
let state = State()
|
|
let channel = SSHPTYChannel(
|
|
output: { data in Task { @MainActor in state.output.append(data) } }, exit: { _ in },
|
|
shellIntegration: { event in Task { @MainActor in state.receive(event) } },
|
|
maxSessionsPolicy: .init(preferences: .init(maxSessions: 1)))
|
|
do {
|
|
try await connect(channel, fixture)
|
|
try await wait { state.coordinator.status == .ordinary }
|
|
let context = try #require(state.coordinator.active)
|
|
do {
|
|
_ = try await channel.contextDraft(context)
|
|
Issue.record("The occupied channel budget must refuse a draft exec")
|
|
} catch {}
|
|
do {
|
|
_ = try await RemoteShellHistoryLoader(channel: channel, charset: .utf8, context: context)
|
|
.load(source: nil)
|
|
Issue.record("The occupied channel budget must refuse a history exec")
|
|
} catch {}
|
|
await channel.write(Data("printf 'TERMINAL_STILL_WORKS\\n'\n".utf8))
|
|
try await wait { String(decoding: state.output, as: UTF8.self).contains("TERMINAL_STILL_WORKS\r\n") }
|
|
await channel.disconnect()
|
|
} catch {
|
|
await channel.disconnect()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
@Test(.timeLimit(.minutes(1)))
|
|
func serverChannelRefusalLeavesTerminalUsable() async throws {
|
|
let fixture = try loadFixture()
|
|
guard (fixture.maxSessions ?? 1) == 1 else { return }
|
|
let state = State()
|
|
let channel = makeChannel(state)
|
|
await channel.configureStartup { execute in
|
|
let backend = try RemoteSessionBackendRegistry.live.backend(.tmux, execute: execute)
|
|
let owner = UUID()
|
|
let session = try await backend.create(
|
|
token: owner, startupCommand: nil, shellIntegration: true, environment: [])
|
|
let reference = RemoteSessionReference(
|
|
backend: .tmux, session: session, hostID: UUID(), endpoint: "fixture", username: "root",
|
|
ownershipToken: owner)
|
|
await MainActor.run { state.reference = reference }
|
|
return try SSHPTYStartupPlan(command: backend.launch(reference, created: true).command)
|
|
}
|
|
do {
|
|
try await connect(channel, fixture)
|
|
try await wait { state.coordinator.status == .unavailable }
|
|
#expect(state.coordinator.directory == nil)
|
|
#expect(state.coordinator.ticket(requiring: .replaceDraft) == nil)
|
|
await #expect(throws: TerminalRuntimeError.self) {
|
|
try await RemoteShellHistoryLoader(channel: channel, charset: .utf8).load(source: nil)
|
|
}
|
|
await channel.write(Data("printf 'SERVER_LIMIT_%s\\n' TERMINAL_OK\n".utf8))
|
|
try await wait { String(decoding: state.output, as: UTF8.self).contains("SERVER_LIMIT_TERMINAL_OK") }
|
|
try await cleanup(channel, state)
|
|
} catch {
|
|
try? await cleanup(channel, state)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
@Test(.timeLimit(.minutes(1)), arguments: [false, true])
|
|
func plainShellHistoryStillLoadsWithoutIntegration(prepared: Bool) async throws {
|
|
let fixture = try loadFixture()
|
|
guard (fixture.maxSessions ?? 1) > 1 else { return }
|
|
let state = State()
|
|
let channel = makeChannel(state)
|
|
if prepared { await channel.configureStartup { _ in nil } }
|
|
do {
|
|
try await connect(channel, fixture, shellIntegrationEnabled: false)
|
|
_ = try await RemoteShellHistoryLoader(channel: channel, charset: .utf8).load(source: nil)
|
|
await channel.disconnect()
|
|
} catch {
|
|
await channel.disconnect()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
@Test(.timeLimit(.minutes(1)))
|
|
func loginScriptAutoAttachNeverReceivesBootstrapInput() async throws {
|
|
let fixture = try loadFixture()
|
|
guard (fixture.maxSessions ?? 1) > 1 else { return }
|
|
let state = State()
|
|
let channel = makeChannel(state)
|
|
let backup = "/tmp/termind-profile-\(UUID().uuidString)"
|
|
let restore = """
|
|
if [ -f '\(backup)' ]; then mv '\(backup)' /root/.bash_profile;
|
|
else rm -f /root/.bash_profile; fi
|
|
"""
|
|
await channel.configureStartup { execute in
|
|
let backend = try RemoteSessionBackendRegistry.live.backend(.tmux, execute: execute)
|
|
let owner = UUID()
|
|
let session = try await backend.create(
|
|
token: owner, startupCommand: "exec vim -n -u NONE -i NONE", shellIntegration: true,
|
|
environment: [.init(variable: "SHELL", value: "/bin/bash")])
|
|
let reference = RemoteSessionReference(
|
|
backend: .tmux, session: session, hostID: UUID(), endpoint: "fixture", username: "root",
|
|
ownershipToken: owner)
|
|
await MainActor.run { state.reference = reference }
|
|
let profile = try RemoteTmuxCommands.quote("exec tmux attach-session -t '\(session.id)'")
|
|
_ = try await execute(
|
|
"[ ! -f /root/.bash_profile ] || cp /root/.bash_profile '\(backup)'; printf '%s\\n' \(profile) > /root/.bash_profile"
|
|
)
|
|
return nil
|
|
}
|
|
do {
|
|
try await connect(channel, fixture)
|
|
let execute = try await channel.runtimeCommandExecutor()
|
|
let reference = try #require(state.reference)
|
|
let target = try RemoteTmuxCommands.quote(reference.session.id)
|
|
var attached = false
|
|
for _ in 0..<100 {
|
|
attached =
|
|
try await execute("tmux list-clients -t \(target) -F '#{client_pid}'").stdout
|
|
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
|
if attached { break }
|
|
try await Task.sleep(for: .milliseconds(25))
|
|
}
|
|
_ = try await execute(restore)
|
|
try #require(attached)
|
|
try await Task.sleep(for: .seconds(1))
|
|
let before = try await execute("tmux capture-pane -p -t \(target)").stdout
|
|
try await Task.sleep(for: .seconds(2))
|
|
let after = try await execute("tmux capture-pane -p -t \(target)").stdout
|
|
#expect(before == after)
|
|
#expect(!after.contains("__termind_"))
|
|
#expect(
|
|
try await execute("tmux display-message -p -t \(target) '#{pane_current_command}'").stdout == "vim\n")
|
|
#expect(state.coordinator.directory == nil)
|
|
#expect(state.coordinator.ticket(requiring: .replaceDraft) == nil)
|
|
try await cleanup(channel, state)
|
|
} catch {
|
|
if let execute = try? await channel.sessionCommandExecutor() { _ = try? await execute(restore) }
|
|
try? await cleanup(channel, state)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private func historyContents(_ command: String, shell: String) -> String {
|
|
switch shell {
|
|
case "zsh": ": 123:0;\(command)\n"
|
|
case "fish": "- cmd: \(command)\n when: 123\n"
|
|
default: "#123\n\(command)\n"
|
|
}
|
|
}
|
|
|
|
private func makeChannel(_ state: State) -> SSHPTYChannel {
|
|
SSHPTYChannel(
|
|
output: { data in Task { @MainActor in state.output.append(data) } }, exit: { _ in },
|
|
shellIntegration: { event in Task { @MainActor in state.receive(event) } })
|
|
}
|
|
|
|
private func cleanup(_ channel: SSHPTYChannel, _ state: State) async throws {
|
|
try await channel.releaseTerminalForControl()
|
|
if let reference = state.reference {
|
|
let execute = try await channel.sessionCommandExecutor()
|
|
try await RemoteSessionBackendRegistry.live.backend(.tmux, execute: execute).terminate(reference)
|
|
}
|
|
await channel.disconnect()
|
|
}
|
|
|
|
private func loadFixture() throws -> Fixture {
|
|
let path = try #require(ProcessInfo.processInfo.environment["TERMIND_TMUX_FIXTURE"])
|
|
return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: URL(fileURLWithPath: path)))
|
|
}
|
|
|
|
private func wait(_ condition: () -> Bool) async throws {
|
|
for _ in 0..<400 {
|
|
if condition() { return }
|
|
try await Task.sleep(for: .milliseconds(25))
|
|
}
|
|
throw TerminalRuntimeError.invalidResponse
|
|
}
|
|
|
|
private func connect(
|
|
_ channel: SSHPTYChannel, _ fixture: Fixture, shellIntegrationEnabled: Bool = true
|
|
) async throws {
|
|
let key = try String(contentsOfFile: fixture.privateKeyPath, encoding: .utf8)
|
|
try await channel.connect(
|
|
using: SSHConnectionRequest(
|
|
configuration: .init(
|
|
hostID: UUID(), host: "127.0.0.1", port: fixture.port, username: "root",
|
|
credentials: [.privateKey(privateKey: SSHSecret(key), publicKey: nil, passphrase: nil)],
|
|
expectedHostKeys: [.init(algorithm: .ed25519, fingerprint: fixture.hostFingerprint)])),
|
|
shellIntegrationEnabled: shellIntegrationEnabled, columns: 100, rows: 30)
|
|
}
|
|
}
|