Files
Termind/TFITests/MobileConnectionPickerTests.swift

426 lines
19 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import Testing
import UIKit
import struct TermindDomain.Host
@testable import TermindIOS
@Suite(.serialized) @MainActor
struct MobileConnectionPickerTests {
@Test func pickerDefaultsToSSHFiltersHostsAndReturnsTheSelectedProtocol() throws {
let app = try MobileAppModel(inMemory: true)
var selections: [(Host.ID, ConnectionHistoryEntry.Kind)] = []
let hostModel = MobileHostPickerModel(library: app.library)
let connectionModel = MobileConnectionPickerModel { selections.append(($0, $1)) }
#expect(connectionModel.kind == .ssh)
#expect(hostModel.isLibraryEmpty)
let production = Host(name: "Production", host: "production.example.invalid")
let staging = Host(name: "Staging", host: "staging.example.invalid")
app.library.upsert(production)
app.library.upsert(staging)
#expect(!hostModel.isLibraryEmpty)
#expect(Set(hostModel.hosts.map(\.id)) == Set([production.id, staging.id]))
hostModel.query = "prod"
#expect(hostModel.hosts.map(\.id) == [production.id])
connectionModel.select(production)
#expect(selections.count == 1)
#expect(selections.first?.0 == production.id)
#expect(selections.first?.1 == .ssh)
connectionModel.kind = .sftp
connectionModel.select(staging)
#expect(selections.count == 2)
#expect(selections.last?.0 == staging.id)
#expect(selections.last?.1 == .sftp)
}
@Test func emptyWorkspaceHistoryKeepsTheLatestFiveUniqueLocalHosts() throws {
let app = try MobileAppModel(inMemory: true)
let scene = MobileSceneModel(app: app, sessionID: "test")
let repeatedHost = Host(
name: "Repeated", host: "repeated.example.invalid",
telnetEnabled: true, telnetPort: 2323)
let otherHosts = (0..<4).map {
Host(name: "Host \($0)", host: "host-\($0).example.invalid")
}
app.library.upsert(repeatedHost)
for host in otherHosts { app.library.upsert(host) }
let newestSSH = try #require(
app.connectionHistory.record(kind: .ssh, for: repeatedHost.id, at: Date(timeIntervalSince1970: 70)))
_ = app.connectionHistory.record(
kind: .ssh, for: repeatedHost.id, at: Date(timeIntervalSince1970: 60))
_ = app.connectionHistory.record(
kind: .telnet, for: repeatedHost.id, at: Date(timeIntervalSince1970: 58))
_ = app.connectionHistory.record(
kind: .sftp, for: repeatedHost.id, at: Date(timeIntervalSince1970: 55))
let other0 = try #require(
app.connectionHistory.record(
kind: .sftp, for: otherHosts[0].id, at: Date(timeIntervalSince1970: 50)))
let other1 = try #require(
app.connectionHistory.record(
kind: .ssh, for: otherHosts[1].id, at: Date(timeIntervalSince1970: 40)))
let other2 = try #require(
app.connectionHistory.record(
kind: .sftp, for: otherHosts[2].id, at: Date(timeIntervalSince1970: 30)))
let other3 = try #require(
app.connectionHistory.record(
kind: .ssh, for: otherHosts[3].id, at: Date(timeIntervalSince1970: 20)))
let expectedEntries = [newestSSH, other0, other1, other2, other3]
_ = app.connectionHistory.record(
kind: .ssh, for: UUID(), at: Date(timeIntervalSince1970: 80))
let rows = MobileWorkspaceListView(app: app, scene: scene).historyRows
#expect(rows.map(\.entry.id) == expectedEntries.map(\.id))
#expect(rows.first?.preferredKind == .ssh)
#expect(rows.first?.loginAddress == app.library.loginAddress(for: repeatedHost, protocol: .ssh))
#expect(Set(rows.map(\.host.id)).count == rows.count)
}
@Test func recentConnectionActionsFollowEveryHostProtocolCombination() {
#expect(MobileConnectionHistoryActionPolicy.menuKinds == [.ssh, .mosh, .eternalTerminal, .telnet, .sftp])
#expect(MobileConnectionHistoryActionPolicy.swipeKinds == [.sftp, .telnet, .eternalTerminal, .mosh, .ssh])
var host = Host(name: "Remote", host: "remote.example.invalid")
assertAvailability(host, ssh: true, telnet: false, sftp: true)
#expect(MobileConnectionHistoryActionPolicy.availableMenuKinds(for: host) == [.ssh, .sftp])
#expect(MobileConnectionHistoryActionPolicy.availableSwipeKinds(for: host) == [.sftp, .ssh])
host.sshEnabled = false
host.telnetEnabled = true
assertAvailability(host, ssh: false, telnet: true, sftp: false)
#expect(MobileConnectionHistoryActionPolicy.availableMenuKinds(for: host) == [.telnet])
#expect(MobileConnectionHistoryActionPolicy.availableSwipeKinds(for: host) == [.telnet])
host.sshEnabled = true
assertAvailability(host, ssh: true, telnet: true, sftp: true)
#expect(MobileConnectionHistoryActionPolicy.availableMenuKinds(for: host) == [.ssh, .telnet, .sftp])
#expect(MobileConnectionHistoryActionPolicy.availableSwipeKinds(for: host) == [.sftp, .telnet, .ssh])
host.terminalTransports.moshEnabled = true
host.terminalTransports.eternalTerminalEnabled = true
#expect(
MobileConnectionHistoryActionPolicy.availableMenuKinds(for: host)
== [.ssh, .mosh, .eternalTerminal, .telnet, .sftp])
#expect(
MobileConnectionHistoryActionPolicy.availableSwipeKinds(for: host)
== [.sftp, .telnet, .eternalTerminal, .mosh, .ssh])
host.sshEnabled = false
host.telnetEnabled = false
assertAvailability(host, ssh: false, telnet: false, sftp: false)
#expect(MobileConnectionHistoryActionPolicy.availableMenuKinds(for: host).isEmpty)
#expect(MobileConnectionHistoryActionPolicy.availableSwipeKinds(for: host).isEmpty)
#expect(MobileConnectionHistory.preferredKind(for: host) == nil)
host.telnetEnabled = true
#expect(MobileConnectionHistory.preferredKind(for: host) == .telnet)
host.sshEnabled = true
#expect(MobileConnectionHistory.preferredKind(for: host) == .ssh)
}
@Test func unavailableHistoryEventStaysVisibleButCannotOpen() throws {
let app = try MobileAppModel(inMemory: true)
let host = Host(
name: "Legacy Telnet", host: "legacy.example.invalid",
sshEnabled: true, telnetEnabled: false)
app.library.upsert(host)
_ = try #require(app.connectionHistory.record(kind: .telnet, for: host.id))
let row = try #require(
MobileConnectionHistory.eventRows(
history: app.connectionHistory, devices: app.devices,
library: app.library, limit: 1
).first)
#expect(row.entry.kind == .telnet)
#expect(!row.canOpen)
}
@Test func historyPageKeepsEveryEventSearchesDeviceNameAndCapsAtFifty() throws {
let app = try MobileAppModel(inMemory: true)
let host = Host(name: "History", host: "history.example.invalid")
app.library.upsert(host)
let historyRepository = InMemoryConnectionHistoryRepository()
let deviceRepository = InMemoryDeviceDirectoryRepository()
for index in (0...MobileHistoryPresentation.limit).reversed() {
let deviceID = UUID()
try deviceRepository.upsert(
DeviceRecord(id: deviceID, displayName: "device-\(index)"))
try historyRepository.insert(
ConnectionHistoryEntry(
hostID: host.id, kind: index.isMultiple(of: 2) ? .ssh : .sftp,
connectedAt: Date(timeIntervalSince1970: TimeInterval(index)),
sourceDeviceID: deviceID),
limit: ConnectionHistoryModel.limit)
}
let history = ConnectionHistoryModel(repository: historyRepository)
let devices = DeviceDirectoryModel(repository: deviceRepository)
let rows = MobileConnectionHistory.eventRows(
history: history, devices: devices, library: app.library,
limit: MobileHistoryPresentation.limit)
#expect(rows.count == MobileHistoryPresentation.limit)
#expect(rows.first?.sourceDeviceName == "device-50")
#expect(rows.filter { $0.host.id == host.id }.count == MobileHistoryPresentation.limit)
#expect(MobileHistoryPresentation.limited(Array(0...50)) == Array(0..<50))
let searched = MobileConnectionHistory.eventRows(
history: history, devices: devices, library: app.library,
query: "device-17", limit: MobileHistoryPresentation.limit)
#expect(searched.map(\.sourceDeviceName) == ["device-17"])
}
@Test func availableRecentTelnetActionOpensATelnetWorkspace() throws {
let app = try MobileAppModel(inMemory: true)
let host = Host(
name: "Console", host: "console.example.invalid",
sshEnabled: false, telnetEnabled: true)
app.library.upsert(host)
let scene = MobileSceneModel(app: app, sessionID: "test")
defer { app.testWorkspaceSession.closeAll() }
scene.open(hostID: host.id, kind: .telnet)
scene.activatePendingWorkspace()
#expect(scene.activeWorkspace?.descriptor.hostID == host.id)
#expect(scene.activeWorkspace?.descriptor.kind == .telnet)
#expect(app.connectionHistory.entries(for: host.id).map(\.kind) == [.telnet])
}
@Test(
.requiresKeyWindow,
arguments: [HostUseCase.snippetExecution, .metrics])
func multiHostPickerGatesConfirmationOnRequiredSelection(useCase: HostUseCase) async throws {
let app = try MobileAppModel(inMemory: true)
let host = Host(name: "Production", host: "production.example.invalid")
app.library.upsert(host)
try await withStandaloneNavigation(
MobileMultiHostPicker(
library: app.library, useCase: useCase, selected: [], title: "Run", actionTitle: "Run",
emptyDescription: AppStrings.Snippets.addAHostBeforeChoosingDefaultTargets,
requiresSelection: true, save: { _ in }),
language: "en"
) { navigation in
let run = try #require(toolbarButton(named: "Run", in: navigation))
#expect(!run.isEnabled)
}
try await withStandaloneNavigation(
MobileMultiHostPicker(
library: app.library, useCase: useCase, selected: [host.id], title: "Add Hosts",
actionTitle: "Done",
emptyDescription: AppStrings.Snippets.addAHostBeforeChoosingDefaultTargets,
requiresSelection: false, save: { _ in }),
language: "en"
) { navigation in
let done = try #require(toolbarButton(named: "Done", in: navigation))
#expect(done.isEnabled)
}
}
@Test(
arguments: [
("en", "Connect", "More", "Close All", "Batch Startup"),
("zh-Hans", "连接", "更多", "全部关闭", "批量启动"),
("zh-Hant", "連線", "更多", "全部關閉", "批次啟動"),
])
func workspaceToolbarExposesLocalizedActions(
language: String, expectedConnectName: String, expectedMoreName: String,
expectedCloseAllName: String, expectedBatchName: String
) async throws {
var connectActivations = 0
var batchActivations = 0
try await withNavigation(
ToolbarHost(
hasWorkspaces: true,
closeAll: {},
connect: { connectActivations += 1 },
batchStartup: { batchActivations += 1 }),
language: language
) { navigation in
let connect = try #require(toolbarButton(named: expectedConnectName, in: navigation))
let connectAction = try #require(connect.action)
#expect(UIApplication.shared.sendAction(connectAction, to: connect.target, from: connect, for: nil))
#expect(connectActivations == 1)
let page = try #require(navigation.topViewController)
let menu: UIMenu
if #available(iOS 27.0, *) {
menu = UIMenu(children: [try #require(page.navigationItem.additionalOverflowItems)])
} else {
let more = try #require(toolbarButton(named: expectedMoreName, in: navigation))
#expect(more.isEnabled)
menu = try #require(more.menu)
}
let resolved = try await resolvedMenu(menu, in: page.view)
let actions = menuActions(resolved)
let closeAll = try #require(actions.first { $0.title == expectedCloseAllName })
#expect(closeAll.attributes.contains(.destructive))
#expect(!closeAll.attributes.contains(.disabled))
#expect(actions.map(\.title) == [expectedBatchName, expectedCloseAllName])
let batch = try #require(actions.first)
#expect(!batch.attributes.contains(.disabled))
#expect(batch.image == UIImage(systemName: "wind"))
let button = UIButton(primaryAction: batch)
button.sendActions(for: .touchUpInside)
#expect(batchActivations == 1)
let groups = resolved.children.compactMap { $0 as? UIMenu }
.filter { $0.options.contains(.displayInline) }
.map { menuActions($0).map(\.title) }
#expect(groups.contains([expectedCloseAllName]))
}
}
@Test func workspaceToolbarDisablesCloseAllWithoutWorkspaces() async throws {
try await withNavigation(
ToolbarHost(hasWorkspaces: false, closeAll: {}, connect: {}),
language: "en"
) { navigation in
let page = try #require(navigation.topViewController)
let menu: UIMenu
if #available(iOS 27.0, *) {
menu = UIMenu(children: [try #require(page.navigationItem.additionalOverflowItems)])
} else {
menu = try #require(toolbarButton(named: "More", in: navigation)?.menu)
}
let actions = try await resolvedMenuActions(menu, in: page.view)
let closeAll = try #require(actions.first { $0.title == "Close All" })
#expect(closeAll.attributes.contains(.disabled))
let batch = try #require(actions.first { $0.title == "Batch Startup" })
#expect(!batch.attributes.contains(.disabled))
#expect(actions.contains { $0.title == "Clear History" } == false)
}
}
private func withNavigation<Content: View>(
_ content: Content,
language: String,
check: (UINavigationController) 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: NavigationStack { content }.environment(\.locale, Locale(identifier: language)))
window.rootViewController = controller
window.windowLevel = .alert + 1
window.makeKeyAndVisible()
defer {
window.isHidden = true
window.rootViewController = nil
previousWindow?.makeKey()
}
try await Task.sleep(for: .milliseconds(700))
let navigation = try #require(descendant(of: UINavigationController.self, in: controller))
try await check(navigation)
}
private func withStandaloneNavigation<Content: View>(
_ content: Content,
language: String,
check: (UINavigationController) 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.environment(\.locale, Locale(identifier: language)))
window.rootViewController = controller
window.windowLevel = .alert + 1
window.makeKeyAndVisible()
defer {
window.isHidden = true
window.rootViewController = nil
previousWindow?.makeKey()
}
try await Task.sleep(for: .milliseconds(700))
let navigation = try #require(descendant(of: UINavigationController.self, in: controller))
try check(navigation)
}
private func toolbarButton(named name: String, in navigation: UINavigationController) -> UIBarButtonItem? {
guard let item = navigation.topViewController?.navigationItem else { return nil }
let buttons =
item.leadingItemGroups.flatMap(\.barButtonItems)
+ item.trailingItemGroups.flatMap(\.barButtonItems)
+ (item.leftBarButtonItems ?? [])
+ (item.rightBarButtonItems ?? [])
return buttons.first { button in
button.accessibilityLabel == name || button.title == name || button.customView?.accessibilityLabel == name
}
}
private func resolvedMenuActions(_ menu: UIMenu, in view: UIView) async throws -> [UIAction] {
menuActions(try await resolvedMenu(menu, in: view))
}
private func resolvedMenu(_ menu: UIMenu, in view: UIView) async throws -> UIMenu {
let button = UIButton(frame: CGRect(x: 20, y: 100, width: 44, height: 44))
button.menu = menu
button.showsMenuAsPrimaryAction = true
view.addSubview(button)
defer { button.removeFromSuperview() }
button.performPrimaryAction()
try await Task.sleep(for: .milliseconds(500))
let interaction = try #require(button.contextMenuInteraction)
var result: UIMenu?
interaction.updateVisibleMenu { resolved in
result = resolved
return resolved
}
interaction.dismissMenu()
try await Task.sleep(for: .milliseconds(300))
return try #require(result)
}
private func assertAvailability(
_ host: Host, ssh: Bool, telnet: Bool, sftp: Bool,
sourceLocation: SourceLocation = #_sourceLocation
) {
#expect(
MobileConnectionHistoryActionPolicy.isAvailable(.ssh, for: host) == ssh,
sourceLocation: sourceLocation)
#expect(
MobileConnectionHistoryActionPolicy.isAvailable(.telnet, for: host) == telnet,
sourceLocation: sourceLocation)
#expect(
MobileConnectionHistoryActionPolicy.isAvailable(.sftp, for: host) == sftp,
sourceLocation: sourceLocation)
}
}
private struct ToolbarHost: View {
let hasWorkspaces: Bool
let closeAll: () -> Void
let connect: () -> Void
var batchStartup: () -> Void = {}
var body: some View {
Color.clear
.navigationTitle("Workspaces")
.toolbar {
MobileWorkspaceToolbar(
hasWorkspaces: hasWorkspaces,
closeAll: closeAll,
connect: connect,
batchStartup: batchStartup
)
}
}
}