466 lines
21 KiB
Swift
466 lines
21 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import SwiftUI
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPresentation
|
|
import Testing
|
|
import UIKit
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
@testable import TermindIOS
|
|
|
|
@Suite @MainActor
|
|
struct MobileShortcutTests {
|
|
@Test func showHistoryTargetsOnlyThisScenesVaultAndDoesNotPushDuplicates() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
defer { app.outbox.stop() }
|
|
let scene = MobileSceneModel(app: app, sessionID: "owner")
|
|
let other = MobileSceneModel(app: app, sessionID: "other")
|
|
scene.selectedDestination = .settings
|
|
scene.showConnectionHistory()
|
|
scene.showConnectionHistory()
|
|
#expect(scene.selectedDestination == .vault)
|
|
#expect(scene.vaultNavigationPath == NavigationPath([MobileVaultRoute.history]))
|
|
#expect(other.vaultNavigationPath.isEmpty)
|
|
}
|
|
|
|
@Test(arguments: MobileDestination.allCases, [nil, .archived] as [MobileAssistantLibraryRoute?])
|
|
func showAssistantWorksFromEveryPageAndPreservesSceneState(
|
|
destination: MobileDestination, route: MobileAssistantLibraryRoute?
|
|
) throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
defer { app.outbox.stop() }
|
|
let scene = MobileSceneModel(app: app, sessionID: "owner")
|
|
let other = MobileSceneModel(app: app, sessionID: "other")
|
|
scene.assistant.conversation.composer.draft = "Keep this draft"
|
|
let conversationID = scene.assistant.conversation.id
|
|
scene.assistantLibraryRoute = route
|
|
scene.selectedDestination = destination
|
|
|
|
scene.showAssistant()
|
|
scene.showAssistant()
|
|
|
|
#expect(scene.selectedDestination == .assistant)
|
|
#expect(scene.assistantLibraryRoute == route)
|
|
#expect(scene.assistant.conversation.id == conversationID)
|
|
#expect(scene.assistant.conversation.composer.draft == "Keep this draft")
|
|
#expect(other.selectedDestination == .vault)
|
|
}
|
|
|
|
@Test(arguments: [true, false])
|
|
func shortcutMapMatchesDesktopAndHasNoDuplicates(hasWorkspace: Bool) {
|
|
#expect(MobileShortcut.settings == KeyboardShortcut(",", modifiers: .command))
|
|
#expect(MobileShortcut.newWindow == KeyboardShortcut("n", modifiers: .command))
|
|
#expect(MobileShortcut.connect == KeyboardShortcut("t", modifiers: .command))
|
|
#expect(MobileShortcut.batchStartup == KeyboardShortcut("b", modifiers: [.command, .shift]))
|
|
#expect(MobileShortcut.newChat == KeyboardShortcut("t", modifiers: [.command, .option]))
|
|
#expect(MobileShortcut.newHost == KeyboardShortcut("n", modifiers: [.command, .shift]))
|
|
#expect(MobileShortcut.generateCommand == KeyboardShortcut("i", modifiers: .command))
|
|
#expect(MobileShortcut.showAssistant == KeyboardShortcut("i", modifiers: [.command, .option]))
|
|
#expect(MobileShortcut.showHistory == KeyboardShortcut("y", modifiers: .command))
|
|
#expect(MobileShortcut.find == KeyboardShortcut("f", modifiers: .command))
|
|
#expect(MobileShortcut.findNext == KeyboardShortcut("g", modifiers: .command))
|
|
#expect(MobileShortcut.findPrevious == KeyboardShortcut("g", modifiers: [.command, .shift]))
|
|
#expect(MobileShortcut.reopenWorkspace == KeyboardShortcut("t", modifiers: [.command, .shift]))
|
|
#expect(MobileShortcut.nextWorkspace == KeyboardShortcut("]", modifiers: [.command, .shift]))
|
|
#expect(MobileShortcut.previousWorkspace == KeyboardShortcut("[", modifiers: [.command, .shift]))
|
|
#expect(MobileShortcut.send == KeyboardShortcut(.return, modifiers: .command))
|
|
#expect(MobileShortcut.stop == KeyboardShortcut(".", modifiers: .command))
|
|
#expect(MobileShortcut.formDefault == KeyboardShortcut(.return, modifiers: []))
|
|
#expect(MobileShortcut.formCancel == KeyboardShortcut(.escape, modifiers: []))
|
|
|
|
let close = WindowCloseShortcuts(hasWorkspace: hasWorkspace)
|
|
#expect(close.closeWindow == KeyboardShortcut("w", modifiers: hasWorkspace ? [.command, .shift] : .command))
|
|
#expect(close.closeAllWindows == KeyboardShortcut("w", modifiers: [.command, .option]))
|
|
#expect(close.closeWorkspace == (hasWorkspace ? KeyboardShortcut("w", modifiers: .command) : nil))
|
|
let shortcuts =
|
|
[
|
|
MobileShortcut.settings,
|
|
MobileShortcut.newWindow,
|
|
MobileShortcut.connect,
|
|
MobileShortcut.batchStartup,
|
|
MobileShortcut.newChat,
|
|
MobileShortcut.newHost,
|
|
MobileShortcut.generateCommand,
|
|
MobileShortcut.showAssistant,
|
|
MobileShortcut.showHistory,
|
|
MobileShortcut.find,
|
|
MobileShortcut.findNext,
|
|
MobileShortcut.findPrevious,
|
|
MobileShortcut.reopenWorkspace,
|
|
close.closeWindow,
|
|
close.closeAllWindows,
|
|
MobileShortcut.nextWorkspace,
|
|
MobileShortcut.previousWorkspace,
|
|
MobileShortcut.send,
|
|
MobileShortcut.stop,
|
|
MobileShortcut.formDefault,
|
|
MobileShortcut.formCancel,
|
|
] + (1...9).map(MobileShortcut.workspace) + [close.closeWorkspace].compactMap { $0 }
|
|
#expect(Set(shortcuts).count == shortcuts.count)
|
|
}
|
|
|
|
@Test func sceneCommandsNavigateToTheirMobileDestinations() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let scene = MobileSceneModel(app: app, sessionID: "test")
|
|
|
|
scene.openSettings()
|
|
#expect(scene.selectedDestination == .settings)
|
|
|
|
scene.openConnectionPicker()
|
|
#expect(scene.selectedDestination == .workspaces)
|
|
#expect(scene.workspaceID == nil)
|
|
#expect(scene.isConnectionPickerPresented)
|
|
|
|
scene.openNewHost()
|
|
#expect(scene.selectedDestination == .vault)
|
|
#expect(scene.editingHost != nil)
|
|
|
|
scene.assistantLibraryRoute = .archived
|
|
let previousConversationID = scene.assistant.conversation.id
|
|
scene.openNewConversation()
|
|
#expect(scene.selectedDestination == .assistant)
|
|
#expect(scene.assistantLibraryRoute == nil)
|
|
#expect(scene.assistant.conversation.id != previousConversationID)
|
|
}
|
|
|
|
@Test func homeScreenConnectActionUsesTheWorkspaceConnectionPicker() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let scene = MobileSceneModel(app: app, sessionID: "test")
|
|
|
|
#expect(scene.perform(.connect))
|
|
#expect(scene.selectedDestination == .workspaces)
|
|
#expect(scene.workspaceID == nil)
|
|
#expect(scene.isConnectionPickerPresented)
|
|
}
|
|
|
|
@Test func batchStartupCommandPresentsThePickerOnlyInItsScene() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let scene = MobileSceneModel(app: app, sessionID: "first")
|
|
let otherScene = MobileSceneModel(app: app, sessionID: "second")
|
|
scene.openTransfers()
|
|
otherScene.openConnectionPicker()
|
|
|
|
scene.openBatchStartup()
|
|
|
|
#expect(scene.selectedDestination == .workspaces)
|
|
#expect(scene.workspaceDestination == nil)
|
|
#expect(scene.workspaceSheet == .batchStartup)
|
|
#expect(otherScene.selectedDestination == .workspaces)
|
|
#expect(otherScene.workspaceSheet == .connectionPicker)
|
|
#expect(app.workspaces.isEmpty)
|
|
}
|
|
|
|
@Test func homeScreenHistoryActionOpensThePreferredTerminalAndReportsAMissingHost() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let host = Host(name: "Files", host: "files.example.invalid")
|
|
app.library.upsert(host)
|
|
let scene = MobileSceneModel(app: app, sessionID: "test")
|
|
defer { app.testWorkspaceSession.closeAll() }
|
|
|
|
#expect(scene.perform(.history(hostID: host.id)))
|
|
scene.activatePendingWorkspace()
|
|
#expect(scene.activeWorkspace?.descriptor.hostID == host.id)
|
|
#expect(scene.activeWorkspace?.descriptor.kind == .ssh)
|
|
#expect(app.connectionHistory.entries(for: host.id).map(\.kind) == [.ssh])
|
|
|
|
let historyCount = app.connectionHistory.entries().count
|
|
#expect(!scene.perform(.history(hostID: UUID())))
|
|
#expect(scene.isWorkspaceUnavailable)
|
|
#expect(app.connectionHistory.entries().count == historyCount)
|
|
}
|
|
|
|
@Test func hostCardActivationReusesOnlyTheScenesMostRecentSSHWorkspace() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let host = Host(name: "Test", host: "example.invalid")
|
|
app.library.upsert(host)
|
|
let firstScene = MobileSceneModel(app: app, sessionID: "first")
|
|
let secondScene = MobileSceneModel(app: app, sessionID: "second")
|
|
defer {
|
|
firstScene.closeAllWorkspaces()
|
|
secondScene.closeAllWorkspaces()
|
|
}
|
|
firstScene.open(hostID: host.id, kind: .ssh)
|
|
firstScene.activatePendingWorkspace()
|
|
let first = firstScene.workspaceID
|
|
firstScene.open(hostID: host.id, kind: .ssh)
|
|
let mostRecent = firstScene.workspaceID
|
|
secondScene.activateHost(host.id)
|
|
secondScene.activatePendingWorkspace()
|
|
let second = secondScene.workspaceID
|
|
#expect(second != first && second != mostRecent)
|
|
|
|
firstScene.openSettings()
|
|
firstScene.activateHost(host.id)
|
|
firstScene.activatePendingWorkspace()
|
|
secondScene.openSettings()
|
|
secondScene.activateHost(host.id)
|
|
secondScene.activatePendingWorkspace()
|
|
#expect(firstScene.workspaceID == mostRecent)
|
|
#expect(secondScene.workspaceID == second)
|
|
#expect(app.workspaces.count == 3)
|
|
}
|
|
|
|
@Test func hostCardActivationFallsBackToTheNewestMatchingSSHWorkspace() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let host = Host(name: "Test", host: "example.invalid")
|
|
app.library.upsert(host)
|
|
_ = try #require(app.testWorkspaceSession.open(hostID: host.id, kind: .ssh))
|
|
let newestSSH = try #require(app.testWorkspaceSession.open(hostID: host.id, kind: .ssh))
|
|
_ = try #require(app.testWorkspaceSession.open(hostID: host.id, kind: .sftp))
|
|
defer { app.testWorkspaceSession.closeAll() }
|
|
let scene = MobileSceneModel(app: app, sessionID: "test")
|
|
|
|
scene.activateHost(host.id)
|
|
scene.activatePendingWorkspace()
|
|
|
|
#expect(scene.workspaceID == newestSSH)
|
|
#expect(app.workspaces.count == 3)
|
|
}
|
|
|
|
@Test func hostCardActivationCreatesSSHWhenOnlySFTPExists() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let host = Host(name: "Test", host: "example.invalid")
|
|
app.library.upsert(host)
|
|
let sftp = try #require(app.testWorkspaceSession.open(hostID: host.id, kind: .sftp))
|
|
defer { app.testWorkspaceSession.closeAll() }
|
|
let scene = MobileSceneModel(app: app, sessionID: "test")
|
|
|
|
scene.activateHost(host.id)
|
|
scene.activatePendingWorkspace()
|
|
|
|
#expect(app.workspaces.count == 2)
|
|
#expect(scene.workspaceID != sftp)
|
|
#expect(scene.activeWorkspace?.descriptor.hostID == host.id)
|
|
#expect(scene.activeWorkspace?.descriptor.kind == .ssh)
|
|
}
|
|
|
|
@Test(arguments: HostTerminalProtocol.allCases)
|
|
func hostCardActivationCreatesAndReusesTheDefaultTerminal(transport: HostTerminalProtocol) throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
var host = Host(name: "Default transport", host: "example.invalid")
|
|
host.sshEnabled = transport != .telnet
|
|
host.telnetEnabled = true
|
|
host.terminalTransports.moshEnabled = true
|
|
host.terminalTransports.eternalTerminalEnabled = true
|
|
if transport != .telnet { host.terminalTransports.defaultTransport = transport }
|
|
app.library.upsert(host)
|
|
let scene = MobileSceneModel(app: app, sessionID: "test")
|
|
defer {
|
|
scene.closeAllWorkspaces()
|
|
app.outbox.stop()
|
|
}
|
|
|
|
scene.activateHost(host.id)
|
|
scene.activatePendingWorkspace()
|
|
let workspace = try #require(scene.activeWorkspace)
|
|
#expect(workspace.descriptor.kind == .terminal(transport))
|
|
#expect(scene.workspaceActivatedByHostRow(for: host)?.workspace === workspace)
|
|
|
|
scene.selectedDestination = .vault
|
|
scene.activateHost(host.id)
|
|
scene.activatePendingWorkspace()
|
|
#expect(scene.activeWorkspace === workspace)
|
|
#expect(scene.workspaceSession.workspaces.count == 1)
|
|
}
|
|
|
|
@Test func hostCardActivationForgetsAClosedRecentWorkspace() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let host = Host(name: "Test", host: "example.invalid")
|
|
app.library.upsert(host)
|
|
let first = try #require(app.testWorkspaceSession.open(hostID: host.id, kind: .ssh))
|
|
let second = try #require(app.testWorkspaceSession.open(hostID: host.id, kind: .ssh))
|
|
defer { app.testWorkspaceSession.closeAll() }
|
|
let scene = MobileSceneModel(app: app, sessionID: "test")
|
|
scene.selectWorkspace(first)
|
|
scene.activatePendingWorkspace()
|
|
scene.selectWorkspace(second)
|
|
|
|
scene.closeWorkspace(second)
|
|
scene.activateHost(host.id)
|
|
|
|
#expect(scene.workspaceID == first)
|
|
#expect(app.workspaces.map(\.id) == [first])
|
|
}
|
|
|
|
@Test func workspaceCommandsSelectByIndexAndWrapInBothDirections() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let host = Host(name: "Test", host: "example.invalid")
|
|
app.library.upsert(host)
|
|
let first = try #require(app.testWorkspaceSession.open(hostID: host.id, kind: .ssh))
|
|
let second = try #require(app.testWorkspaceSession.open(hostID: host.id, kind: .sftp))
|
|
let third = try #require(app.testWorkspaceSession.open(hostID: host.id, kind: .ssh))
|
|
defer { app.testWorkspaceSession.closeAll() }
|
|
let scene = MobileSceneModel(app: app, sessionID: "test")
|
|
|
|
scene.openSettings()
|
|
scene.selectNextWorkspace()
|
|
#expect(scene.workspaceID == nil)
|
|
scene.activatePendingWorkspace()
|
|
#expect(scene.workspaceID == first)
|
|
#expect(scene.selectedDestination == .workspaces)
|
|
|
|
scene.openSettings()
|
|
scene.selectPreviousWorkspace()
|
|
scene.activatePendingWorkspace()
|
|
#expect(scene.workspaceID == third)
|
|
|
|
scene.selectNextWorkspace()
|
|
#expect(scene.workspaceID == first)
|
|
|
|
scene.selectWorkspace(at: 1)
|
|
#expect(scene.workspaceID == second)
|
|
#expect(!scene.canUseTerminalCommands)
|
|
|
|
scene.selectLastWorkspace()
|
|
#expect(scene.workspaceID == third)
|
|
#expect(scene.canUseTerminalCommands)
|
|
scene.presentTerminalFind()
|
|
#expect(scene.activeWorkspace?.find.isPresented == true)
|
|
scene.activeWorkspace?.find.dismiss()
|
|
|
|
scene.selectWorkspace(at: 99)
|
|
#expect(scene.workspaceID == third)
|
|
}
|
|
|
|
@Test func closeCommandClosesTheLastWorkspaceBeforeRequestingWindowClosure() throws {
|
|
var requestedSessions: [String] = []
|
|
var dependencies = MobileAppDependencies.testing()
|
|
dependencies.closeWorkspaceSession = { id, _ in requestedSessions.append(id) }
|
|
let app = try MobileAppModel(dependencies: dependencies)
|
|
let host = Host(name: "Test", host: "example.invalid")
|
|
app.library.upsert(host)
|
|
let scene = MobileSceneModel(app: app, sessionID: "owner")
|
|
let other = MobileSceneModel(app: app, sessionID: "other")
|
|
defer { app.discardWorkspaceSessions(["owner", "other"]) }
|
|
let first = try #require(scene.workspaceSession.open(hostID: host.id, kind: .ssh))
|
|
let second = try #require(other.workspaceSession.open(hostID: host.id, kind: .sftp))
|
|
scene.selectWorkspace(first)
|
|
scene.activatePendingWorkspace()
|
|
other.selectWorkspace(second)
|
|
other.activatePendingWorkspace()
|
|
|
|
scene.closeSelectionOrWindow()
|
|
|
|
#expect(scene.workspaceSession.orderedWorkspaceItems.isEmpty)
|
|
#expect(scene.workspaceID == nil)
|
|
#expect(requestedSessions.isEmpty)
|
|
#expect(other.activeWorkspace?.id == second)
|
|
|
|
scene.closeSelectionOrWindow()
|
|
|
|
#expect(requestedSessions == ["owner"])
|
|
#expect(other.activeWorkspace?.id == second)
|
|
}
|
|
|
|
@Test(arguments: MobileDestination.allCases)
|
|
func closeCommandWithoutSelectionRequestsOnlyItsOwnWindow(destination: MobileDestination) throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let scene = MobileSceneModel(app: app, sessionID: "owner")
|
|
let other = MobileSceneModel(app: app, sessionID: "other")
|
|
defer { app.discardWorkspaceSessions(["owner", "other"]) }
|
|
var requestedSessions: [String] = []
|
|
app.closeWorkspaceSession = { id, _ in requestedSessions.append(id) }
|
|
scene.selectedDestination = destination
|
|
|
|
scene.closeSelectionOrWindow()
|
|
|
|
#expect(requestedSessions == [scene.workspaceSession.id])
|
|
#expect(!other.workspaceSession.isDiscarded)
|
|
#expect(app.error == nil)
|
|
#expect(!scene.isWorkspaceUnavailable)
|
|
}
|
|
|
|
@Test func closeCommandOnlyClosesAWorkspaceVisibleInThisScene() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let host = Host(name: "Test", host: "example.invalid")
|
|
app.library.upsert(host)
|
|
let first = try #require(app.testWorkspaceSession.open(hostID: host.id, kind: .ssh))
|
|
let second = try #require(app.workspaceSession(for: "other").open(hostID: host.id, kind: .sftp))
|
|
defer { app.discardWorkspaceSessions(["test", "other"]) }
|
|
let scene = MobileSceneModel(app: app, sessionID: "test")
|
|
let otherScene = MobileSceneModel(app: app, sessionID: "other")
|
|
otherScene.selectWorkspace(second)
|
|
otherScene.activatePendingWorkspace()
|
|
|
|
scene.selectWorkspace(first)
|
|
scene.activatePendingWorkspace()
|
|
#expect(scene.canCloseWorkspace)
|
|
scene.openSettings()
|
|
#expect(!scene.canCloseWorkspace)
|
|
scene.closeActiveWorkspace()
|
|
#expect(app.workspaces.map(\.id) == [first, second])
|
|
|
|
scene.selectWorkspace(first)
|
|
scene.activatePendingWorkspace()
|
|
scene.closeActiveWorkspace()
|
|
#expect(app.workspaces.map(\.id) == [second])
|
|
#expect(scene.workspaceID == nil)
|
|
#expect(otherScene.workspaceID == second)
|
|
#expect(otherScene.activeWorkspace?.id == second)
|
|
}
|
|
|
|
@Test func terminalFunctionKeysFollowDesktopFindDirection() throws {
|
|
var actions: [String] = []
|
|
let find = TerminalFindController(perform: { actions.append($0) }, focus: {})
|
|
let view = MobileTerminalView(frame: .zero)
|
|
view.findController = find
|
|
find.updateQuery("needle")
|
|
actions.removeAll()
|
|
|
|
let next = try keyCommand(in: view, input: UIKeyCommand.f3, modifiers: [])
|
|
#expect(UIApplication.shared.sendAction(try #require(next.action), to: view, from: next, for: nil))
|
|
#expect(
|
|
actions == [
|
|
TerminalFindBinding.search("needle"),
|
|
TerminalFindBinding.navigate(.previous),
|
|
])
|
|
|
|
actions.removeAll()
|
|
let previous = try keyCommand(in: view, input: UIKeyCommand.f3, modifiers: .shift)
|
|
#expect(UIApplication.shared.sendAction(try #require(previous.action), to: view, from: previous, for: nil))
|
|
#expect(actions == [TerminalFindBinding.navigate(.next)])
|
|
}
|
|
|
|
@Test func terminalEscapeReturnsToGhosttyUnlessFindIsPresented() throws {
|
|
var actions: [String] = []
|
|
var focusCount = 0
|
|
let find = TerminalFindController(
|
|
perform: { actions.append($0) },
|
|
focus: { focusCount += 1 }
|
|
)
|
|
let view = MobileTerminalView(frame: .zero)
|
|
view.findController = find
|
|
|
|
let terminalEscape = try keyCommand(in: view, input: UIKeyCommand.inputEscape, modifiers: [])
|
|
#expect(!NSStringFromSelector(try #require(terminalEscape.action)).contains("handleDismissFindCommand"))
|
|
|
|
find.present()
|
|
let dismiss = try keyCommand(in: view, input: UIKeyCommand.inputEscape, modifiers: [])
|
|
#expect(NSStringFromSelector(try #require(dismiss.action)).contains("handleDismissFindCommand"))
|
|
#expect(UIApplication.shared.sendAction(try #require(dismiss.action), to: view, from: dismiss, for: nil))
|
|
#expect(!find.isPresented)
|
|
#expect(actions == [TerminalFindBinding.end])
|
|
#expect(focusCount == 1)
|
|
|
|
let shifted = try keyCommand(in: view, input: UIKeyCommand.inputEscape, modifiers: .shift)
|
|
#expect(!NSStringFromSelector(try #require(shifted.action)).contains("handleDismissFindCommand"))
|
|
}
|
|
|
|
private func keyCommand(
|
|
in view: MobileTerminalView,
|
|
input: String,
|
|
modifiers: UIKeyModifierFlags
|
|
) throws -> UIKeyCommand {
|
|
try #require(
|
|
view.keyCommands?.first {
|
|
$0.input == input
|
|
&& $0.modifierFlags.intersection([.command, .control, .alternate, .shift]) == modifiers
|
|
}
|
|
)
|
|
}
|
|
}
|