Files
Termind/TFITests/MobileCommandGeneratorAndUploadTests.swift

1601 lines
74 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import TermindApplication
import TermindDomain
import TermindPresentation
import TermindRemoteInfrastructure
import Testing
import UniformTypeIdentifiers
import struct TermindDomain.Host
@testable import TermindIOS
@MainActor
private final class MobileCommandGeneratorStub: CommandGenerating {
var command: String
var delay: Duration?
var ignoresCancellation = false
private(set) var didFinish = false
private(set) var contexts: [CommandGeneratorContext] = []
init(command: String, delay: Duration? = nil) {
self.command = command
self.delay = delay
}
func generate(_ context: CommandGeneratorContext) async throws -> String {
contexts.append(context)
if let delay {
if ignoresCancellation {
try? await Task.sleep(for: delay)
} else {
try await Task.sleep(for: delay)
}
}
didFinish = true
return command
}
}
@MainActor
private final class MobileSSHCommandConnectionBox {
var draft = "list files"
private(set) var writes: [Data] = []
private(set) var shellIntegrationPreferences: [Bool] = []
private var exits: [SSHPTYChannel.ExitHandler] = []
private var shellIntegrations: [SSHPTYChannel.ShellIntegrationHandler] = []
var sessionCount: Int { shellIntegrations.count }
var services: MobileWorkspaceConnectionServices {
MobileWorkspaceConnectionServices(
makePTYChannel: { [weak self] output, exit, shellIntegration in
self?.exits.append(exit)
self?.shellIntegrations.append(shellIntegration)
return SSHPTYChannel(
output: output,
exit: exit,
shellIntegration: shellIntegration
)
},
connectSSH: { [weak self] _, _, shellIntegrationEnabled, _, _, _ in
self?.shellIntegrationPreferences.append(shellIntegrationEnabled)
},
connectSFTP: { _, _, _ in throw SFTPError.notConnected },
writeSSH: { [weak self] _, data in
guard let self else { return }
writes.append(data)
if data == Data("\u{1B}[777;1~".utf8) {
shellIntegrations.last?(.draft(draft))
}
},
submitInitialCommand: { [weak self] _, data, _ in
self?.writes.append(data)
}
)
}
func sendShellEvents(
_ events: [SSHShellIntegrationEvent],
session index: Int? = nil
) async {
let index = index ?? shellIntegrations.index(before: shellIntegrations.endIndex)
let handler = shellIntegrations[index]
await Task.detached {
for event in events { handler(event) }
}.value
}
func finishSession(
_ result: Result<Int32, TermindSSHError>,
session index: Int
) async {
let handler = exits[index]
await Task.detached { handler(result) }.value
}
}
@Suite @MainActor
struct MobileCommandGeneratorTests {
@Test func ordinaryStartupFallbackSubmitsOnceAndOldConnectionCannotReleaseANewSnippet() async throws {
var (host, library) = try commandFixture()
host.shellIntegrationMode = .enabled
let snippet = Snippet(title: "Startup", body: "echo startup")
host.startupSnippetID = snippet.id
library.upsert(snippet)
library.upsert(host)
let connections = MobileSSHCommandConnectionBox()
let workspace = MobileWorkspace(
descriptor: .init(hostID: host.id, title: host.name, kind: .ssh), hosts: library,
connectionServices: connections.services, initialCommand: "echo explicit")
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
await connections.sendShellEvents([.runtime(.init(status: .unavailable))])
#expect(await eventually { workspace.terminalContext.status == .unavailable })
#expect(connections.writes.isEmpty)
let fallback = SSHShellIntegrationEvent.ordinaryStartupUnavailable(
ticket: .init(generation: UUID(), inputRevision: 0))
await connections.sendShellEvents([fallback, fallback])
#expect(await eventually { connections.writes == [Data("echo explicit\r".utf8)] })
#expect(workspace.terminalContext.active == nil)
#expect(!workspace.canGenerateFromCurrentDraft)
#expect(workspace.workingDirectory == nil)
await connections.finishSession(.failure(.socket("test disconnect")), session: 0)
#expect(await eventually(attempts: 500) { connections.sessionCount == 2 && workspace.connected })
await connections.sendShellEvents([fallback], session: 0)
await connections.sendShellEvents([.runtime(.init(status: .unavailable))], session: 1)
#expect(await eventually { workspace.terminalContext.status == .unavailable })
#expect(connections.writes == [Data("echo explicit\r".utf8)])
await connections.sendShellEvents([fallback, fallback], session: 1)
#expect(
await eventually {
connections.writes == [Data("echo explicit\r".utf8), Data("echo startup\r".utf8)]
})
}
@Test func rejectedOrdinaryStartupDoesNotReplayAtALaterPrompt() async throws {
var (host, library) = try commandFixture()
host.shellIntegrationMode = .enabled
library.upsert(host)
let connections = MobileSSHCommandConnectionBox()
let workspace = MobileWorkspace(
descriptor: .init(hostID: host.id, title: host.name, kind: .ssh), hosts: library,
connectionServices: connections.services, initialCommand: "echo initial")
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
await connections.sendShellEvents([
.ordinaryStartupUnavailable(ticket: nil),
.available(shell: "bash", systemInfo: nil, homeDirectory: "/home"),
.promptReady,
])
#expect(await eventually { workspace.canGenerateFromCurrentDraft })
#expect(workspace.pendingInitialCommand == nil)
#expect(connections.writes.isEmpty)
}
@Test(arguments: ["shell", "tmux", "zellij", "zmx", "herdr"])
func runningCommandsKeepTheDisplayedDirectoryUntilTheShellReportsAnother(backend: String) async throws {
var (host, library) = try commandFixture()
host.shellIntegrationMode = .enabled
library.upsert(host)
let connections = MobileSSHCommandConnectionBox()
let workspace = MobileWorkspace(
descriptor: .init(hostID: host.id, title: host.name, kind: .ssh),
hosts: library, connectionServices: connections.services)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
var context = TerminalShellContext(
identity: .init(backend: backend, server: "s", session: "s", pane: "a", shellInstance: "i"),
shell: "bash", directory: "/before", home: "/home/test", promptReady: true, sequence: 1,
capabilities: [.directory, .commandLifecycle, .readDraft, .replaceDraft])
let status: TerminalRuntimeUpdate.Status = backend == "shell" ? .ordinary : .multiplexer
func receive() {
workspace.receiveShellEvent(
.runtime(.init(status: status, active: context.identity, contexts: [context])))
}
receive()
#expect(workspace.canGenerateFromCurrentDraft)
context.sequence = 2
context.promptReady = false
context.capabilities = [.commandLifecycle]
receive()
#expect(workspace.workingDirectory == "/before")
#expect(!workspace.canGenerateFromCurrentDraft)
context.sequence = 3
context.directory = "/after"
receive()
#expect(workspace.workingDirectory == "/after")
#expect(!workspace.canGenerateFromCurrentDraft)
workspace.receiveShellEvent(.runtime(.init(status: .unavailable)))
#expect(workspace.workingDirectory == nil)
}
@Test(arguments: [false, true])
func paneHistoryInvalidationDoesNotDependOnSessionPersistence(managed: Bool) async throws {
let (host, library) = try commandFixture()
let workspace = MobileWorkspace(
descriptor: .init(hostID: host.id, title: host.name, kind: .ssh), hosts: library)
defer { workspace.disconnect() }
if managed {
workspace.sessionPersistence.attachment = .init(
backend: .tmux, session: .init(id: "test", name: "Test", identity: Data()),
hostID: host.id, endpoint: "test:22", username: "user", ownershipToken: UUID())
}
let context = TerminalShellContext(
identity: .init(backend: "test", server: "s", session: "s", pane: "a", shellInstance: "i"),
shell: "bash", directory: "/", home: "/home/test", promptReady: true, sequence: 1,
capabilities: [.commandLifecycle], historyPath: "/home/test/history")
workspace.receiveShellEvent(
.runtime(.init(status: .multiplexer, active: context.identity, contexts: [context])))
workspace.commandHistory.load(using: HistoryLoader(), source: context.historySource)
#expect(await eventually { workspace.commandHistory.hasLoaded })
#expect(workspace.commandHistory.entries().map(\.command) == ["imported"])
workspace.receiveShellEvent(
.runtime(.init(status: .multiplexer, active: context.identity, contexts: [context])))
#expect(workspace.commandHistory.hasLoaded)
workspace.receiveShellEvent(.runtime(.init(status: .unknown)))
#expect(workspace.commandHistory.entries().isEmpty)
#expect(!workspace.commandHistory.hasLoaded)
}
private struct HistoryLoader: ShellHistoryLoading {
func load(source: ShellHistorySource?) async throws -> [ShellHistoryRecord] { [.init(command: "imported")] }
}
@Test func runtimePaneInvalidationClearsDirectoryAndIgnoresUnscopedEvents() async throws {
var (host, library) = try commandFixture()
host.shellIntegrationMode = .enabled
library.upsert(host)
let connections = MobileSSHCommandConnectionBox()
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library, connectionServices: connections.services)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
let context = TerminalShellContext(
identity: .init(backend: "test-multiplexer", server: "s", session: "s", pane: "a", shellInstance: "i"),
shell: "bash", directory: "/pane-a", home: "/home/test", promptReady: true, sequence: 1,
capabilities: [.directory, .commandLifecycle, .readDraft, .replaceDraft])
workspace.receiveShellEvent(
.runtime(.init(status: .multiplexer, active: context.identity, contexts: [context])))
#expect(!host.sessionPersistence.enabled)
#expect(workspace.canGenerateFromCurrentDraft)
#expect(workspace.workingDirectory == "/pane-a")
workspace.receiveShellEvent(.runtime(.init(status: .unavailable)))
workspace.receiveWorkingDirectory("/stale")
workspace.receiveShellEvent(.promptReady)
#expect(workspace.workingDirectory == nil)
#expect(workspace.sameDirectoryLaunch == nil)
#expect(!workspace.canGenerateFromCurrentDraft)
}
@Test(arguments: HostShellIntegrationMode.allCases, [false, true])
func integrationUsesConnectionSnapshotUntilManualOrAutomaticReconnect(
mode: HostShellIntegrationMode, global: Bool
) async throws {
var (host, library) = try commandFixture()
host.shellIntegrationMode = mode
library.upsert(host)
let settings = TerminalSettingsModel()
#expect(settings.set(global, for: \.shellIntegration))
let connections = MobileSSHCommandConnectionBox()
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library, terminalSettings: settings, connectionServices: connections.services)
let history = workspace.commandHistory
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
let expected = mode.isEnabled(globalSetting: global)
#expect(connections.shellIntegrationPreferences == [expected])
workspace.receiveShellEvent(.available(shell: "bash", systemInfo: nil, homeDirectory: "/home/test"))
workspace.receiveWorkingDirectory("/srv/app")
#expect(settings.set(!global, for: \.shellIntegration))
host.shellIntegrationMode = expected ? .disabled : .enabled
library.upsert(host)
#expect(workspace.isShellIntegrationEnabled == expected)
#expect(workspace.canGenerateFromCurrentDraft == expected)
#expect((workspace.sameDirectoryLaunch != nil) == expected)
workspace.receiveShellEvent(.commandStarted("pwd"))
workspace.receiveShellEvent(.commandFinished(0, duration: 0.1))
#expect(history.entries().count == (expected ? 1 : 0))
#expect(connections.sessionCount == 1)
workspace.disconnect()
workspace.connect()
#expect(await eventually { workspace.connected })
#expect(connections.shellIntegrationPreferences == [expected, !expected])
host.shellIntegrationMode = .followGlobalSettings
library.replacePersistedSnapshot(VaultSnapshot(hosts: [host], identities: library.identities))
var synced = settings.preferences
synced.shellIntegration = expected
settings.applyRemoteSnapshot(synced)
#expect(workspace.isShellIntegrationEnabled == !expected)
await connections.finishSession(.failure(.socket("test disconnect")), session: 1)
#expect(await eventually(attempts: 500) { connections.sessionCount == 3 && workspace.connected })
#expect(connections.shellIntegrationPreferences == [expected, !expected, expected])
}
@Test func startupSnippetUsesExplicitLaunchOnceThenRefreshesOnReconnect() async throws {
var (host, library) = try commandFixture()
var startup = Snippet(title: "Startup", body: "echo first")
host.startupSnippetID = startup.id
library.upsert(startup)
library.upsert(host)
let connections = MobileSSHCommandConnectionBox()
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library,
connectionServices: connections.services,
initialCommand: "echo explicit"
)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
#expect(connections.writes.isEmpty)
await connections.sendShellEvents([
.available(shell: "bash", systemInfo: nil, homeDirectory: "/home/test")
])
#expect(await eventually { connections.writes == [Data("echo explicit\r".utf8)] })
startup.body = "echo latest"
library.upsert(startup)
await connections.finishSession(.failure(.socket("test disconnect")), session: 0)
#expect(await eventually(attempts: 500) { connections.sessionCount == 2 && workspace.connected })
await connections.sendShellEvents(
[.available(shell: "bash", systemInfo: nil, homeDirectory: "/home/test")],
session: 1
)
#expect(
await eventually {
connections.writes == [
Data("echo explicit\r".utf8),
Data("echo latest\r".utf8),
]
}
)
}
@Test func startupSnippetRunsImmediatelyWhenShellIntegrationIsDisabled() async throws {
var (host, library) = try commandFixture()
let startup = Snippet(title: "Startup", body: "export TERMIND_TEST=1")
host.startupSnippetID = startup.id
host.shellIntegrationMode = .disabled
library.upsert(startup)
library.upsert(host)
let connections = MobileSSHCommandConnectionBox()
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library,
connectionServices: connections.services
)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
#expect(
await eventually {
connections.writes == [Data("export TERMIND_TEST=1\r".utf8)]
}
)
}
@Test(arguments: [nil, "", "error: missing file\n 中文 😀\n\n"] as [String?])
func readsTheCurrentDraftAndInsertsWithoutExecuting(_ screen: String?) async throws {
let (host, library) = try commandFixture()
let connections = MobileSSHCommandConnectionBox()
connections.draft = "show hidden files"
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library,
connectionServices: connections.services
)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
let systemInfo = RemoteSystemInfo(
distributionID: "debian",
distributionVersion: "13",
kernelName: "Linux",
kernelRelease: "6.12.0",
architecture: "arm64"
)
workspace.receiveWorkingDirectory("/srv/app")
await connections.sendShellEvents([
.available(shell: "zsh", systemInfo: systemInfo, homeDirectory: "/home/test")
])
#expect(await eventually { workspace.canGenerateFromCurrentDraft })
let generator = MobileCommandGeneratorStub(command: "ls -la")
var viewportReads = 0
workspace.generateCommand(using: generator) { source in
#expect(source === workspace)
#expect(connections.writes.isEmpty)
viewportReads += 1
return screen
}
#expect(await eventually { workspace.commandGeneratorState == .inserted(targetCount: 1) })
#expect(
generator.contexts == [
CommandGeneratorContext(
draft: "show hidden files",
shell: "zsh",
cwd: "/srv/app",
remoteSystemInfo: systemInfo,
viewport: screen.map { .init(text: $0) }
)
]
)
#expect(viewportReads == 1)
#expect(connections.writes.first == Data("\u{1B}[777;1~".utf8))
#expect(
connections.writes.last
== Data("\u{1B}[777;2~\u{1B}[200~ls -la\u{1B}[201~".utf8)
)
#expect(connections.writes.last?.contains(0x0A) == false)
#expect(connections.writes.last?.contains(0x0D) == false)
}
@Test func unavailablePromptFailsWithoutReadingOrCallingTheModel() async throws {
let (host, library) = try commandFixture()
let connections = MobileSSHCommandConnectionBox()
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library,
connectionServices: connections.services
)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
let generator = MobileCommandGeneratorStub(command: "ls")
workspace.generateCommand(using: generator)
guard case .failed = workspace.commandGeneratorState else {
Issue.record("Expected an unavailable-prompt failure")
return
}
#expect(generator.contexts.isEmpty)
#expect(connections.writes.isEmpty)
}
@Test func sameDirectorySFTPLaunchRequiresLiveShellIntegrationAndWorkingDirectory() async throws {
let (host, library) = try commandFixture()
let connections = MobileSSHCommandConnectionBox()
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library,
connectionServices: connections.services
)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
workspace.receiveWorkingDirectory("/srv/releases/")
#expect(workspace.sameDirectoryLaunch == nil)
await connections.sendShellEvents([
.available(shell: "bash", systemInfo: nil, homeDirectory: "/home/test")
])
#expect(
await eventually {
workspace.sameDirectoryLaunch
== MobileWorkspaceDirectoryLaunch(hostID: host.id, kind: .sftp, path: "/srv/releases")
})
workspace.receiveWorkingDirectory(nil)
#expect(workspace.sameDirectoryLaunch == nil)
}
@Test func directoryStartupCommandRunsOnceWithoutEnteringHistory() async throws {
let connections = MobileSSHCommandConnectionBox()
let app = try MobileAppModel(
inMemory: true,
workspaceConnectionServices: connections.services
)
let identity = Identity(name: "Deploy", username: "deploy")
var host = Host(name: "Example", host: "example.invalid")
host.identityID = identity.id
try app.library.upsert(identity, passwordUpdate: .replace("test-only"))
app.library.upsert(host)
app.library.trust(
KnownHostEntry(
hostname: host.host,
port: host.port,
algorithm: .ed25519,
fingerprint: "SHA256:test"
))
let command = try #require(RemoteShellCommand.changeDirectory(to: "/srv/it's live"))
let id = try #require(app.testWorkspaceSession.open(hostID: host.id, kind: .ssh, initialPath: "/srv/it's live"))
let workspace = try #require(app.workspaces.first(where: { $0.id == id }))
defer { app.testWorkspaceSession.close(id) }
#expect(await eventually { workspace.connected })
#expect(!connections.writes.contains(Data((command + "\r").utf8)))
await connections.sendShellEvents([
.available(shell: "zsh", systemInfo: nil, homeDirectory: "/home/test"),
.commandStarted(command),
.commandFinished(0, duration: 0.01),
.commandStarted("pwd"),
.commandFinished(0, duration: 0.02),
])
#expect(await eventually { connections.writes.contains(Data((command + "\r").utf8)) })
#expect(await eventually { workspace.commandHistory.entries().count == 1 })
#expect(workspace.commandHistory.entries().first?.command == "pwd")
#expect(workspace.pendingInitialCommand == nil)
}
@Test func snippetStartupCommandWritesOnceAfterSSHConnect() async throws {
let connections = MobileSSHCommandConnectionBox()
let app = try MobileAppModel(
inMemory: true,
workspaceConnectionServices: connections.services
)
let identity = Identity(name: "Deploy", username: "deploy")
var host = Host(name: "Example", host: "example.invalid")
host.identityID = identity.id
try app.library.upsert(identity, passwordUpdate: .replace("test-only"))
app.library.upsert(host)
app.library.trust(
KnownHostEntry(
hostname: host.host,
port: host.port,
algorithm: .ed25519,
fingerprint: "SHA256:test"
))
let snippet = Snippet(title: "Status", body: "uptime")
let id = try #require(app.testWorkspaceSession.execute(snippet, on: [host.id]).first)
let workspace = try #require(app.workspaces.first(where: { $0.id == id }))
defer { app.testWorkspaceSession.close(id) }
#expect(await eventually { workspace.connected })
#expect(!connections.writes.contains(Data((snippet.body + "\r").utf8)))
await connections.sendShellEvents([
.available(shell: "zsh", systemInfo: nil, homeDirectory: "/home/test")
])
#expect(await eventually { connections.writes.contains(Data((snippet.body + "\r").utf8)) })
#expect(workspace.pendingInitialCommand == nil)
}
enum GenerationInterruption: CaseIterable, Sendable {
case input, disconnect, cancel
}
@Test(arguments: GenerationInterruption.allCases)
func interruptionDiscardsLateGeneration(_ interruption: GenerationInterruption) async throws {
let (host, library) = try commandFixture()
let connections = MobileSSHCommandConnectionBox()
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library,
connectionServices: connections.services
)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
workspace.receiveWorkingDirectory("/tmp")
await connections.sendShellEvents([
.available(shell: "bash", systemInfo: nil, homeDirectory: "/home/test"),
.promptReady,
])
#expect(await eventually { workspace.canGenerateFromCurrentDraft })
let generator = MobileCommandGeneratorStub(command: "ls", delay: .seconds(30))
generator.ignoresCancellation = true
workspace.generateCommand(using: generator, readViewport: { _ in "original screen" })
#expect(await eventually { workspace.commandGeneratorState == .generating })
switch interruption {
case .input: await workspace.receiveInteractiveTerminalInput(Data("new draft".utf8))
case .disconnect: workspace.disconnect()
case .cancel: workspace.cancelCommandGeneration()
}
#expect(await eventually { generator.didFinish })
#expect(await eventually { workspace.commandGeneratorState == .idle })
#expect(!connections.writes.contains(Data("\u{1B}[777;2~\u{1B}[200~ls\u{1B}[201~".utf8)))
}
@Test func productionCallbacksStayOrderedAndIgnoreAnOldSession() async throws {
let (host, library) = try commandFixture()
let connections = MobileSSHCommandConnectionBox()
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library,
connectionServices: connections.services
)
let history = workspace.commandHistory
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected && connections.sessionCount == 1 })
workspace.receiveWorkingDirectory("/srv/app")
await connections.sendShellEvents([
.available(shell: "zsh", systemInfo: nil, homeDirectory: "/home/test"),
.promptReady,
.commandStarted("pwd"),
])
#expect(await eventually { history.entries().count == 1 })
#expect(!workspace.canGenerateFromCurrentDraft)
await connections.sendShellEvents([
.commandFinished(0, duration: 0.25)
])
#expect(await eventually { workspace.canGenerateFromCurrentDraft })
let entry = try #require(history.entries().first)
#expect(entry.command == "pwd")
#expect(entry.exitStatus == 0)
#expect(entry.duration == 0.25)
await connections.finishSession(.success(0), session: 0)
#expect(await eventually { !workspace.connected })
workspace.connect()
#expect(await eventually { workspace.connected && connections.sessionCount == 2 })
workspace.receiveWorkingDirectory("/srv/next")
await connections.sendShellEvents([.commandStarted("stale")], session: 0)
await connections.sendShellEvents(
[
.available(shell: "bash", systemInfo: nil, homeDirectory: "/home/test"),
.promptReady,
], session: 1)
#expect(await eventually { workspace.canGenerateFromCurrentDraft })
#expect(!history.entries().contains { $0.command == "stale" })
}
@Test func disabledIntegrationIsPassedToTheTransportAndRemainsUnavailable() async throws {
let (host, library) = try commandFixture()
let connections = MobileSSHCommandConnectionBox()
let settings = TerminalSettingsModel()
#expect(settings.set(false, for: \.shellIntegration))
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library,
terminalSettings: settings,
connectionServices: connections.services
)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
await connections.sendShellEvents([
.available(shell: "bash", systemInfo: nil, homeDirectory: "/home/test"),
.promptReady,
])
#expect(await eventually { connections.shellIntegrationPreferences == [false] })
#expect(!workspace.canGenerateFromCurrentDraft)
workspace.receiveWorkingDirectory("/srv/app")
#expect(workspace.sameDirectoryLaunch == nil)
}
@Test func unsupportedShellRemainsUnavailableAfterOrderedPromptEvents() async throws {
let (host, library) = try commandFixture()
let connections = MobileSSHCommandConnectionBox()
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library,
connectionServices: connections.services
)
let history = workspace.commandHistory
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
workspace.receiveWorkingDirectory("/srv/app")
await connections.sendShellEvents([
.available(shell: "tcsh", systemInfo: nil, homeDirectory: "/home/test"),
.promptReady,
.commandStarted("echo marker"),
.promptReady,
])
#expect(await eventually { history.entries().count == 1 })
#expect(!workspace.canGenerateFromCurrentDraft)
let generator = MobileCommandGeneratorStub(command: "echo generated")
workspace.generateCommand(using: generator)
guard case .failed = workspace.commandGeneratorState else {
Issue.record("Expected an unsupported-shell failure")
return
}
#expect(generator.contexts.isEmpty)
#expect(connections.writes.isEmpty)
}
private func commandFixture() throws -> (Host, HostLibraryModel) {
let identity = Identity(name: "Deploy", username: "deploy")
var host = Host(name: "Example", host: "example.invalid")
host.identityID = identity.id
let library = HostLibraryModel(
hosts: [host],
identities: [identity],
knownHosts: [
KnownHostEntry(
hostname: host.host,
port: host.port,
algorithm: .ed25519,
fingerprint: "SHA256:test"
)
]
)
try library.upsert(identity, passwordUpdate: .replace("test-only"))
return (host, library)
}
}
@Suite(.serialized) @MainActor
struct MobileUploadBatchTests {
@Test func usernameOnlyTerminalUploadRejectsInteractionWithoutOpeningASheet() async throws {
let identity = Identity(name: "OTP", username: "user")
let host = Host(name: "OTP", host: "example.invalid", identityID: identity.id)
let library = HostLibraryModel(hosts: [host], identities: [identity])
let client = MobileUploadSFTPClient()
let transfers = SFTPTransferCoordinator(store: library) { client }
let services = MobileWorkspaceConnectionServices(
makePTYChannel: { SSHPTYChannel(output: $0, exit: $1, shellIntegration: $2) },
connectSSH: { _, _, _, _, _, _ in },
connectSFTP: { request, _, _ in
#expect(request.interactionPolicy == .forbidden)
#expect(request.configuration.credentials.isEmpty)
let handler = try #require(request.enforcingInteractionPolicy().keyboardInteractive)
_ = try await handler(
SSHKeyboardInteractiveChallenge(
connectionID: UUID(), hostID: host.id, hostname: host.host, port: 22,
username: "user", round: 1, name: "OTP", instruction: "",
prompts: [.init(text: "Code", echo: false)]))
return client
}, writeSSH: { _, _ in }
)
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library, transfers: transfers, connectionServices: services)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
workspace.receiveWorkingDirectory("/srv/current")
#expect(workspace.uploadTargetProblem == nil)
#expect(workspace.prepareUpload([URL(fileURLWithPath: "/tmp/termind-test-upload")]))
#expect(await eventually { workspace.uploadNotice?.isRejected == true })
#expect(workspace.operationError == nil)
#expect(workspace.keyboardAuthentication.current == nil)
#expect(transfers.sftpTransfers.isEmpty)
}
@Test(arguments: [UploadFailureStage.connection, .localFile, .preparation, .enqueue])
private func terminalUploadFailuresUseNoticesAndReleaseTheClient(_ stage: UploadFailureStage) async throws {
let client = MobileUploadSFTPClient(failureStage: stage)
let workspace = uploadWorkspace(client: client) {
if stage == .connection { throw URLError(.cannotConnectToHost) }
return client
}
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
workspace.receiveWorkingDirectory("/srv/current")
let source = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
defer { try? FileManager.default.removeItem(at: source) }
if stage == .enqueue {
try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true)
} else if stage != .localFile {
try Data("upload".utf8).write(to: source)
}
#expect(workspace.prepareUpload([source]))
#expect(await eventually { workspace.uploadNotice?.isRejected == true })
#expect(workspace.operationError == nil)
#expect(workspace.transfers?.sftpTransfers.isEmpty == true)
#expect(!workspace.hasPendingTransferConflict)
if stage != .connection { #expect(await client.disconnectCount == 1) }
}
@Test(arguments: [false, true])
func conflictDismissalCancelsOnlyAnUnconsumedRequest(_ confirms: Bool) async throws {
let remote = RemoteFile(
path: "/home/test/report.txt", name: "report.txt", kind: .file, size: 0,
permissions: 0o644, ownerUID: 501, ownerGID: 20)
let client = MobileUploadSFTPClient(attributes: [remote.path: remote])
let workspace = uploadWorkspace(client: client, kind: .sftp)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let file = directory.appending(path: "report.txt")
try Data("report".utf8).write(to: file)
#expect(workspace.prepareUpload([file]))
#expect(await eventually { workspace.hasPendingTransferConflict })
let request = try #require(workspace.transferConflictID)
workspace.dismissTransferConflict(request)
if confirms { workspace.enqueuePendingTransfer(.replace) }
#expect(await eventually { !workspace.hasPendingTransferConflict })
if confirms {
#expect(await eventually { workspace.transfers?.sftpTransfers.first?.state == .completed })
#expect(await client.uploadedData[remote.path] == Data("report".utf8))
} else {
#expect(workspace.transfers?.sftpTransfers.isEmpty == true)
}
#expect(workspace.prepareUpload([file]))
#expect(await eventually { workspace.hasPendingTransferConflict })
let replacement = try #require(workspace.transferConflictID)
#expect(replacement != request)
workspace.dismissTransferConflict(request)
await withCheckedContinuation { continuation in
DispatchQueue.main.async { continuation.resume() }
}
#expect(workspace.transferConflictID == replacement)
workspace.cancelPendingUpload()
}
@Test func browserUploadFailuresKeepTheirOperationAlert() async throws {
let client = MobileUploadSFTPClient()
let workspace = uploadWorkspace(client: client, kind: .sftp)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
let missing = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
#expect(workspace.prepareUpload([missing]))
#expect(await eventually { workspace.operationError != nil })
#expect(workspace.uploadNotice == nil)
#expect(await client.disconnectCount == 0)
workspace.reportUploadFailure("Provider failed")
#expect(workspace.operationError == "Provider failed")
#expect(workspace.uploadNotice == nil)
}
@Test func partialFileProviderFailureStaysVisibleWhileReadableFilesAreQueued() async throws {
let client = MobileUploadSFTPClient()
let workspace = uploadWorkspace(client: client)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
workspace.receiveWorkingDirectory("/srv/current")
let source = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try Data("readable".utf8).write(to: source)
defer { try? FileManager.default.removeItem(at: source) }
let good = NSItemProvider()
good.suggestedName = "good.txt"
good.registerFileRepresentation(
forTypeIdentifier: UTType.plainText.identifier, fileOptions: [], visibility: .all
) { completion in
completion(source, false, nil)
return nil
}
let bad = NSItemProvider()
bad.suggestedName = "bad.txt"
bad.registerFileRepresentation(
forTypeIdentifier: UTType.plainText.identifier, fileOptions: [], visibility: .all
) { completion in
completion(nil, false, CocoaError(.fileReadNoPermission))
return nil
}
let payload = await MobileDropPayloadLoader.load([good, bad], kinds: [.files], fileMode: .staged)
defer {
if let root = payload.stagedRoot { try? FileManager.default.removeItem(at: root) }
}
let error = try #require(payload.failures.first)
#expect(workspace.prepareUpload(payload.fileURLs))
workspace.reportUploadFailure(error)
#expect(await eventuallyAsync { await client.disconnectCount == 1 })
#expect(workspace.transfers?.sftpTransfers.count == 1)
#expect(workspace.uploadNotice == .rejected(error))
#expect(workspace.operationError == nil)
// A new batch replaces the partial-failure notice and restores the ordinary success timer.
#expect(workspace.prepareUpload([source]))
#expect(workspace.uploadNotice == .preparing("/srv/current"))
#expect(await eventually { workspace.uploadNotice == .queued(1, "/srv/current") })
try await Task.sleep(for: .milliseconds(2300))
#expect(workspace.uploadNotice == nil)
}
@Test func repeatedFailuresRestartTheThreeSecondDismissalTimer() async throws {
let workspace = uploadWorkspace(client: MobileUploadSFTPClient())
defer { workspace.disconnect() }
workspace.reportUploadFailure("Unreadable file")
try await Task.sleep(for: .milliseconds(1600))
workspace.reportUploadFailure("Unreadable file")
try await Task.sleep(for: .milliseconds(1600))
#expect(workspace.uploadNotice == .rejected("Unreadable file"))
try await Task.sleep(for: .milliseconds(1700))
#expect(workspace.uploadNotice == nil)
#expect(workspace.operationError == nil)
}
@Test(arguments: [false, true])
func cancelledOpeningCannotReplaceANewerDropFailure(_ lateFailure: Bool) async throws {
let client = MobileUploadSFTPClient()
let opening = MobileUploadOpeningGate()
let workspace = uploadWorkspace(client: client) {
if opening.hasStarted { throw URLError(.timedOut) }
return try await opening.open()
}
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
workspace.receiveWorkingDirectory("/srv/current")
let source = URL(fileURLWithPath: "/tmp/unused-upload")
#expect(workspace.prepareUpload([source]))
#expect(await eventually { opening.hasStarted })
workspace.cancelPendingUpload()
#expect(workspace.uploadNotice == nil)
#expect(workspace.operationError == nil)
#expect(workspace.prepareUpload([source]))
#expect(await eventually { workspace.uploadNotice?.isRejected == true })
let notice = workspace.uploadNotice
opening.finish(lateFailure ? .failure(SSHInteractionRequired()) : .success(client))
#expect(await eventually { opening.hasFinished })
if !lateFailure { #expect(await eventuallyAsync { await client.disconnectCount == 1 }) }
#expect(workspace.uploadNotice == notice)
#expect(workspace.operationError == nil)
#expect(workspace.transfers?.sftpTransfers.isEmpty == true)
workspace.cancelPendingUpload()
#expect(workspace.uploadNotice == nil)
}
@Test(arguments: [ConnectionHistoryEntry.Kind.ssh, .sftp])
func transferRoutesRetainTheRemoteHomeAfterWorkspaceClosure(_ kind: ConnectionHistoryEntry.Kind) async throws {
let workspace = uploadWorkspace(client: MobileUploadSFTPClient(), kind: kind)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
if kind == .ssh { workspace.receiveWorkingDirectory("/home/test") }
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let file = directory.appending(path: "report.txt")
try Data("report".utf8).write(to: file)
#expect(workspace.prepareUpload([file]))
let transfers = try #require(workspace.transfers)
#expect(await eventually { transfers.sftpTransfers.first?.state == .completed })
workspace.disconnect()
let transfer = try #require(transfers.sftpTransfers.first)
#expect(transfer.destinationHomeDirectory == "/home/test")
#expect(transfer.routeLabel { _ in "Upload" } == "Upload · ~/report.txt")
#expect(workspace.sftpChannel.homeDirectory == nil)
}
@Test func downloadsAndRemoteCopiesRetainResolvedHomeDirectories() async throws {
let workspace = uploadWorkspace(client: MobileUploadSFTPClient(), kind: .sftp)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
let transfers = try #require(workspace.transfers)
let file = RemoteFile(
path: "/home/test/report.txt", name: "report.txt", kind: .file, size: 0,
permissions: 0o644, ownerUID: 501, ownerGID: 20)
let destination = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: destination) }
workspace.prepareDownload(file, to: destination)
#expect(await eventually { transfers.sftpTransfers.first?.state == .completed })
let download = try #require(transfers.sftpTransfers.first)
#expect(download.sourceHomeDirectory == "/home/test")
#expect(download.routeLabel { _ in "Upload" } == "Upload · ~/report.txt")
let source = try #require(workspace.remoteCopySource(for: file))
let payload = transfers.registerRemoteCopySource(source)
await workspace.sftpChannel.openDirectory("/home/test/archive")
workspace.prepareRemoteCopy(payload)
#expect(await eventually { transfers.sftpTransfers.contains { $0.direction == .copy } })
let copy = try #require(transfers.sftpTransfers.first { $0.direction == .copy })
#expect(copy.sourceHomeDirectory == "/home/test")
#expect(copy.destinationHomeDirectory == "/home/test")
#expect(copy.routeLabel { _ in "Upload" } == "Upload · ~/report.txt → Upload · ~/archive/report.txt")
transfers.cancelAll()
await transfers.waitForCancellation()
}
@Test func unavailableHomeDoesNotPreventConnectingToAnExplicitDirectory() async throws {
let workspace = uploadWorkspace(
client: MobileUploadSFTPClient(homeDirectory: nil), kind: .sftp, initialPath: "/srv")
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
#expect(workspace.sftpChannel.path == "/srv")
#expect(workspace.sftpChannel.homeDirectory == nil)
}
private func uploadWorkspace(
client: MobileUploadSFTPClient,
kind: ConnectionHistoryEntry.Kind = .ssh,
initialPath: String = "",
connectionOrigin: RemoteConnectionOrigin = .library,
open: (() async throws -> any SFTPClientProtocol)? = nil
) -> MobileWorkspace {
let identity = Identity(name: "Upload", username: "user")
let host = Host(
name: "Upload", host: "example.invalid", identityID: identity.id, sftpChannelInitialPath: initialPath)
let library = HostLibraryModel(hosts: [host], identities: [identity])
let transfers = SFTPTransferCoordinator(store: library) { client }
let services = MobileWorkspaceConnectionServices(
makePTYChannel: { SSHPTYChannel(output: $0, exit: $1, shellIntegration: $2) },
connectSSH: { _, _, _, _, _, _ in },
connectSFTP: { _, _, _ in
if let open { return try await open() }
return client
}, writeSSH: { _, _ in })
return MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: kind),
hosts: library, transfers: transfers, connectionServices: services, connectionOrigin: connectionOrigin)
}
@Test func serialUploadSurvivesClosingTheSFTPWorkspace() async throws {
let identity = Identity(name: "OTP", username: "user")
let host = Host(name: "OTP", host: "example.invalid", identityID: identity.id)
let library = HostLibraryModel(hosts: [host], identities: [identity])
let primary = MobileUploadSFTPClient(blocksUpload: true)
let extra = MobileUploadSFTPClient(requiresInteraction: true)
let transfers = SFTPTransferCoordinator(store: library) { extra }
let services = MobileWorkspaceConnectionServices(
makePTYChannel: { SSHPTYChannel(output: $0, exit: $1, shellIntegration: $2) },
connectSSH: { _, _, _, _, _, _ in }, connectSFTP: { _, _, _ in primary }, writeSSH: { _, _ in })
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .sftp),
hosts: library, transfers: transfers, connectionServices: services)
workspace.connect()
#expect(await eventually { workspace.connected })
let file = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try Data("test".utf8).write(to: file)
defer { try? FileManager.default.removeItem(at: file) }
#expect(workspace.prepareUpload([file]))
#expect(await eventuallyAsync { await primary.startedUploads == 1 })
workspace.disconnect()
#expect(await primary.disconnectCount == 0)
await primary.finishUploads()
#expect(await eventually { transfers.sftpTransfers.allSatisfy { $0.state == .completed } })
#expect(await eventuallyAsync { await primary.disconnectCount == 1 })
}
@Test func sshUploadUsesTheReportedWorkingDirectoryAndReleasesPreflightClient() async throws {
let identity = Identity(name: "Deploy", username: "deploy")
var host = Host(name: "Example", host: "example.invalid")
host.identityID = identity.id
let library = HostLibraryModel(
hosts: [host],
identities: [identity],
knownHosts: [
KnownHostEntry(
hostname: host.host,
port: host.port,
algorithm: .ed25519,
fingerprint: "SHA256:test"
)
]
)
try library.upsert(identity, passwordUpdate: .replace("test-only"))
let preflightClient = MobileUploadSFTPClient()
let transferClient = MobileUploadSFTPClient()
let transfers = SFTPTransferCoordinator(store: library) { transferClient }
let services = MobileWorkspaceConnectionServices(
makePTYChannel: { SSHPTYChannel(output: $0, exit: $1, shellIntegration: $2) },
connectSSH: { _, _, _, _, _, _ in },
connectSFTP: { request, _, _ in
#expect(request.interactionPolicy == .forbidden)
return preflightClient
},
writeSSH: { _, _ in }
)
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library,
transfers: transfers,
connectionServices: services
)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
workspace.receiveWorkingDirectory("/srv/current")
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let file = directory.appending(path: "report.txt")
try Data("report".utf8).write(to: file)
#expect(workspace.prepareUpload([file]))
#expect(await eventually { transfers.sftpTransfers.count == 1 })
#expect(transfers.sftpTransfers.first?.remotePath == "/srv/current/report.txt")
#expect(await eventuallyAsync { await preflightClient.disconnectCount == 1 })
}
@Test func terminalBatchUploadsFiveFilesConcurrentlyAndKeepsQueuedFilesAfterClosure() async throws {
let identity = Identity(name: "Upload", username: "user")
let host = Host(name: "Concurrent", host: "parallel.invalid", identityID: identity.id)
let library = HostLibraryModel(hosts: [host], identities: [identity])
let clients = MobileParallelUploadClients()
let primary = clients.makeClient()
let transfers = SFTPTransferCoordinator(
store: library, clientFactory: { clients.makeClient() }, globalLimit: 15, perHostLimit: 5,
strategy: .connectionsPreferred)
let services = MobileWorkspaceConnectionServices(
makePTYChannel: { SSHPTYChannel(output: $0, exit: $1, shellIntegration: $2) },
connectSSH: { _, _, _, _, _, _ in },
connectSFTP: { request, _, _ in
#expect(request.interactionPolicy == .forbidden)
return primary
}, writeSSH: { _, _ in })
let workspace = MobileWorkspace(
descriptor: .init(hostID: host.id, title: host.name, kind: .ssh), hosts: library,
transfers: transfers, connectionServices: services)
workspace.connect()
#expect(await eventually { workspace.connected })
workspace.receiveWorkingDirectory("/srv/current")
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let files = try (0..<7).map { index in
let file = directory.appending(path: "file-\(index)")
try Data([1]).write(to: file)
return file
}
#expect(workspace.prepareUpload(files))
#expect(await eventuallyAsync { await clients.startedUploads() == 5 })
#expect(transfers.activeCount == 5)
#expect(clients.snapshot.count == 5)
#expect(await primary.startedUploads == 1)
workspace.disconnect()
#expect(await primary.disconnectCount == 0)
await primary.finishUploads()
#expect(await eventuallyAsync { await clients.startedUploads() == 6 })
#expect(transfers.activeCount == 5)
for _ in 0..<100 {
await clients.finishUploads()
if transfers.activeCount == 0 { break }
try await Task.sleep(for: .milliseconds(10))
}
#expect(transfers.sftpTransfers.count == 7)
#expect(transfers.sftpTransfers.allSatisfy { $0.state == .completed })
#expect(await primary.disconnectCount == 1)
}
@Test func sftpFolderUploadUsesTheCurrentDirectoryAndOneBatch() async throws {
let identity = Identity(name: "Deploy", username: "deploy")
var host = Host(name: "Example", host: "example.invalid")
host.identityID = identity.id
let library = HostLibraryModel(
hosts: [host],
identities: [identity],
knownHosts: [
KnownHostEntry(
hostname: host.host,
port: host.port,
algorithm: .ed25519,
fingerprint: "SHA256:test"
)
]
)
try library.upsert(identity, passwordUpdate: .replace("test-only"))
let client = MobileUploadSFTPClient()
let transfers = SFTPTransferCoordinator(store: library) { client }
let services = MobileWorkspaceConnectionServices(
makePTYChannel: { SSHPTYChannel(output: $0, exit: $1, shellIntegration: $2) },
connectSSH: { _, _, _, _, _, _ in },
connectSFTP: { _, _, _ in client },
writeSSH: { _, _ in }
)
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .sftp),
hosts: library,
transfers: transfers,
connectionServices: services
)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
await workspace.sftpChannel.openDirectory("/srv/incoming")
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
let folder = root.appending(path: "bundle", directoryHint: .isDirectory)
let nested = folder.appending(path: "nested", directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
try Data("payload".utf8).write(to: nested.appending(path: "payload.txt"))
let looseFile = root.appending(path: "README.md")
try Data("readme".utf8).write(to: looseFile)
let providers = [(folder, UTType.folder), (looseFile, UTType.plainText)].map { url, type in
let provider = NSItemProvider()
provider.suggestedName = url.lastPathComponent
provider.registerFileRepresentation(forTypeIdentifier: type.identifier, fileOptions: [], visibility: .all) {
@Sendable completion in
completion(url, false, nil)
return nil
}
return provider
}
let payload = await MobileDropPayloadLoader.load(providers, kinds: [.files], fileMode: .staged)
defer { if let root = payload.stagedRoot { try? FileManager.default.removeItem(at: root) } }
#expect(payload.failures.isEmpty)
try FileManager.default.removeItem(at: root)
#expect(workspace.prepareUpload(payload.fileURLs))
#expect(await eventually { transfers.sftpTransfers.count == 2 })
#expect(
Set(transfers.sftpTransfers.map(\.remotePath)) == [
"/srv/incoming/README.md",
"/srv/incoming/bundle/nested/payload.txt",
]
)
#expect(Set(transfers.sftpTransfers.map(\.batchID)).count == 1)
#expect(await eventuallyAsync { await client.uploadedData.count == 2 })
#expect(
await client.uploadedData == [
"/srv/incoming/README.md": Data("readme".utf8),
"/srv/incoming/bundle/nested/payload.txt": Data("payload".utf8),
])
#expect(workspace.uploadNotice == .queued(2, "/srv/incoming"))
#expect(
await client.createdDirectories == [
"/srv/incoming/bundle",
"/srv/incoming/bundle/nested",
]
)
}
@Test(arguments: [false, true])
func successfulFileDownloadsRefreshHistoryWhileInvalidAndFailedFilesDoNot(external: Bool) async throws {
let identity = Identity(name: "Deploy", username: "deploy")
var host = Host(name: "Example", host: "example.invalid")
host.identityID = identity.id
let library = HostLibraryModel(
hosts: [host],
identities: [identity],
knownHosts: [
KnownHostEntry(
hostname: host.host,
port: host.port,
algorithm: .ed25519,
fingerprint: "SHA256:test"
)
]
)
try library.upsert(identity, passwordUpdate: .replace("test-only"))
let report = RemoteFile(
path: "/srv/report.txt", name: "report.txt", kind: .file, size: 4,
permissions: 0o644, ownerUID: 501, ownerGID: 20)
let broken = RemoteFile(
path: "/srv/broken.txt", name: "broken.txt", kind: .file, size: 4,
permissions: 0o644, ownerUID: 501, ownerGID: 20)
let changedDirectory = RemoteFile(
path: "/srv/changed", name: "changed", kind: .directory, size: 0,
permissions: 0o755, ownerUID: 501, ownerGID: 20)
let sessionClient = MobileUploadSFTPClient(
attributes: [
report.path: report,
broken.path: broken,
changedDirectory.path: changedDirectory,
],
failedDownloadPaths: [broken.path])
let transferClient = MobileUploadSFTPClient(failedDownloadPaths: [broken.path])
let transfers = SFTPTransferCoordinator(store: library) { transferClient }
let services = MobileWorkspaceConnectionServices(
makePTYChannel: { SSHPTYChannel(output: $0, exit: $1, shellIntegration: $2) },
connectSSH: { _, _, _, _, _, _ in },
connectSFTP: { _, _, _ in sessionClient },
writeSSH: { _, _ in }
)
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .sftp),
hosts: library,
transfers: transfers,
connectionServices: services,
connectionOrigin: external ? .externalLink(hostKeyConstraints: [:]) : .library
)
let history = workspace.sftpHistory
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
#expect(await workspace.reopenHistoryFile(at: report.path))
#expect(
await eventually {
history.entries().contains { $0.kind == .file && $0.path == report.path }
})
#expect(await workspace.reopenHistoryFile(at: broken.path))
#expect(
await eventually {
guard let state = transfers.sftpTransfers.first(where: { $0.remotePath == broken.path })?.state
else { return false }
if case .failed = state { return true }
return false
})
#expect(!history.entries().contains { $0.path == broken.path })
#expect(transfers.sftpTransfers.allSatisfy { $0.sourceHomeDirectory == "/home/test" })
#expect(!(await workspace.reopenHistoryFile(at: "/srv/missing.txt")))
#expect(!(await workspace.reopenHistoryFile(at: changedDirectory.path)))
#expect(!history.entries().contains { $0.path == changedDirectory.path })
}
@Test func closingTemporaryWorkspacePreventsLatePreviewFromRestoringHistory() async throws {
let client = MobileUploadSFTPClient(blocksDownload: true)
let workspace = uploadWorkspace(
client: client, kind: .sftp, connectionOrigin: .externalLink(hostKeyConstraints: [:]))
workspace.connect()
#expect(await eventually { workspace.connected })
let file = RemoteFile(
path: "/report.txt", name: "report.txt", kind: .file, size: 4,
permissions: 0o644, ownerUID: 501, ownerGID: 20)
var completed = false
#expect(workspace.preview(file) { _ in completed = true })
#expect(await eventuallyAsync { await client.startedDownloads == 1 })
workspace.close()
await client.finishDownloads()
#expect(await eventually { completed })
#expect(workspace.sftpHistory.entries().isEmpty)
}
@Test func sharedUploadPlannerDoesNotFollowASymlinkOutsideTheSelectedFolder() async throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
let upload = root.appending(path: "upload", directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: upload, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
try Data("inside".utf8).write(to: upload.appending(path: "inside.txt"))
let outside = root.appending(path: "outside.txt")
try Data("outside".utf8).write(to: outside)
try FileManager.default.createSymbolicLink(
at: upload.appending(path: "escape.txt"),
withDestinationURL: outside
)
let client = MobileUploadSFTPClient()
let batch = try await SFTPLocalUploadPlanner.prepare(
[upload],
destination: "/incoming",
client: client
)
#expect(batch.transferCount == 1)
#expect(batch.rootCount == 1)
}
@Test func sharedUploadPlannerCreatesAnEmptyFolderWithoutInventingATransfer() async throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
let emptyFolder = root.appending(path: "empty", directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: emptyFolder, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let client = MobileUploadSFTPClient()
let hostID = UUID()
let configuration = SSHConnectionConfiguration(
hostID: hostID,
host: "example.invalid",
port: 22,
username: "deploy",
credentials: [],
expectedHostKeys: []
)
let coordinator = SFTPTransferCoordinator(store: HostLibraryModel()) { client }
let batch = try await SFTPLocalUploadPlanner.prepare(
[emptyFolder],
destination: "/incoming",
client: client
)
let result = try await SFTPLocalUploadPlanner.enqueue(
batch,
policy: .replace,
hostID: hostID,
connection: .saved(SSHAdditionalConnectionSnapshot(configuration: configuration)),
client: client,
coordinator: coordinator
)
#expect(batch.transferCount == 0)
#expect(!batch.isEmpty)
#expect(result.transferCount == 0)
#expect(result.changedRemoteDirectories)
#expect(await client.createdDirectories == ["/incoming/empty"])
#expect(coordinator.sftpTransfers.isEmpty)
}
@Test func sharedUploadReplaceRejectsAChildOutsideTheConflictingDirectory() async throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let local = root.appending(path: "payload.txt")
try Data("replacement".utf8).write(to: local)
let target = RemoteFile(
path: "/incoming/payload.txt", name: "payload.txt", kind: .directory, size: 0,
permissions: 0o755, ownerUID: 501, ownerGID: 20)
let escaped = RemoteFile(
path: "/outside/untouched.txt", name: "untouched.txt", kind: .file, size: 1,
permissions: 0o644, ownerUID: 501, ownerGID: 20)
let client = MobileUploadSFTPClient(attributes: [target.path: target], children: [target.path: [escaped]])
let coordinator = SFTPTransferCoordinator(store: HostLibraryModel()) { client }
let hostID = UUID()
let configuration = SSHConnectionConfiguration(
hostID: hostID, host: "example.invalid", port: 22, username: "deploy",
credentials: [], expectedHostKeys: [])
let batch = try await SFTPLocalUploadPlanner.prepare([local], destination: "/incoming", client: client)
#expect(batch.conflictCount == 1)
await #expect(throws: (any Error).self) {
try await SFTPLocalUploadPlanner.enqueue(
batch, policy: .replace, hostID: hostID,
connection: .saved(SSHAdditionalConnectionSnapshot(configuration: configuration)),
client: client, coordinator: coordinator)
}
#expect(await client.listedDirectories == [target.path])
#expect(await client.removedPaths.isEmpty)
#expect(coordinator.sftpTransfers.isEmpty)
}
@Test func cancelledUploadDoesNotCreateRemoteDirectories() async throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
let folder = root.appending(path: "empty", directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let client = MobileUploadSFTPClient()
let coordinator = SFTPTransferCoordinator(store: HostLibraryModel()) { client }
let hostID = UUID()
let configuration = SSHConnectionConfiguration(
hostID: hostID, host: "example.invalid", port: 22, username: "deploy",
credentials: [], expectedHostKeys: [])
let batch = try await SFTPLocalUploadPlanner.prepare([folder], destination: "/incoming", client: client)
let task = Task { @MainActor in
withUnsafeCurrentTask { $0?.cancel() }
return try await SFTPLocalUploadPlanner.enqueue(
batch, policy: .replace, hostID: hostID,
connection: .saved(SSHAdditionalConnectionSnapshot(configuration: configuration)),
client: client, coordinator: coordinator)
}
await #expect(throws: CancellationError.self) { try await task.value }
#expect(await client.createdDirectories.isEmpty)
#expect(coordinator.sftpTransfers.isEmpty)
}
private func eventuallyAsync(_ condition: @escaping () async -> Bool) async -> Bool {
for _ in 0..<100 {
if await condition() { return true }
try? await Task.sleep(for: .milliseconds(10))
}
return false
}
}
private nonisolated enum UploadFailureStage: Sendable {
case connection, localFile, preparation, enqueue
}
@MainActor
private final class MobileUploadOpeningGate {
private var continuation: CheckedContinuation<any SFTPClientProtocol, any Error>?
private(set) var hasStarted = false
private(set) var hasFinished = false
func open() async throws -> any SFTPClientProtocol {
defer { hasFinished = true }
return try await withCheckedThrowingContinuation {
continuation = $0
hasStarted = true
}
}
func finish(_ result: Result<any SFTPClientProtocol, any Error>) {
continuation?.resume(with: result)
continuation = nil
}
}
private actor MobileUploadSFTPClient: SFTPClientProtocol {
private(set) var uploadedData: [String: Data] = [:]
private(set) var createdDirectories: [String] = []
private(set) var listedDirectories: [String] = []
private(set) var removedPaths: [String] = []
private(set) var disconnectCount = 0
private(set) var startedUploads = 0
private(set) var startedDownloads = 0
private let resolvedHomeDirectory: String?
private let blocksUpload: Bool
private let blocksDownload: Bool
private let requiresInteraction: Bool
private let failureStage: UploadFailureStage?
private var uploadWaiters: [CheckedContinuation<Void, Never>] = []
private var downloadWaiters: [CheckedContinuation<Void, Never>] = []
private let attributes: [String: RemoteFile]
private let failedDownloadPaths: Set<String>
private let children: [String: [RemoteFile]]
init(
homeDirectory: String? = "/home/test",
attributes: [String: RemoteFile] = [:],
failedDownloadPaths: Set<String> = [],
children: [String: [RemoteFile]] = [:],
blocksUpload: Bool = false, blocksDownload: Bool = false, requiresInteraction: Bool = false,
failureStage: UploadFailureStage? = nil
) {
self.resolvedHomeDirectory = homeDirectory
self.blocksUpload = blocksUpload
self.blocksDownload = blocksDownload
self.requiresInteraction = requiresInteraction
self.failureStage = failureStage
self.attributes = attributes
self.failedDownloadPaths = failedDownloadPaths
self.children = children
}
func connect(using configuration: SSHConnectionConfiguration, progress: SSHRouteProgressHandler?) async throws {
if requiresInteraction { throw SSHInteractionRequired() }
}
func finishUploads() {
let pending = uploadWaiters
uploadWaiters.removeAll()
for waiter in pending { waiter.resume() }
}
func finishDownloads() {
let pending = downloadWaiters
downloadWaiters.removeAll()
for waiter in pending { waiter.resume() }
}
func disconnect() async { disconnectCount += 1 }
func homeDirectory() async throws -> String {
guard let resolvedHomeDirectory else { throw SFTPError.operation("Home unavailable") }
return resolvedHomeDirectory
}
func listDirectory(at path: String) async throws -> [RemoteFile] {
listedDirectories.append(path)
return children[path, default: []]
}
func attributes(at path: String) async throws -> RemoteFile? {
if failureStage == .preparation { throw SFTPError.operation("Cannot inspect upload destination") }
return attributes[path]
}
func setPermissions(at path: String, to permissions: UInt32) async throws {}
func createDirectory(at path: String) async throws {
if failureStage == .enqueue { throw SFTPError.operation("Cannot create upload directory") }
createdDirectories.append(path)
}
func rename(from source: String, to destination: String, overwrite: Bool) async throws {}
func removeFile(at path: String) async throws { removedPaths.append(path) }
func removeDirectory(at path: String) async throws { removedPaths.append(path) }
func uploadFile(
from localURL: URL,
to remotePath: String,
overwrite: Bool,
cancellation: TransferCancellationToken,
progress: @escaping @Sendable (SFTPProgress) -> Void
) async throws {
startedUploads += 1
if blocksUpload { await withCheckedContinuation { uploadWaiters.append($0) } }
uploadedData[remotePath] = try Data(contentsOf: localURL)
}
func downloadFile(
from remotePath: String,
to localURL: URL,
overwrite: Bool,
cancellation: TransferCancellationToken,
progress: @escaping @Sendable (SFTPProgress) -> Void
) async throws {
startedDownloads += 1
if blocksDownload { await withCheckedContinuation { downloadWaiters.append($0) } }
if failedDownloadPaths.contains(remotePath) {
throw SFTPError.operation("Download failed")
}
}
}
private nonisolated final class MobileParallelUploadClients: @unchecked Sendable {
private let lock = NSLock()
private var clients: [MobileUploadSFTPClient] = []
var snapshot: [MobileUploadSFTPClient] { lock.withLock { clients } }
func makeClient() -> MobileUploadSFTPClient {
let client = MobileUploadSFTPClient(blocksUpload: true)
lock.withLock { clients.append(client) }
return client
}
func startedUploads() async -> Int {
var count = 0
for client in snapshot { count += await client.startedUploads }
return count
}
func finishUploads() async {
for client in snapshot { await client.finishUploads() }
}
}