Files
Termind/TFITests/MobileAppTests.swift
T

1820 lines
83 KiB
Swift

import Foundation
import GhosttyTerminal
import SwiftData
import SwiftUI
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPersistence
import TermindPresentation
import TermindRemoteInfrastructure
import Testing
import UIKit
@testable import TermindIOS
@Suite @MainActor
struct MobileAppTests {
private final class MetricsPreferences: MetricsConfigurationStoring {
var values: [MetricsSelection] = []
func load() -> [MetricsSelection] { values }
func save(_ selections: [MetricsSelection]) { values = selections }
}
@Test func startupUsesTheSharedSchemaAndEmptyRepository() throws {
let app = try MobileAppModel(inMemory: true)
#expect(try app.hosts.snapshot().value == .empty)
#expect(Set(app.container.schema.entities.map(\.name)) == Set(TermindPersistence.schema.entities.map(\.name)))
let features = MobileFeatureRegistry.standard()
#expect(features.registrations.map(\.id) == [.vault, .metrics, .workspaces, .assistant, .settings])
}
@Test func scenesCanUseTheSameAppRepository() throws {
let repository = InMemoryHostRepository()
let app = try MobileAppModel(hosts: repository)
let host = TermindDomain.Host(name: "Example", host: "example.invalid")
try repository.upsertHost(host)
#expect(try app.hosts.snapshot().value.hosts == [host])
}
@Test func assistantNavigationAndDraftsAreSceneLocalWhileConversationsAreShared() throws {
let app = try MobileAppModel(inMemory: true)
let first = MobileSceneModel(app: app)
let second = MobileSceneModel(app: app)
first.assistant.conversation.composer.draft = "first draft"
second.assistant.conversation.composer.draft = "second draft"
let conversation = ConversationModel(messages: [ChatMessage(role: .user, text: "Shared")])
first.assistant.library.insertAndSelect(conversation)
#expect(second.assistant.library.conversation(id: conversation.id) === conversation)
#expect(second.assistant.library.selectedConversation == nil)
#expect(second.assistant.conversation.composer.draft == "second draft")
second.assistant.select(conversation)
first.assistant.newConversation()
#expect(second.assistant.conversation === conversation)
#expect(first.assistant.conversation !== conversation)
#expect(first.assistant.conversation.composer.draft.isEmpty)
}
@Test func historyModelsUseTheSharedSwiftDataContainer() throws {
let app = try MobileAppModel(inMemory: true)
let hostID = UUID()
app.commandHistory.record("echo persisted", for: hostID)
app.sftpHistory.record(kind: .directory, path: "/srv/app", for: hostID)
let commands = CommandHistoryModel(
repository: SwiftDataCommandHistoryRepository(context: ModelContext(app.container))
)
let files = SFTPHistoryModel(
repository: SwiftDataSFTPHistoryRepository(context: ModelContext(app.container))
)
#expect(commands.entries(for: hostID).map(\.command) == ["echo persisted"])
#expect(files.entries(for: hostID).map(\.path) == ["/srv/app"])
}
@Test func clearingRecentsPreservesCommandAndFileHistory() throws {
let app = try MobileAppModel(inMemory: true)
defer { app.outbox.stop() }
let host = TermindDomain.Host(name: "Recent", host: "recent.example.invalid")
app.library.upsert(host)
_ = app.connectionHistory.record(kind: .ssh, for: host.id)
app.commandHistory.record("pwd", for: host.id)
app.sftpHistory.record(kind: .directory, path: "/srv", for: host.id)
app.connectionHistory.clear(for: host.id)
#expect(app.connectionHistory.entries(for: host.id).isEmpty)
#expect(app.commandHistory.entries(for: host.id).map(\.command) == ["pwd"])
#expect(app.sftpHistory.entries(for: host.id).map(\.path) == ["/srv"])
}
}
@Suite @MainActor
struct MobileWorkspaceTests {
@Test func externalConnectionWorkspaceIsEphemeralAndDoesNotTouchVault() throws {
let app = try MobileAppModel(inMemory: true)
let request = try RemoteConnectionURLParser.parse(
#require(URL(string: "ssh://deploy@example.invalid:2222")))
let id = app.openExternalConnection(request)
let workspace = try #require(app.workspaces.first { $0.id == id })
#expect(workspace.isExternalTemporaryConnection)
#expect(workspace.descriptor.title == "example.invalid")
#expect(app.savedWorkspaceDescriptors.isEmpty)
#expect(app.library.hosts.isEmpty)
#expect(app.orderedWorkspaceItems.map(\.id) == [id])
#expect(app.orderedWorkspaceItems.first?.loginAddress == "deploy@example.invalid:2222")
let savedHost = TermindDomain.Host(name: "Saved", host: "saved.example.invalid")
app.library.upsert(savedHost)
let savedID = try #require(app.open(hostID: savedHost.id, kind: .ssh))
#expect(app.orderedWorkspaceItems.map(\.id) == [id, savedID])
app.close(id)
#expect(app.orderedWorkspaceItems.map(\.id) == [savedID])
app.close(savedID)
#expect(app.orderedWorkspaceItems.isEmpty)
}
@Test func liveActivitiesUseStableDistinctWorkspaceDestinations() {
let first = MobileWorkspaceDescriptor(hostID: UUID(), title: "Server", kind: .ssh)
var renamed = first
renamed.title = "Renamed"
let second = MobileWorkspaceDescriptor(hostID: first.hostID, title: "Server", kind: .sftp)
#expect(first.destinationURL == renamed.destinationURL)
#expect(first.destinationURL != second.destinationURL)
}
@Test func renamingUpdatesActivityStateWithoutChangingIdentity() throws {
let descriptor = MobileWorkspaceDescriptor(hostID: UUID(), title: "Production", kind: .ssh)
let workspace = MobileWorkspace(descriptor: descriptor, hosts: HostLibraryModel())
var activityTitle: String?
workspace.didChangeState = { activityTitle = $0.title }
#expect(!workspace.rename(to: " "))
#expect(workspace.rename(to: " Release Shell "))
#expect(workspace.id == descriptor.id)
#expect(workspace.descriptor.title == "Release Shell")
#expect(workspace.activitySession.title == "Release Shell")
#expect(activityTitle == "Release Shell")
#expect(!workspace.rename(to: "Release Shell"))
}
@Test func automaticTitlesUseVisibleNamesAcrossProtocolsAndAppendAfterTheMaximum() throws {
let app = try MobileAppModel(inMemory: true)
let host = TermindDomain.Host(name: "Server", host: "example.invalid")
app.library.upsert(host)
defer { app.closeAll() }
let firstID = try #require(app.open(hostID: host.id, kind: .ssh))
let secondID = try #require(
app.open(hostID: host.id, kind: .ssh, initialPath: "/srv/releases"))
let thirdID = try #require(
app.execute(Snippet(title: "Status", body: "uptime"), on: [host.id]).first)
let sftpID = try #require(
app.open(hostID: host.id, kind: .sftp, initialPath: "/srv/releases"))
let first = try #require(app.workspaces.first { $0.id == firstID })
let second = try #require(app.workspaces.first { $0.id == secondID })
let third = try #require(app.workspaces.first { $0.id == thirdID })
let sftp = try #require(app.workspaces.first { $0.id == sftpID })
#expect(
[first, second, third, sftp].map(\.descriptor.title)
== ["Server", "Server (1)", "Server (2)", "Server (3)"])
#expect(third.activitySession.title == "Server (2)")
#expect(app.renameWorkspace(secondID, to: "Logs"))
let fourthID = try #require(app.open(hostID: host.id, kind: .ssh))
let fourth = try #require(app.workspaces.first { $0.id == fourthID })
#expect(fourth.descriptor.title == "Server (4)")
app.close(firstID)
let recycledBaseID = try #require(app.open(hostID: host.id, kind: .ssh))
let recycledBase = try #require(app.workspaces.first { $0.id == recycledBaseID })
#expect(recycledBase.descriptor.title == "Server")
#expect(second.descriptor.title == "Logs")
#expect(third.descriptor.title == "Server (2)")
#expect(fourth.descriptor.title == "Server (4)")
app.close(secondID)
let recycledOneID = try #require(app.open(hostID: host.id, kind: .ssh))
let recycledOne = try #require(app.workspaces.first { $0.id == recycledOneID })
#expect(recycledOne.descriptor.title == "Server (5)")
#expect(app.workspaceSettings.setAutomaticRenaming(false))
let unnumberedID = try #require(app.open(hostID: host.id, kind: .sftp))
#expect(app.workspaces.first { $0.id == unnumberedID }?.descriptor.title == "Server")
}
@Test func hostNamesThatEndInNumbersDoNotConflictWithParenthesizedSuffixes() throws {
let app = try MobileAppModel(inMemory: true)
let server = TermindDomain.Host(name: "Server", host: "server.example")
let serverOne = TermindDomain.Host(name: "Server 1", host: "server-one.example")
app.library.upsert(server)
app.library.upsert(serverOne)
defer { app.closeAll() }
let ids = [
try #require(app.open(hostID: server.id, kind: .ssh)),
try #require(app.open(hostID: serverOne.id, kind: .sftp)),
try #require(app.open(hostID: server.id, kind: .sftp)),
try #require(app.open(hostID: serverOne.id, kind: .ssh)),
]
#expect(
ids.compactMap { id in app.workspaces.first { $0.id == id }?.descriptor.title }
== ["Server", "Server 1", "Server (1)", "Server 1 (1)"])
}
@Test func closeAllDisconnectsAndRemovesEveryWorkspaceAndIsRepeatable() throws {
let app = try MobileAppModel(inMemory: true)
let host = TermindDomain.Host(name: "Test", host: "example.invalid")
app.library.upsert(host)
_ = try #require(app.open(hostID: host.id, kind: .ssh))
_ = try #require(app.open(hostID: host.id, kind: .sftp))
let opened = app.workspaces
app.closeAll()
#expect(app.workspaces.isEmpty)
#expect(opened.allSatisfy { !$0.connected && $0.status == String(localized: "Disconnected") })
app.closeAll()
#expect(app.workspaces.isEmpty)
}
@Test func closeOtherWorkspacesKeepsTheTargetAndRemovesOpenedAndRecentWorkspaces() throws {
let suite = "termind.close-other-workspaces.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let seed = try MobileAppModel(inMemory: true)
let host = TermindDomain.Host(name: "Test", host: "example.invalid")
seed.library.upsert(host)
let descriptors = [
MobileWorkspaceDescriptor(hostID: host.id, title: "Keep", kind: .ssh),
MobileWorkspaceDescriptor(hostID: host.id, title: "Opened", kind: .sftp),
MobileWorkspaceDescriptor(hostID: host.id, title: "Recent", kind: .ssh),
]
let store = MobileWorkspaceStore(defaults: defaults)
try store.save(descriptors)
var dependencies = MobileAppDependencies.testing(defaults: defaults)
dependencies.container = seed.container
dependencies.workspaceStore = store
let app = try MobileAppModel(dependencies: dependencies)
let scene = MobileSceneModel(app: app)
let kept = try #require(app.openWorkspace(descriptors[0].id))
let opened = try #require(app.openWorkspace(descriptors[1].id))
#expect(app.recentWorkspaces == [descriptors[2]])
#expect(app.closableWorkspaceCount == 3)
#expect(scene.canCloseMultipleWorkspaces)
scene.closeOtherWorkspaces(than: kept.id)
#expect(app.workspaces.map(\.id) == [kept.id])
#expect(app.savedWorkspaceDescriptors == [kept.descriptor])
#expect(app.recentWorkspaces.isEmpty)
#expect(!opened.connected)
#expect(opened.status == String(localized: "Disconnected"))
#expect(try store.load() == [kept.descriptor])
#expect(!scene.canCloseMultipleWorkspaces)
app.closeAll()
}
@Test func hostEditsPersistThroughTheSharedVaultStore() throws {
let app = try MobileAppModel(inMemory: true)
let host = TermindDomain.Host(name: "Test", host: "example.invalid")
app.library.upsert(host)
#expect(app.library.storageError == nil)
let store = HostPersistence(context: ModelContext(app.container))
#expect(try store.load().hosts == app.library.hosts)
#expect(try store.load().hosts.first?.id == host.id)
}
@Test func freeMetricsLimitCountsAllSelectionsAndDeduplicates() {
let library = HostLibraryModel()
let hosts = (0..<5).map { TermindDomain.Host(name: "Host \($0)", host: "example.invalid") }
for host in hosts { library.upsert(host) }
let metrics = MobileMetricsModel(
preferences: MetricsPreferences(), hosts: library, services: .testing)
metrics.pause()
#expect(metrics.add(hosts[0].id, pro: false))
#expect(metrics.add(hosts[0].id, pro: false))
#expect(metrics.add(hosts[1].id, pro: false))
#expect(metrics.add(hosts[2].id, pro: false))
#expect(!metrics.add(hosts[3].id, pro: false))
#expect(metrics.add(hosts[4].id, pro: true))
#expect(metrics.selections.count == 4)
}
}
@Suite
struct MobileTerminalShiftStateTests {
@Test func controlShortcutsPreserveOrderAndKeyPresses() {
#expect(MobileTerminalControlShortcut.allCases.map(\.label) == ["^C", "^I", "^S", "^Z"])
#expect(
MobileTerminalControlShortcut.allCases.map(\.keyPress)
== [
TerminalKeyPress(.c, modifiers: .ctrl),
TerminalKeyPress(.i, modifiers: .ctrl),
TerminalKeyPress(.s, modifiers: .ctrl),
TerminalKeyPress(.z, modifiers: .ctrl),
])
}
@Test func armedShiftAppliesOnce() {
var state = MobileTerminalShiftState()
state.toggle(at: Date(timeIntervalSince1970: 1))
#expect(state.activation == .armed)
#expect(state.consumeForNextKey() == .shift)
#expect(state.activation == .inactive)
#expect(state.consumeForNextKey().isEmpty)
}
@Test func quickSecondTapLocksShiftUntilCancelled() {
var state = MobileTerminalShiftState()
state.toggle(at: Date(timeIntervalSince1970: 1))
state.toggle(at: Date(timeIntervalSince1970: 1.2))
#expect(state.activation == .locked)
#expect(state.consumeForNextKey() == .shift)
#expect(state.activation == .locked)
state.toggle(at: Date(timeIntervalSince1970: 2))
#expect(state.activation == .inactive)
}
@Test func slowSecondTapCancelsArmedShift() {
var state = MobileTerminalShiftState()
state.toggle(at: Date(timeIntervalSince1970: 1))
state.toggle(at: Date(timeIntervalSince1970: 2))
#expect(state.activation == .inactive)
}
}
@Suite @MainActor
struct MobileWorkspaceConnectionRecoveryTests {
@Test func sameDirectorySFTPStartupUsesTheOverrideAndOffersSSHOnlyWhileConnected() async throws {
let connections = MobileSFTPConnectionBox(clients: [MobileTestSFTPClient()])
let app = try MobileAppModel(
inMemory: true,
workspaceConnectionServices: connections.services
)
let host = try configureConnectionFixture(in: app)
let id = try #require(app.open(hostID: host.id, kind: .sftp, initialPath: "/srv/releases/"))
let workspace = try #require(app.workspaces.first(where: { $0.id == id }))
defer { app.close(id) }
#expect(workspace.sameDirectoryLaunch == nil)
#expect(await eventually { workspace.connected })
#expect(workspace.sftpChannel.path == "/srv/releases")
#expect(
workspace.sameDirectoryLaunch
== MobileWorkspaceDirectoryLaunch(hostID: host.id, kind: .ssh, path: "/srv/releases")
)
#expect(app.renameWorkspace(id, to: " Release Files "))
#expect(workspace.descriptor.title == "Release Files")
workspace.disconnect()
#expect(workspace.sameDirectoryLaunch == nil)
}
@Test func backgroundExpirationResumesEveryAffectedWorkspaceOnForeground() async throws {
let background = MobileTestBackgroundTaskManager()
let connections = MobileSFTPConnectionBox(
clients: [
MobileTestSFTPClient(), MobileTestSFTPClient(),
MobileTestSFTPClient(), MobileTestSFTPClient(),
])
let app = try MobileAppModel(
inMemory: true, background: background, workspaceConnectionServices: connections.services)
let host = try configureConnectionFixture(in: app)
_ = try #require(app.open(hostID: host.id, kind: .sftp))
_ = try #require(app.open(hostID: host.id, kind: .sftp))
defer { app.closeAll() }
#expect(await eventually { app.workspaces.allSatisfy(\.connected) })
let sceneID = UUID()
app.sceneChanged(sceneID, phase: .active)
app.sceneChanged(sceneID, phase: .background)
background.expire()
#expect(app.workspaces.allSatisfy { !$0.connected && $0.journey.isPresented })
#expect(app.workspaces.allSatisfy { $0.journey.mode == .reconnection })
#expect(connections.connectCount == 2)
app.sceneChanged(sceneID, phase: .active)
#expect(await eventually { app.workspaces.allSatisfy(\.connected) })
#expect(connections.connectCount == 4)
}
@Test func returningBeforeBackgroundExpirationKeepsHealthyConnection() async throws {
let background = MobileTestBackgroundTaskManager()
let connections = MobileSFTPConnectionBox(clients: [MobileTestSFTPClient()])
let app = try MobileAppModel(
inMemory: true, background: background, workspaceConnectionServices: connections.services)
let host = try configureConnectionFixture(in: app)
_ = try #require(app.open(hostID: host.id, kind: .sftp))
defer { app.closeAll() }
#expect(await eventually { app.workspaces.first?.connected == true })
let sceneID = UUID()
app.sceneChanged(sceneID, phase: .active)
app.sceneChanged(sceneID, phase: .background)
app.sceneChanged(sceneID, phase: .active)
background.expire()
#expect(app.workspaces.first?.connected == true)
#expect(connections.connectCount == 1)
}
@Test func failedForegroundResumeKeepsReconnectionJourneyVisible() async throws {
let (host, library) = try connectionFixture()
let connections = MobileSFTPConnectionBox(clients: [MobileTestSFTPClient()])
let reconnectController = SessionReconnectController(
sleep: { _ in try await Task.sleep(for: .seconds(3_600)) })
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .sftp),
hosts: library, connectionServices: connections.services,
reconnectController: reconnectController)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
workspace.suspendForBackgroundExpiration()
workspace.resumeAfterBackground()
#expect(await eventually { workspace.reconnectState?.attempt == 1 })
#expect(workspace.journey.isPresented)
#expect(workspace.journey.mode == .reconnection)
#expect(workspace.operationError == nil)
}
@Test func explicitDisconnectDoesNotCreateAResumeIntent() async throws {
let (host, library) = try connectionFixture()
let connections = MobileSFTPConnectionBox(clients: [MobileTestSFTPClient(), MobileTestSFTPClient()])
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .sftp),
hosts: library, connectionServices: connections.services)
workspace.connect()
#expect(await eventually { workspace.connected })
workspace.disconnect()
workspace.resumeAfterBackground()
#expect(!workspace.connected)
#expect(!workspace.journey.isPresented)
#expect(connections.connectCount == 1)
}
@Test func backgroundExpirationClearsPendingAuthenticationBeforeResume() async throws {
let host = TermindDomain.Host(name: "Example", host: "example.invalid")
let library = HostLibraryModel(
hosts: [host],
knownHosts: [
KnownHostEntry(
hostname: host.host, port: host.port, algorithm: .ed25519,
fingerprint: "SHA256:test")
])
let connections = MobileSFTPConnectionBox(clients: [])
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.authenticationPrompt != nil })
workspace.suspendForBackgroundExpiration()
#expect(workspace.authenticationPrompt == nil)
#expect(!workspace.authenticationRequired)
#expect(workspace.journey.isPresented)
#expect(workspace.journey.mode == .connection)
workspace.resumeAfterBackground()
#expect(await eventually { workspace.authenticationPrompt != nil })
}
@Test func initialConnectionFailureStaysInJourney() async throws {
let (host, library) = try connectionFixture()
let connections = MobileSFTPConnectionBox(clients: [])
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .sftp),
hosts: library,
connectionServices: connections.services)
defer { workspace.disconnect() }
workspace.connect()
#expect(
await eventually {
if case .failed = workspace.journey.phase { return true }
return false
})
#expect(workspace.operationError == nil)
}
@Test func authenticationPersistenceFailureRemainsInJourneyForm() async throws {
let message = "Keychain unavailable"
let host = TermindDomain.Host(name: "Example", host: "example.invalid")
let library = HostLibraryModel(
hosts: [host],
knownHosts: [
KnownHostEntry(
hostname: host.host, port: host.port, algorithm: .ed25519,
fingerprint: "SHA256:test")
],
secrets: MobileFailingSecretStore(message: message))
let connections = MobileSFTPConnectionBox(clients: [])
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.authenticationPrompt != nil })
workspace.submitAuthentication(
identityID: nil, username: "deploy", password: "test-only", passphrase: nil, keyID: nil, save: true)
#expect(workspace.authenticationPrompt?.failureMessage == message)
#expect(workspace.journey.phase == .waiting)
#expect(workspace.operationError == nil)
}
@Test func sftpOperationFailureUsesOperationErrorWithoutFailingJourney() async throws {
let (host, library) = try connectionFixture()
let connections = MobileSFTPConnectionBox(clients: [MobileOperationFailingSFTPClient()])
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .sftp),
hosts: library,
connectionServices: connections.services)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
await workspace.sftpChannel.openDirectory("/restricted")
#expect(workspace.sftpChannel.operationError == "Permission denied")
if case .failed = workspace.journey.phase {
Issue.record("An ordinary SFTP operation error must not fail the connection Journey")
}
}
@Test func disconnectClearsJourneyAndOperationErrors() async throws {
let (host, library) = try connectionFixture()
let connections = MobileSFTPConnectionBox(clients: [])
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .sftp),
hosts: library,
connectionServices: connections.services)
workspace.operationError = "Permission denied"
workspace.connect()
#expect(await eventually { workspace.journey.isPresented })
workspace.disconnect()
#expect(!workspace.journey.isPresented)
#expect(workspace.operationError == nil)
}
@Test func sftpUnexpectedLossReconnectsWithoutResettingTheCurrentPath() async throws {
let identity = Identity(name: "Deploy", username: "deploy")
var host = TermindDomain.Host(name: "Example", host: "example.invalid")
host.identityID = identity.id
let library = HostLibraryModel(
hosts: [host],
knownHosts: [
KnownHostEntry(
hostname: host.host, port: host.port, algorithm: .ed25519, fingerprint: "SHA256:test")
])
try library.upsert(identity, passwordUpdate: .replace("test-only"))
let connections = MobileSFTPConnectionBox(clients: [MobileTestSFTPClient(), MobileTestSFTPClient()])
let reconnectController = SessionReconnectController(
sleep: { _ in try await Task.sleep(for: .seconds(3_600)) })
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .sftp),
hosts: library,
connectionServices: connections.services,
reconnectController: reconnectController)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
await workspace.sftpChannel.openDirectory("/srv/project")
#expect(workspace.sftpChannel.path == "/srv/project")
let retiredConnectionFailure = connections.connectionFailure
retiredConnectionFailure?("offline")
retiredConnectionFailure?("duplicate offline callback")
#expect(await eventually { workspace.reconnectState?.attempt == 1 })
#expect(workspace.journey.mode == .reconnection)
#expect(workspace.operationError == nil)
workspace.retryReconnectNow()
#expect(await eventually { workspace.connected })
#expect(workspace.sftpChannel.path == "/srv/project")
#expect(connections.connectCount == 2)
retiredConnectionFailure?("stale callback")
try await Task.sleep(for: .milliseconds(20))
#expect(workspace.connected)
#expect(workspace.reconnectState == nil)
}
@Test func sftpConnectionPublishesRouteProgressToJourney() async throws {
let (host, library) = try connectionFixture()
let connections = MobileSFTPConnectionBox(
clients: [MobileTestSFTPClient()],
progressEvents: [
.connecting(host.id), .authenticating(host.id), .authenticated(host.id),
])
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .sftp),
hosts: library, connectionServices: connections.services)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
#expect(await eventually { workspace.journey.logs.count >= 6 })
#expect(connections.didReceiveProgressHandler)
#expect(workspace.journey.purpose == .sftpChannel)
}
@Test func sshUnexpectedExitReconnectsOnceAndRejectsRetiredCallbacks() async throws {
let (host, library) = try connectionFixture()
let connections = MobileSFTPConnectionBox(clients: [])
let reconnectController = SessionReconnectController(
sleep: { _ in try await Task.sleep(for: .seconds(3_600)) })
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library, connectionServices: connections.services,
reconnectController: reconnectController)
var closeRequest: MobileWorkspace.ID?
workspace.didRequestClose = { closeRequest = $0 }
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
let retiredExit = try #require(connections.sshExitHandlers.first)
retiredExit(.failure(.socket("offline")))
#expect(await eventually { workspace.reconnectState?.attempt == 1 })
#expect(closeRequest == nil)
workspace.retryReconnectNow()
#expect(await eventually { workspace.connected })
#expect(connections.sshConnectCount == 2)
retiredExit(.success(0))
try await Task.sleep(for: .milliseconds(20))
#expect(workspace.connected)
#expect(workspace.reconnectState == nil)
#expect(closeRequest == nil)
}
@Test func successfulSSHExitClosesOnlyItsWorkspaceAndAcceptsAnyExitStatus() async throws {
let connections = MobileSFTPConnectionBox(clients: [])
let app = try MobileAppModel(
inMemory: true, workspaceConnectionServices: connections.services)
let host = try configureConnectionFixture(in: app)
let firstID = try #require(app.open(hostID: host.id, kind: .ssh))
#expect(
await eventually {
app.workspaces.first(where: { $0.id == firstID })?.connected == true
&& connections.sshExitHandlers.count == 1
})
let secondID = try #require(app.open(hostID: host.id, kind: .ssh))
defer { app.closeAll() }
#expect(
await eventually {
app.workspaces.first(where: { $0.id == secondID })?.connected == true
&& connections.sshExitHandlers.count == 2
})
connections.sshExitHandlers[0](.success(0))
#expect(
await eventually {
!app.workspaces.contains(where: { $0.id == firstID })
&& app.workspaces.contains(where: { $0.id == secondID })
})
connections.sshExitHandlers[0](.success(0))
try await Task.sleep(for: .milliseconds(20))
#expect(app.workspaces.map(\.id) == [secondID])
connections.sshExitHandlers[1](.success(23))
#expect(await eventually { app.workspaces.isEmpty })
}
@Test func onlySuccessfulSessionsEnterAutomaticRecovery() {
let controller = SessionReconnectController(sleep: { _ in try await Task.sleep(for: .seconds(3_600)) })
let state = RemoteSessionConnectionController(reconnect: controller)
defer { state.resetRecovery() }
#expect(!state.scheduleRecovery(after: "initial failure") {})
#expect(state.reconnectState == nil)
#expect(!state.markConnected())
#expect(state.scheduleRecovery(after: "connection lost") {})
#expect(state.isRecovering)
#expect(state.reconnectState?.attempt == 1)
#expect(!state.scheduleRecovery(after: "still offline") {})
#expect(state.reconnectState?.attempt == 2)
}
@Test func stoppingRecoveryKeepsManualReconnectAvailableAndResetClearsHistory() {
let controller = SessionReconnectController(sleep: { _ in try await Task.sleep(for: .seconds(3_600)) })
let state = RemoteSessionConnectionController(reconnect: controller)
_ = state.markConnected()
_ = state.scheduleRecovery(after: "offline") {}
#expect(state.stopRecovery()?.lastError == "offline")
#expect(state.reconnectState == nil)
#expect(state.hasConnectedOnce)
#expect(!state.isRecovering)
state.resetRecovery()
#expect(!state.hasConnectedOnce)
#expect(!state.isRecovering)
}
@Test func aRecoveredConnectionReportsRecoveryAndClearsRetryState() {
let controller = SessionReconnectController(sleep: { _ in try await Task.sleep(for: .seconds(3_600)) })
let state = RemoteSessionConnectionController(reconnect: controller)
_ = state.markConnected()
_ = state.scheduleRecovery(after: "offline") {}
#expect(state.markConnected())
#expect(state.reconnectState == nil)
#expect(state.hasConnectedOnce)
#expect(!state.isRecovering)
}
@Test func firstUseHostKeyContinuesOnTheOriginalSSHConnection() async throws {
let identity = Identity(name: "Deploy", username: "deploy")
var host = TermindDomain.Host(name: "Example", host: "example.invalid")
host.identityID = identity.id
let library = HostLibraryModel(hosts: [host], identities: [identity])
try library.upsert(identity, passwordUpdate: .replace("test-only"))
var connectionCount = 0
let services = MobileWorkspaceConnectionServices(
makePTYChannel: {
SSHPTYChannel(output: $0, exit: $1, shellIntegration: $2)
},
connectSSH: { _, request, _, _, _, _ in
connectionCount += 1
let configuration = request.configuration
let observation = SSHHostKeyObservation(
hostname: configuration.host, port: configuration.port,
algorithm: .ed25519, fingerprint: "SHA256:first-use"
)
let authorizer = try #require(request.authorizeHostKey)
guard
try await authorizer(
SSHHostKeyChallenge(
hostID: configuration.hostID,
observation: observation,
expectedHostKeys: configuration.expectedHostKeys
)
) == .accept
else { throw CancellationError() }
},
connectSFTP: { _, _, _ in throw SFTPError.notConnected },
writeSSH: { _, _ in }
)
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(
hostID: host.id, title: host.name, kind: .ssh
),
hosts: library, connectionServices: services
)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.hostKeyPrompt != nil })
#expect(connectionCount == 1)
workspace.trustHostKey()
#expect(await eventually { workspace.connected })
#expect(connectionCount == 1)
#expect(library.knownHosts(for: host).map(\.fingerprint) == ["SHA256:first-use"])
}
private func eventually(_ condition: @escaping @MainActor () -> Bool) async -> Bool {
for _ in 0..<100 {
if condition() { return true }
try? await Task.sleep(for: .milliseconds(10))
}
return false
}
private func connectionFixture() throws -> (TermindDomain.Host, HostLibraryModel) {
let identity = Identity(name: "Deploy", username: "deploy")
var host = TermindDomain.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)
}
private func configureConnectionFixture(in app: MobileAppModel) throws -> TermindDomain.Host {
let identity = Identity(name: "Deploy", username: "deploy")
var host = TermindDomain.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"))
return host
}
}
@Suite @MainActor
struct MobileLiveActivitySchedulingTests {
@Test func workspaceStateChangePublishesAValueSnapshot() async throws {
let identity = Identity(name: "Deploy", username: "deploy")
var host = TermindDomain.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 connections = MobileSFTPConnectionBox(clients: [MobileTestSFTPClient()])
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .sftp),
hosts: library, connectionServices: connections.services)
let activities = MobileLiveActivityController(enabled: true)
var receivedID: UUID?
var receivedConnected = false
workspace.didChangeState = {
receivedID = $0.id
receivedConnected = $0.connected
activities.update([$0])
}
defer {
workspace.didChangeState = nil
workspace.disconnect()
activities.update([])
}
workspace.connect()
for _ in 0..<100 where !workspace.connected {
try await Task.sleep(for: .milliseconds(10))
}
#expect(workspace.connected)
#expect(receivedID == workspace.id)
#expect(receivedConnected)
}
}
@Suite(.serialized) @MainActor
struct MobileWorkspaceNavigationRenderingTests {
@Test func padWorkspaceDetailsShowTopTabsAndLeadingSwitcherButHideCompactBottomTabs() async throws {
guard UIDevice.current.userInterfaceIdiom == .pad else { return }
let connections = MobileSFTPConnectionBox(clients: [MobileTestSFTPClient()])
let app = try MobileAppModel(inMemory: true, workspaceConnectionServices: connections.services)
let host = try configureConnectionFixture(in: app)
let sshID = try #require(app.open(hostID: host.id, kind: .ssh))
let sftpID = try #require(app.open(hostID: host.id, kind: .sftp))
defer { app.closeAll() }
#expect(await eventually { app.workspaces.allSatisfy(\.connected) })
let scene = MobileSceneModel(app: app)
scene.selectWorkspace(sshID)
try await withHostedView(MobileRootView(app: app, scene: scene)) { controller in
let tabs = try #require(descendant(of: UITabBarController.self, in: controller))
for id in [sshID, sftpID] {
scene.selectWorkspace(id)
let workspace = try #require(app.workspaces.first { $0.id == id })
let navigation = try #require(
await navigationController(titled: workspace.descriptor.title, in: controller))
let detail = try #require(navigation.topViewController)
for sizeClass: UIUserInterfaceSizeClass in [.regular, .compact, .regular] {
controller.traitOverrides.horizontalSizeClass = sizeClass
tabs.sidebar.isHidden = true
try await Task.sleep(for: .milliseconds(700))
#expect(tabs.isTabBarHidden == (sizeClass == .compact))
#expect(navigation.topViewController === detail)
let leading =
detail.navigationItem.leadingItemGroups.flatMap(\.barButtonItems)
+ (detail.navigationItem.leftBarButtonItems ?? [])
#expect(leading.contains { $0.menu != nil } == (sizeClass == .regular))
if let image = renderedPNGData(of: controller) {
Attachment.record(
image, named: "workspace-tabs-\(workspace.descriptor.kind)-\(sizeClass.rawValue).png")
}
}
tabs.sidebar.isHidden = false
try await Task.sleep(for: .milliseconds(700))
let search = try #require(detail.navigationItem.searchController)
#expect(!search.isActive)
if #available(iOS 26.0, *) {
#expect(detail.navigationItem.preferredSearchBarPlacement == .integratedButton)
}
#expect(!workspace.find.isPresented)
if let image = renderedPNGData(of: controller) {
Attachment.record(image, named: "workspace-sidebar-search-\(workspace.descriptor.kind).png")
}
}
}
}
@Test func crossDestinationExecutionBuildsTheWorkspaceDetailInThePadRoot() async throws {
let connections = MobileSFTPConnectionBox(clients: [])
let app = try MobileAppModel(
inMemory: true,
workspaceConnectionServices: connections.services
)
let host = try configureConnectionFixture(in: app)
let scene = MobileSceneModel(app: app)
let snippet = Snippet(title: "Status", body: "uptime")
scene.execute(snippet, on: [host.id])
let workspace = try #require(app.workspaces.first)
defer { app.closeAll() }
#expect(app.renameWorkspace(workspace.id, to: "Snippet Workspace"))
#expect(scene.selectedDestination == .workspaces)
#expect(scene.workspaceID == nil)
try await withHostedView(
MobileRootView(app: app, scene: scene)
.environment(\.horizontalSizeClass, .regular)
) { controller in
let navigation = try #require(
await navigationController(titled: "Snippet Workspace", in: controller)
)
let detail = try #require(navigation.topViewController)
#expect(scene.workspaceID == workspace.id)
#expect(descendant(of: MobileTerminalView.self, in: detail.view) != nil)
}
}
@Test func switchingFromSFTPRebuildsSSHContentAndConnectionJourney() async throws {
let connections = MobileSFTPConnectionBox(clients: [MobileTestSFTPClient()])
let app = try MobileAppModel(
inMemory: true,
workspaceConnectionServices: connections.services
)
let host = try configureConnectionFixture(in: app)
let sftpID = try #require(app.open(hostID: host.id, kind: .sftp))
let sshID = try #require(app.open(hostID: host.id, kind: .ssh))
let journeyID = try #require(app.open(hostID: host.id, kind: .ssh))
defer { app.closeAll() }
#expect(await eventually { app.workspaces.allSatisfy(\.connected) })
try await Task.sleep(for: .milliseconds(700))
#expect(app.renameWorkspace(sftpID, to: "SFTP Workspace"))
#expect(app.renameWorkspace(sshID, to: "SSH Workspace"))
#expect(app.renameWorkspace(journeyID, to: "SSH Journey"))
let journeyWorkspace = try #require(app.workspaces.first(where: { $0.id == journeyID }))
journeyWorkspace.disconnect()
journeyWorkspace.journey.begin(
host: host,
route: app.library.route(for: host),
purpose: .sshSession,
username: app.library.identity(for: host)?.username
)
journeyWorkspace.journey.fail("Network unavailable", at: host.id)
let scene = MobileSceneModel(app: app)
scene.selectWorkspace(sftpID)
try await withHostedView(MobileRootView(app: app, scene: scene)) { controller in
var navigation = try #require(
await navigationController(titled: "SFTP Workspace", in: controller)
)
var detail = try #require(navigation.topViewController)
#expect(!detail.navigationItem.hidesBackButton)
#expect(descendant(of: MobileTerminalView.self, in: detail.view) == nil)
try await Task.sleep(for: .milliseconds(500))
#expect(try #require(detail.navigationItem.searchController).isActive == false)
if #available(iOS 26.0, *) {
#expect(detail.navigationItem.preferredSearchBarPlacement == .integratedButton)
}
if let image = renderedPNGData(of: controller) {
Attachment.record(image, named: "workspace-sftp-toolbar.png")
}
scene.selectWorkspace(sshID)
navigation = try #require(
await navigationController(titled: "SSH Workspace", in: controller)
)
detail = try #require(navigation.topViewController)
let terminal = try #require(descendant(of: MobileTerminalView.self, in: detail.view))
#expect(!detail.navigationItem.hidesBackButton)
#expect(terminal.window != nil)
#expect(terminal.bounds.width > 0)
#expect(terminal.bounds.height > 0)
try await Task.sleep(for: .milliseconds(500))
#expect(try #require(detail.navigationItem.searchController).isActive == false)
if #available(iOS 26.0, *) {
#expect(detail.navigationItem.preferredSearchBarPlacement == .integratedButton)
}
if let image = renderedPNGData(of: controller) {
Attachment.record(image, named: "workspace-ssh-toolbar.png")
}
scene.selectWorkspace(sftpID)
_ = try #require(await navigationController(titled: "SFTP Workspace", in: controller))
scene.selectWorkspace(journeyID)
navigation = try #require(
await navigationController(titled: "SSH Journey", in: controller)
)
detail = try #require(navigation.topViewController)
#expect(!detail.navigationItem.hidesBackButton)
#expect(try #require(renderedPNGData(of: detail)).count > 10_000)
scene.selectWorkspace(sshID)
_ = try #require(await navigationController(titled: "SSH Workspace", in: controller))
scene.selectWorkspace(sftpID)
_ = try #require(await navigationController(titled: "SFTP Workspace", in: controller))
scene.closeWorkspace(sftpID)
_ = try #require(await navigationController(titled: "Workspaces", in: controller))
#expect(scene.workspaceID == nil)
}
}
@Test func resizingKeepsWorkspaceDetailsWithoutReconnecting() async throws {
let connections = MobileSFTPConnectionBox(clients: [MobileTestSFTPClient()])
let app = try MobileAppModel(inMemory: true, workspaceConnectionServices: connections.services)
let host = try configureConnectionFixture(in: app)
let sshID = try #require(app.open(hostID: host.id, kind: .ssh))
let sftpID = try #require(app.open(hostID: host.id, kind: .sftp))
defer { app.closeAll() }
#expect(await eventually { app.workspaces.allSatisfy(\.connected) })
#expect(app.renameWorkspace(sshID, to: "Resize SSH"))
#expect(app.renameWorkspace(sftpID, to: "Resize SFTP"))
let ssh = try #require(app.workspaces.first { $0.id == sshID })
let sftp = try #require(app.workspaces.first { $0.id == sftpID })
let terminal = ssh.terminal
let browser = sftp.sftpChannel
browser.query = "Retained session filter"
let scene = MobileSceneModel(app: app)
scene.selectWorkspace(sshID)
let regions = MobileRegionTestState()
try await withHostedView(
MobileRegionTestHost(state: regions) { MobileRootView(app: app, scene: scene) }
) { controller in
_ = try #require(await navigationController(titled: "Resize SSH", in: controller))
for id in [sshID, sftpID] {
scene.selectWorkspace(id)
let title = id == sshID ? "Resize SSH" : "Resize SFTP"
let navigation = try #require(await navigationController(titled: title, in: controller))
let detail = navigation.topViewController
let nativeTerminal = detail.flatMap { descendant(of: MobileTerminalView.self, in: $0.view) }
for sizeClass: UIUserInterfaceSizeClass in [.regular, .compact, .regular, .compact] {
controller.traitOverrides.horizontalSizeClass = sizeClass
try await Task.sleep(for: .milliseconds(300))
#expect(scene.workspaceID == id)
let resizedNavigation = try #require(await navigationController(titled: title, in: controller))
#expect(resizedNavigation === navigation)
#expect(navigation.topViewController === detail)
#expect(ssh.connected && sftp.connected)
#expect(ssh.terminal === terminal)
#expect(sftp.sftpChannel === browser)
#expect(browser.query == "Retained session filter")
#expect(connections.sshConnectCount == 1)
#expect(connections.connectCount == 1)
}
for region in [
MobileReservedRegion(kind: .division, frame: CGRect(x: 0, y: 250, width: 2000, height: 32)),
MobileReservedRegion(kind: .division, frame: CGRect(x: 160, y: 0, width: 32, height: 2000)),
MobileReservedRegion(kind: .occlusion, frame: CGRect(x: 0, y: 0, width: 50, height: 40)),
MobileReservedRegion(kind: .division, frame: .zero, isActive: false),
] {
regions.regions = [region]
try await Task.sleep(for: .milliseconds(400))
#expect(navigation.topViewController === detail)
#expect(ssh.terminal === terminal)
#expect(sftp.sftpChannel === browser)
#expect(ssh.connected && sftp.connected)
#expect(connections.sshConnectCount == 1)
#expect(connections.connectCount == 1)
if let nativeTerminal, let detail {
#expect(descendant(of: MobileTerminalView.self, in: detail.view) === nativeTerminal)
#expect(nativeTerminal.bounds.width > 0 && nativeTerminal.bounds.height > 0)
}
}
}
}
}
@Test(arguments: [DynamicTypeSize.large, .accessibility3])
func sftpLongPathsFitNarrowAndWideContainers(typeSize: DynamicTypeSize) async throws {
let connections = MobileSFTPConnectionBox(clients: [MobileTestSFTPClient()])
let app = try MobileAppModel(inMemory: true, workspaceConnectionServices: connections.services)
let host = try configureConnectionFixture(in: app)
let id = try #require(app.open(hostID: host.id, kind: .sftp))
defer { app.closeAll() }
let workspace = try #require(app.workspaces.first { $0.id == id })
#expect(await eventually { workspace.connected })
let path = "/srv/projects/production-environment/很长的服务器配置目录/application-configuration"
await workspace.sftpChannel.openDirectory(path)
let scene = MobileSceneModel(app: app)
scene.selectWorkspace(id)
try await withHostedView(
MobileRootView(app: app, scene: scene)
.environment(\.horizontalSizeClass, .compact)
.environment(\.dynamicTypeSize, typeSize)
) { controller in
_ = try #require(await navigationController(titled: workspace.descriptor.title, in: controller))
for width: CGFloat in [390, 844] {
controller.view.frame.size = CGSize(width: width, height: 800)
controller.view.layoutIfNeeded()
try await Task.sleep(for: .milliseconds(300))
#expect(workspace.sftpChannel.path == path)
#expect(connections.connectCount == 1)
let image = try #require(renderedPNGData(of: controller))
Attachment.record(image, named: "sftp-long-path-\(Int(width))-\(typeSize).png")
}
}
}
private func configureConnectionFixture(in app: MobileAppModel) throws -> TermindDomain.Host {
let identity = Identity(name: "Deploy", username: "deploy")
var host = TermindDomain.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"
)
)
return host
}
private func eventually(_ condition: @escaping @MainActor () -> Bool) async -> Bool {
for _ in 0..<100 {
if condition() { return true }
try? await Task.sleep(for: .milliseconds(10))
}
return false
}
private func withHostedView<Content: View>(
_ content: Content,
check: (UIViewController) async throws -> Void
) async throws {
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let previousWindow = scene.keyWindow
let window = UIWindow(windowScene: scene)
let controller = UIHostingController(rootView: content)
window.rootViewController = controller
window.windowLevel = .alert + 2
window.makeKeyAndVisible()
defer {
window.isHidden = true
window.rootViewController = nil
previousWindow?.makeKey()
}
try await Task.sleep(for: .milliseconds(700))
try await check(controller)
}
private func navigationController(
titled title: String,
in controller: UIViewController
) async -> UINavigationController? {
for _ in 0..<50 {
if let navigation = descendants(of: UINavigationController.self, in: controller).first(where: {
$0.topViewController?.navigationItem.title == title
}) {
return navigation
}
try? await Task.sleep(for: .milliseconds(20))
}
return nil
}
private func descendants<T: UIViewController>(
of type: T.Type,
in controller: UIViewController
) -> [T] {
var pending = [controller]
var visited = Set<ObjectIdentifier>()
var results: [T] = []
while let next = pending.popLast() {
guard visited.insert(ObjectIdentifier(next)).inserted else { continue }
if let result = next as? T { results.append(result) }
pending.append(contentsOf: next.children)
if let presented = next.presentedViewController { pending.append(presented) }
}
return results
}
private func descendant<T: UIViewController>(
of type: T.Type,
in controller: UIViewController
) -> T? {
descendants(of: type, in: controller).first
}
private func descendant<T: UIView>(of type: T.Type, in view: UIView) -> T? {
if let result = view as? T { return result }
for subview in view.subviews {
if let result = descendant(of: type, in: subview) { return result }
}
return nil
}
private func renderedPNGData(of controller: UIViewController) -> Data? {
controller.view.layoutIfNeeded()
let image = UIGraphicsImageRenderer(bounds: controller.view.bounds).image { _ in
controller.view.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
}
return image.pngData()
}
}
@MainActor
private final class MobileSFTPConnectionBox {
var connectionFailure: (@Sendable (String) -> Void)?
private var clients: [any SFTPClientProtocol]
private let progressEvents: [SSHRouteEvent]
private(set) var connectCount = 0
private(set) var didReceiveProgressHandler = false
private(set) var sshConnectCount = 0
private(set) var sshExitHandlers: [SSHPTYChannel.ExitHandler] = []
init(
clients: [any SFTPClientProtocol],
progressEvents: [SSHRouteEvent] = []
) {
self.clients = clients
self.progressEvents = progressEvents
}
var services: MobileWorkspaceConnectionServices {
MobileWorkspaceConnectionServices(
makePTYChannel: { [self] output, exit, shellIntegration in
sshExitHandlers.append(exit)
return SSHPTYChannel(output: output, exit: exit, shellIntegration: shellIntegration)
},
connectSSH: { [self] _, _, _, _, _, _ in sshConnectCount += 1 },
connectSFTP: { [self] _, progress, connectionFailure in
guard !clients.isEmpty else { throw SFTPError.notConnected }
self.connectionFailure = connectionFailure
self.connectCount += 1
self.didReceiveProgressHandler = progress != nil
for event in progressEvents { progress?(event) }
return self.clients.removeFirst()
},
writeSSH: { session, data in await session.write(data) })
}
}
@MainActor
private final class MobileTestBackgroundTaskManager: MobileBackgroundTaskManaging {
private var expiration: (@MainActor () -> Void)?
func begin(expiration: @escaping @MainActor () -> Void) {
self.expiration = expiration
}
func finish() {
expiration = nil
}
func expire() {
let action = expiration
expiration = nil
action?()
}
}
private actor MobileOperationFailingSFTPClient: SFTPClientProtocol {
private var listCount = 0
func connect(using configuration: SSHConnectionConfiguration, progress: SSHRouteProgressHandler?) async throws {}
func disconnect() async {}
func homeDirectory() async throws -> String { "/home/test" }
func listDirectory(at path: String) async throws -> [RemoteFile] {
defer { listCount += 1 }
if listCount > 0 { throw SFTPError.operation("Permission denied") }
return []
}
func attributes(at path: String) async throws -> RemoteFile? { nil }
func setPermissions(at path: String, to permissions: UInt32) async throws {}
func createDirectory(at path: String) async throws {}
func rename(from source: String, to destination: String, overwrite: Bool) async throws {}
func removeFile(at path: String) async throws {}
func removeDirectory(at path: String) async throws {}
func uploadFile(
from localURL: URL, to remotePath: String, overwrite: Bool,
cancellation: TransferCancellationToken,
progress: @escaping @Sendable (SFTPProgress) -> Void
) async throws {}
func downloadFile(
from remotePath: String, to localURL: URL, overwrite: Bool,
cancellation: TransferCancellationToken,
progress: @escaping @Sendable (SFTPProgress) -> Void
) async throws {}
}
private final class MobileFailingSecretStore: SecretStoring, @unchecked Sendable {
let message: String
init(message: String) { self.message = message }
func string(for account: String, storage: PasswordStorage) throws -> String? { nil }
func setString(_ value: String?, for account: String, storage: PasswordStorage) throws {
throw TermindSSHError.library(message)
}
func keyCredential(for account: String, storage: KeyStorage) throws -> StoredSSHKeyCredential? { nil }
func setKeyCredential(_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage) throws {}
func removePassword(_ account: String, storage: PasswordStorage) throws {}
func removeKeyCredential(_ account: String, storage: KeyStorage) throws {}
}
private actor MobileTestSFTPClient: SFTPClientProtocol {
func connect(using configuration: SSHConnectionConfiguration, progress: SSHRouteProgressHandler?) async throws {}
func disconnect() async {}
func homeDirectory() async throws -> String { "/home/test" }
func listDirectory(at path: String) async throws -> [RemoteFile] { [] }
func attributes(at path: String) async throws -> RemoteFile? { nil }
func setPermissions(at path: String, to permissions: UInt32) async throws {}
func createDirectory(at path: String) async throws {}
func rename(from source: String, to destination: String, overwrite: Bool) async throws {}
func removeFile(at path: String) async throws {}
func removeDirectory(at path: String) async throws {}
func uploadFile(
from localURL: URL, to remotePath: String, overwrite: Bool,
cancellation: TransferCancellationToken,
progress: @escaping @Sendable (SFTPProgress) -> Void
) async throws {}
func downloadFile(
from remotePath: String, to localURL: URL, overwrite: Bool,
cancellation: TransferCancellationToken,
progress: @escaping @Sendable (SFTPProgress) -> Void
) async throws {}
}
@Suite struct WorkspaceActivityRoutingTests {
@Test func activityBoundsItsPayloadAndKeepsTheFocusedSessionVisible() throws {
let sessions = (0..<12).map { index in
WorkspaceActivityAttributes.Session(
id: UUID(), title: String(repeating: "服务器🖥️", count: 100),
kind: "ssh", connected: index < 3)
}
let focused = sessions[10].id
let state = WorkspaceActivityAttributes.ContentState.snapshot(sessions, focusedID: focused)
#expect(state.workspaceCount == 12)
#expect(state.connectedCount == 3)
#expect(WorkspaceActivityAttributes.ContentState.maximumPresentedSessionCount == 6)
#expect(state.sessions.count == 6)
#expect(state.sessions.first?.id == focused)
#expect(state.workspaceCount > state.sessions.count)
#expect(WorkspaceActivityAttributes.workspaceID(from: state.destinationURL) == focused)
#expect(try JSONEncoder().encode(state).count < 3500)
}
@Test func activityDecodesLegacyStatesWithSixSessions() throws {
let sessions = (0..<6).map { index in
WorkspaceActivityAttributes.Session(
id: UUID(), title: "Workspace \(index)", kind: "ssh", connected: index < 2)
}
let legacyState = WorkspaceActivityAttributes.ContentState(
workspaceCount: sessions.count, connectedCount: 2, sessions: sessions, focusedWorkspaceID: nil)
let restored = try JSONDecoder().decode(
WorkspaceActivityAttributes.ContentState.self,
from: JSONEncoder().encode(legacyState))
#expect(restored.sessions.count == 6)
#expect(restored.workspaceCount == 6)
#expect(restored.connectedCount == 2)
}
@Test func aggregateActivityOpensOnlyTheWorkspaceList() throws {
#expect(WorkspaceActivityAttributes.accepts(WorkspaceActivityAttributes.destinationURL))
for value in ["https://workspaces", "termind://workspaces/extra", "termind://workspaces?command=ls"] {
#expect(!WorkspaceActivityAttributes.accepts(try #require(URL(string: value))))
}
}
@Test func acceptsOnlyWorkspaceLinks() throws {
let id = UUID()
#expect(
WorkspaceActivityAttributes.workspaceID(from: try #require(URL(string: "termind://workspace/\(id)"))) == id)
for value in [
"https://workspace/\(id)", "termind://workspace/not-an-id", "termind://workspace/\(id)/extra",
"termind://workspace/\(id)?command=ls",
] {
#expect(WorkspaceActivityAttributes.workspaceID(from: try #require(URL(string: value))) == nil)
}
}
@Test func workspaceDropTargetInstallsItsNativeInteractionOnTheWindow() {
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 1_024, height: 768))
var targeted: [Bool] = []
let coordinator = MobileWindowDropTarget.Coordinator(
isEnabled: true,
targeted: { targeted.append($0) },
receive: { !$0.isEmpty }
)
let region = UIView(frame: window.bounds)
window.addSubview(region)
coordinator.install(in: region)
#expect(window.interactions.contains { $0 is UIDropInteraction })
coordinator.isEnabled = false
#expect(!targeted.contains(true))
coordinator.uninstall()
#expect(!window.interactions.contains { $0 is UIDropInteraction })
}
}
@Suite struct WorkspaceHostIconTests {
@Test func usesTheSameDistributionMatchAsTheHostList() throws {
let catalog = try JSONDecoder().decode(
LinuxDistributionIconCatalog.self,
from: Data(
#"{"entries":[{"title":"Ubuntu","asset":"LinuxDistro-ubuntu","ids":["ubuntu"],"hex":"E95420"},{"title":"Debian","asset":"LinuxDistro-debian","ids":["debian"],"hex":"A81D33"}]}"#
.utf8))
let ubuntu = try #require(DetectedDistribution(id: "ubuntu", likeIDs: ["debian"]))
#expect(catalog.entry(matching: ubuntu)?.asset == "LinuxDistro-ubuntu")
let derivative = try #require(DetectedDistribution(id: "custom", likeIDs: ["debian"]))
#expect(catalog.entry(matching: derivative)?.asset == "LinuxDistro-debian")
#expect(catalog.entry(matching: try #require(DetectedDistribution(id: "unknown"))) == nil)
}
@Test func activityPreservesSystemArtworkAndSessionDestination() throws {
let id = UUID()
let session = WorkspaceActivityAttributes.Session(
id: id, title: "Production", kind: "ssh",
connected: true, iconAsset: "LinuxDistro-ubuntu")
let state = WorkspaceActivityAttributes.ContentState.snapshot([session], focusedID: id)
let restored = try JSONDecoder().decode(
WorkspaceActivityAttributes.ContentState.self,
from: JSONEncoder().encode(state))
#expect(restored.sessions.first?.iconAsset == "LinuxDistro-ubuntu")
#expect(WorkspaceActivityAttributes.workspaceID(from: restored.destinationURL) == id)
}
}
@Suite @MainActor
struct MobileWorkspaceRestorationTests {
@Test func displayOrderDoesNotChangeWhenSavedWorkspacesOpenOrRename() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let seed = try fixture.makeApp()
let host = try fixture.installHost(in: seed)
let descriptors = (0..<3).map {
MobileWorkspaceDescriptor(hostID: host.id, title: "Saved \($0)", kind: .ssh)
}
try fixture.store().save(descriptors)
let app = try fixture.makeApp()
defer { app.closeAll() }
#expect(app.orderedWorkspaceItems.map(\.id) == descriptors.map(\.id))
_ = try #require(app.openWorkspace(descriptors[1].id))
#expect(app.orderedWorkspaceItems.map(\.id) == descriptors.map(\.id))
#expect(app.renameWorkspace(descriptors[1].id, to: "Opened"))
#expect(app.orderedWorkspaceItems.map(\.id) == descriptors.map(\.id))
#expect(app.orderedWorkspaceItems.map(\.descriptor.title) == ["Saved 0", "Opened", "Saved 2"])
#expect(app.renameWorkspace(descriptors[0].id, to: "Still Dormant"))
#expect(app.workspaces.map(\.id) == [descriptors[1].id])
#expect(app.orderedWorkspaceItems.map(\.descriptor.title) == ["Still Dormant", "Opened", "Saved 2"])
#expect(try fixture.store().load().map(\.title) == ["Still Dormant", "Opened", "Saved 2"])
let appended = try #require(app.open(hostID: host.id, kind: .sftp))
#expect(app.orderedWorkspaceItems.map(\.id) == descriptors.map(\.id) + [appended])
}
@Test func relaunchPreservesDescriptorsWithoutConnectingOrReplayingCommands() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let first = try fixture.makeApp()
let host = try fixture.installHost(in: first)
let sshID = try #require(first.open(hostID: host.id, kind: .ssh, initialPath: "/private/work"))
let commandID = try #require(
first.execute(Snippet(title: "Deploy", body: "echo never-replay"), on: [host.id]).first)
_ = try #require(first.open(hostID: host.id, kind: .sftp, initialPath: "/private/files"))
#expect(first.renameWorkspace(sshID, to: "Release Shell"))
let expected = first.workspaces.map(\.descriptor)
let historyCount = first.connectionHistory.entries().count
let saved = try #require(fixture.defaults.data(forKey: MobileWorkspaceStore.key))
#expect(!String(decoding: saved, as: UTF8.self).contains("never-replay"))
#expect(!String(decoding: saved, as: UTF8.self).contains("/private"))
// Retire process-owned transports without closing the saved entries.
for workspace in first.workspaces { workspace.disconnect() }
let relaunched = try fixture.makeApp()
#expect(relaunched.workspaces.isEmpty)
#expect(relaunched.recentWorkspaces == expected)
#expect(relaunched.connectionHistory.entries().count == historyCount)
let restoredCommand = try #require(relaunched.openWorkspace(commandID))
#expect(restoredCommand.pendingInitialCommand == nil)
#expect(restoredCommand.descriptor == expected[1])
let newID = try #require(relaunched.open(hostID: host.id, kind: .ssh))
#expect(relaunched.workspaces.first { $0.id == newID }?.descriptor.title == host.name)
relaunched.closeAll()
}
@Test func selectingRestoredWorkspaceAcrossScenesConnectsOnlyItOnce() async throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let original = try fixture.makeApp()
let host = try fixture.installHost(in: original)
let descriptors = [
MobileWorkspaceDescriptor(hostID: host.id, title: "First", kind: .sftp),
MobileWorkspaceDescriptor(hostID: host.id, title: "Second", kind: .sftp),
]
try fixture.store().save(descriptors)
let app = try fixture.makeApp()
try fixture.installPassword(in: app)
defer { app.closeAll() }
let scene = MobileSceneModel(app: app)
let otherScene = MobileSceneModel(app: app)
app.sceneChanged(scene.id, phase: .active)
#expect(fixture.connections.connectCount == 0)
#expect(app.connectionHistory.entries().isEmpty)
scene.open(descriptors[1].destinationURL)
scene.activatePendingWorkspace()
otherScene.open(descriptors[1].destinationURL)
otherScene.activatePendingWorkspace()
scene.open(descriptors[1].destinationURL)
#expect(await eventually { app.workspaces[0].connected })
#expect(fixture.connections.connectCount == 1)
#expect(app.workspaces.map(\.descriptor) == [descriptors[1]])
#expect(app.recentWorkspaces == [descriptors[0]])
#expect(app.connectionHistory.entries().count == 1)
#expect(scene.workspaceID == descriptors[1].id)
#expect(otherScene.workspaceID == descriptors[1].id)
app.sceneChanged(otherScene.id, phase: .background)
app.sceneChanged(scene.id, phase: .background)
fixture.background.expire()
app.sceneChanged(scene.id, phase: .active)
#expect(await eventually { fixture.connections.connectCount == 2 && app.workspaces[0].connected })
#expect(app.workspaces.map(\.descriptor) == [descriptors[1]])
#expect(app.recentWorkspaces == [descriptors[0]])
}
@Test func failedFirstOpenRequiresExplicitRetryInsteadOfRetryingOnSelection() async throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let seed = try fixture.makeApp()
let host = try fixture.installHost(in: seed)
let descriptor = MobileWorkspaceDescriptor(hostID: host.id, title: "Server", kind: .sftp)
try fixture.store().save([descriptor])
var attempts = 0
var services = fixture.connections.services
services.connectSFTP = { _, _, _ in
attempts += 1
throw SFTPError.notConnected
}
let app = try fixture.makeApp(services: services)
try fixture.installPassword(in: app)
defer { app.closeAll() }
let scene = MobileSceneModel(app: app)
scene.open(descriptor.destinationURL)
scene.activatePendingWorkspace()
#expect(await eventually { attempts == 1 && !app.workspaces[0].busy })
scene.selectWorkspace(descriptor.id)
scene.selectWorkspace(descriptor.id)
#expect(attempts == 1)
app.workspaces[0].connect()
#expect(await eventually { attempts == 2 && !app.workspaces[0].busy })
}
@Test func closeHostDeletionAndCloseAllRemoveSavedEntries() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let app = try fixture.makeApp()
let host = try fixture.installHost(in: app)
let otherHost = TermindDomain.Host(name: "Other", host: "other.invalid")
app.library.upsert(otherHost)
let closedID = try #require(app.open(hostID: host.id, kind: .ssh))
_ = try #require(app.open(hostID: host.id, kind: .sftp))
let remainingID = try #require(app.open(hostID: otherHost.id, kind: .ssh))
app.close(closedID)
#expect(try !fixture.store().load().contains { $0.id == closedID })
app.deleteHost(host.id)
#expect(try fixture.store().load().map(\.id) == [remainingID])
let relaunched = try fixture.makeApp()
#expect(relaunched.workspaces.isEmpty)
#expect(relaunched.recentWorkspaces.map(\.id) == [remainingID])
let history = relaunched.connectionHistory.entries()
#expect(MobileSceneModel(app: relaunched).canCloseAllWorkspaces)
relaunched.closeAll()
#expect(relaunched.connectionHistory.entries() == history)
#expect(try fixture.store().load().isEmpty)
#expect(try fixture.makeApp().workspaces.isEmpty)
for workspace in app.workspaces { workspace.disconnect() }
}
@Test func normalSSHExitRemovesSavedEntry() async throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let app = try fixture.makeApp()
let host = try fixture.installHost(in: app)
_ = try #require(app.open(hostID: host.id, kind: .ssh))
defer { app.closeAll() }
#expect(await eventually { app.workspaces.first?.connected == true })
let exit = try #require(fixture.connections.sshExitHandlers.first)
exit(.success(23))
#expect(await eventually { app.workspaces.isEmpty })
#expect(try fixture.store().load().isEmpty)
#expect(try fixture.makeApp().workspaces.isEmpty)
}
@Test func missingHostsArePrunedWithoutRecreatingThem() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let seed = try fixture.makeApp()
let host = try fixture.installHost(in: seed)
let valid = MobileWorkspaceDescriptor(hostID: host.id, title: "Present", kind: .ssh)
let missing = MobileWorkspaceDescriptor(hostID: UUID(), title: "Removed", kind: .ssh)
try fixture.store().save([missing, valid])
let app = try fixture.makeApp()
#expect(app.workspaces.isEmpty)
#expect(app.recentWorkspaces == [valid])
#expect(try fixture.store().load() == [valid])
app.closeAll()
}
@Test(arguments: [Data("broken".utf8), Data(#"{"version":99,"workspaces":[]}"#.utf8)])
func unreadableSnapshotSurvivesStartupAndLaterMutations(data: Data) throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
fixture.defaults.set(data, forKey: MobileWorkspaceStore.key)
let app = try fixture.makeApp()
#expect(app.workspaces.isEmpty)
#expect(app.error != nil)
let host = try fixture.installHost(in: app)
_ = try #require(app.open(hostID: host.id, kind: .ssh))
app.closeAll()
#expect(fixture.defaults.data(forKey: MobileWorkspaceStore.key) == data)
}
@Test func staleAndAggregateLinksDoNotRestoreAnyWorkspaces() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let seed = try fixture.makeApp()
let host = try fixture.installHost(in: seed)
let descriptor = MobileWorkspaceDescriptor(hostID: host.id, title: "Server", kind: .ssh)
try fixture.store().save([descriptor])
let app = try fixture.makeApp()
defer { app.closeAll() }
let scene = MobileSceneModel(app: app)
scene.open(WorkspaceActivityAttributes.destinationURL(for: UUID()))
scene.activatePendingWorkspace()
#expect(scene.selectedDestination == .workspaces)
#expect(scene.workspaceID == nil)
#expect(scene.isWorkspaceUnavailable)
scene.open(WorkspaceActivityAttributes.destinationURL)
scene.activatePendingWorkspace()
#expect(scene.workspaceID == nil)
#expect(!scene.isWorkspaceUnavailable)
#expect(app.workspaces.isEmpty)
#expect(app.recentWorkspaces == [descriptor])
#expect(app.connectionHistory.entries().isEmpty)
scene.selectedDestination = .vault
scene.open(descriptor.destinationURL)
scene.open(WorkspaceActivityAttributes.destinationURL)
scene.activatePendingWorkspace()
#expect(scene.workspaceID == nil)
#expect(app.workspaces.map(\.id) == [descriptor.id])
#expect(app.connectionHistory.entries().count == 1)
app.close(descriptor.id)
scene.selectWorkspace(descriptor.id)
#expect(scene.isWorkspaceUnavailable)
}
@Test func mutationsPreserveRecentEntriesAndTheirOrderAcrossLaunches() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let seed = try fixture.makeApp()
let host = try fixture.installHost(in: seed)
let descriptors = (0..<3).map {
MobileWorkspaceDescriptor(hostID: host.id, title: "Saved \($0)", kind: .ssh)
}
try fixture.store().save(descriptors)
let app = try fixture.makeApp()
let restored = try #require(app.openWorkspace(descriptors[1].id))
#expect(restored.descriptor == descriptors[1])
#expect(app.renameWorkspace(restored.id, to: "Renamed"))
let created = try #require(app.open(hostID: host.id, kind: .ssh))
#expect(app.workspaces.last?.descriptor.title == host.name)
app.close(descriptors[0].id)
app.close(created)
let expected = [restored.descriptor, descriptors[2]]
#expect(try fixture.store().load() == expected)
#expect(app.recentWorkspaces == [descriptors[2]])
let relaunched = try fixture.makeApp()
#expect(relaunched.recentWorkspaces == expected)
let replacement = try #require(relaunched.open(hostID: host.id, kind: .ssh))
#expect(relaunched.workspaces.first?.descriptor.title == host.name)
relaunched.close(replacement)
app.closeAll()
}
@Test(arguments: [false, true])
func hostDeletionRemovesBothCurrentAndUnopenedEntries(remote: Bool) throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let seed = try fixture.makeApp()
let host = try fixture.installHost(in: seed)
let other = TermindDomain.Host(name: "Other", host: "other.invalid")
seed.library.upsert(other)
let descriptors = [
MobileWorkspaceDescriptor(hostID: host.id, title: "Open", kind: .ssh),
MobileWorkspaceDescriptor(hostID: host.id, title: "Recent", kind: .sftp),
MobileWorkspaceDescriptor(hostID: other.id, title: "Retained", kind: .ssh),
]
try fixture.store().save(descriptors)
let app = try fixture.makeApp()
_ = try #require(app.openWorkspace(descriptors[0].id))
if remote {
app.applyRemoteVault(.init(hosts: [other]), keys: [VaultRecordKey(kind: .host, id: host.id)])
} else {
app.deleteHost(host.id)
}
#expect(app.workspaces.isEmpty)
#expect(app.recentWorkspaces == [descriptors[2]])
#expect(try fixture.store().load() == [descriptors[2]])
app.closeAll()
}
@Test func legacyMigrationAllowsCreateRenameCloseAndRelaunch() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let seed = try fixture.makeApp()
let host = try fixture.installHost(in: seed)
let id = UUID()
let legacy = """
[{"id":"\(id)","hostID":"\(host.id)","title":"Original","kind":"ssh"}]
"""
fixture.defaults.set(Data(legacy.utf8), forKey: MobileWorkspaceStore.key)
let app = try fixture.makeApp()
#expect(app.error == nil)
#expect(app.recentWorkspaces.map(\.id) == [id])
_ = try #require(app.openWorkspace(id))
#expect(app.renameWorkspace(id, to: "Renamed"))
let created = try #require(app.open(hostID: host.id, kind: .ssh))
app.close(created)
let relaunched = try fixture.makeApp()
#expect(relaunched.error == nil)
#expect(relaunched.recentWorkspaces.map(\.title) == ["Renamed"])
#expect(relaunched.recentWorkspaces.map(\.id) == [id])
relaunched.closeAll()
#expect(try fixture.makeApp().recentWorkspaces.isEmpty)
for workspace in app.workspaces { workspace.disconnect() }
}
private func eventually(_ condition: () -> Bool) async -> Bool {
for _ in 0..<100 {
if condition() { return true }
try? await Task.sleep(for: .milliseconds(10))
}
return condition()
}
private final class Fixture {
let suiteName = "termind.workspace-restoration-tests.\(UUID().uuidString)"
let defaults: UserDefaults
let container: ModelContainer
let identity = Identity(name: "Deploy", username: "deploy")
let background = MobileTestBackgroundTaskManager()
let connections = MobileSFTPConnectionBox(
clients: [MobileTestSFTPClient(), MobileTestSFTPClient(), MobileTestSFTPClient()])
init() throws {
defaults = try #require(UserDefaults(suiteName: suiteName))
container = try TermindPersistence.makeContainer(inMemory: true)
}
func store() -> MobileWorkspaceStore { MobileWorkspaceStore(defaults: defaults) }
func cleanUp() { defaults.removePersistentDomain(forName: suiteName) }
func makeApp(services: MobileWorkspaceConnectionServices? = nil) throws -> MobileAppModel {
var dependencies = MobileAppDependencies.testing(
background: background, workspaceConnections: services ?? connections.services)
dependencies.container = container
dependencies.workspaceStore = store()
return try MobileAppModel(dependencies: dependencies)
}
func installHost(in app: MobileAppModel) throws -> TermindDomain.Host {
var host = TermindDomain.Host(name: "Server", host: "example.invalid")
host.identityID = identity.id
try installPassword(in: app)
app.library.upsert(host)
app.library.trust(
KnownHostEntry(
hostname: host.host, port: host.port, algorithm: .ed25519,
fingerprint: "SHA256:test"))
return host
}
func installPassword(in app: MobileAppModel) throws {
try app.library.upsert(identity, passwordUpdate: .replace("test-only"))
}
}
}