// 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, 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? 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) { 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] = [] private var downloadWaiters: [CheckedContinuation] = [] private let attributes: [String: RemoteFile] private let failedDownloadPaths: Set private let children: [String: [RemoteFile]] init( homeDirectory: String? = "/home/test", attributes: [String: RemoteFile] = [:], failedDownloadPaths: Set = [], 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() } } }