Files
Termind/TFMTests/CommandNotificationTests.swift
T
Kaede Akino ad2bf28790 refactor: remove obsolete application paths and adapters
Remove the transfer forwarding adapter, borrowed SFTP channel scheduling,
legacy assistant send entry points, unreachable Telnet password state,
unused Tailscale APIs, and unused host deletion impact projections.
Migrate behavioral tests to current paths and cover SFTP passphrase saving.

Validation: Swift formatting and shared-copy/localization checks passed.
The macOS test run reported failures in ConnectionStatusIconLayoutTests
and AgentRuntimeTests; investigation and iOS validation remain pending.
2026-10-04 22:10:09 +08:00

272 lines
13 KiB
Swift

import Foundation
import GhosttyTerminal
import TermindApplication
import TermindDomain
import TermindPresentation
import Testing
@testable import Termind
@Suite @MainActor
struct CommandNotificationTests {
@Test(arguments: ["hidden", "navigation", "snippets", "history", "peer", "archive", "visible"])
func assistantNotificationOpensSidebarWithoutReplacingWorkspace(_ state: String) throws {
let app = AppModel(hosts: HostLibraryModel())
let window = WindowModel()
app.registerWindow(window)
let conversation = ConversationModel()
conversation.composer.draft = "Target"
conversation.sendDraft()
window.assistantLibrary.insertAndSelect(conversation)
let workspace = Workspace(pane: Pane(.empty))
workspace.splitFocusedPane(axis: .horizontal, with: Pane(.empty))
window.open(workspace)
let focusedPane = workspace.focusedPane
window.showAssistant()
switch state {
case "hidden": window.showsSidebar = false
case "navigation": window.sidebarMode = .navigation
case "snippets": window.sidebarMode = .snippets
case "history": window.sidebarMode = .history
case "peer":
let peer = ConversationModel()
peer.composer.draft = "Other conversation"
peer.sendDraft()
window.assistantLibrary.insertAndSelect(peer)
case "archive": window.showAssistantArchiveInSidebar()
default: break
}
#expect(window.isShowingAssistant(conversation.id) == (state == "visible"))
#expect(app.isAssistantNotificationFocused(conversation.id) == (state == "visible"))
window.isQuickOpenPresented = true
#expect(app.selectAssistantNotificationTarget(conversation.id) === window)
#expect(window.selectedWorkspace === workspace)
#expect(workspace.focusedPane === focusedPane)
#expect(window.showsSidebar && window.sidebarMode == .assistant)
#expect(window.isShowingAssistant(conversation.id))
#expect(!window.isAssistantArchivePresented)
#expect(!window.isQuickOpenPresented && !window.isToolbarFindFocused)
}
@Test(arguments: [true, false])
func assistantNotificationPrefersFocusThenStableOrderWithinMatchingWindows(_ visible: Bool) throws {
let app = AppModel(hosts: HostLibraryModel())
let windows = [WindowModel(), WindowModel()].sorted { $0.id.uuidString < $1.id.uuidString }
for window in windows { app.registerWindow(window) }
let conversation = ConversationModel()
conversation.composer.draft = "Target"
conversation.sendDraft()
windows[0].assistantLibrary.insertAndSelect(conversation)
for window in windows {
window.openAssistantFullScreen(conversation.id)
if !visible { window.selectVault() }
}
#expect(
app.selectAssistantNotificationTarget(conversation.id, windowIsFocused: { $0 === windows[1] })
=== windows[1])
if !visible { windows[1].selectVault() }
#expect(app.selectAssistantNotificationTarget(conversation.id, windowIsFocused: { _ in false }) === windows[0])
windows[0].closeForWindow()
#expect(app.selectAssistantNotificationTarget(conversation.id) === windows[1])
}
@Test func assistantNotificationReusesHiddenSelectionBeforeAnUnrelatedFocusedWindow() throws {
let app = AppModel(hosts: HostLibraryModel())
let owner = WindowModel()
let other = WindowModel()
app.registerWindow(owner)
app.registerWindow(other)
let conversation = ConversationModel()
conversation.composer.draft = "Target"
conversation.sendDraft()
owner.assistantLibrary.insertAndSelect(conversation)
owner.openAssistantFullScreen(conversation.id)
let workspace = Workspace(pane: Pane(.empty))
owner.open(workspace)
owner.showsSidebar = false
#expect(!app.isAssistantNotificationFocused(conversation.id))
#expect(app.selectAssistantNotificationTarget(conversation.id, windowIsFocused: { $0 === other }) === owner)
#expect(owner.selectedWorkspace === workspace)
#expect(owner.isShowingAssistant(conversation.id))
#expect(other.assistantLibrary.selectedConversationID == nil)
other.openAssistantFullScreen(conversation.id)
owner.showsSidebar = false
#expect(app.selectAssistantNotificationTarget(conversation.id, windowIsFocused: { $0 === owner }) === other)
#expect(!owner.showsSidebar)
}
@Test func notificationRouterUsesTheLatestClickAndConsumesItOnce() async throws {
let app = AppModel(hosts: HostLibraryModel())
let window = WindowModel()
app.registerWindow(window)
let first = ConversationModel()
let second = ConversationModel()
first.composer.draft = "First"
second.composer.draft = "Second"
first.sendDraft()
window.assistantLibrary.insertAndSelect(first)
second.sendDraft()
window.assistantLibrary.insertAndSelect(second)
let activations = AsyncStream<Void>.makeStream()
var count = 0
let router = DesktopNotificationRouter(app: app) { _ in
count += 1
activations.continuation.yield(())
}
router.open(.assistant(first.id))
router.open(.assistant(second.id))
var iterator = activations.stream.makeAsyncIterator()
_ = await iterator.next()
#expect(window.assistantLibrary.selectedConversationID == second.id)
router.resume()
#expect(count == 1)
router.stop()
router.open(.assistant(first.id))
router.resume()
#expect(window.assistantLibrary.selectedConversationID == second.id)
}
@Test func manuallyUnreadWorkspacesCountOnceWithoutRequiringATerminal() {
let app = AppModel(hosts: HostLibraryModel())
let window = WindowModel()
app.registerWindow(window)
let workspace = Workspace(pane: Pane(.empty))
window.open(workspace, select: false)
workspace.markCommandsUnread()
workspace.markCommandsUnread()
#expect(workspace.hasUnreadCommandCompletion)
#expect(window.unreadCompletionCount == 1)
#expect(app.unreadNotificationCount == 1)
workspace.markCommandsRead()
#expect(app.unreadNotificationCount == 0)
#expect(!Workspace(pane: Pane(.empty)).hasUnreadCommandCompletion)
}
@Test func appOwnsCommandNotificationsWithoutChangingThemeOrFontPrecedence() {
let state = TerminalViewState(configSource: .none)
let preferences = TerminalPreferences(
darkCustomEnabled: true,
darkCustomConfiguration: "notify-on-command-finish = always\nbackground = 010203")
state.applyPreferences(preferences)
#expect(state.controller.terminalConfiguration.rendered.contains("notify-on-command-finish = never"))
#expect(!state.theme.dark.rendered.contains("notify-on-command-finish = always"))
#expect(state.theme.dark.rendered.hasSuffix("background = 010203"))
}
@Test func workspaceFocusIncludesEveryPaneAndTargetsFollowMoves() throws {
let host = Host(name: "Build", host: "test.invalid")
let app = AppModel(hosts: HostLibraryModel(hosts: [host]))
let window = WindowModel()
let other = WindowModel()
app.registerWindow(window)
app.registerWindow(other)
let first = Pane.sshSession(host: host)
let second = Pane.sshSession(host: host)
let workspace = Workspace(pane: first)
workspace.splitFocusedPane(axis: .horizontal, with: second)
window.open(workspace)
let firstID = try #require(first.commandSessionID)
let secondID = try #require(second.commandSessionID)
#expect(workspace.focusedPane === second)
#expect(app.isCommandWorkspaceFocused(firstID, windowIsFocused: { $0 === window }))
#expect(app.isCommandWorkspaceFocused(secondID, windowIsFocused: { $0 === window }))
#expect(!app.isCommandWorkspaceFocused(firstID, windowIsFocused: { _ in false }))
window.selectVault()
#expect(app.selectCommandNotificationTarget(firstID) === window)
#expect(window.selectedWorkspace === workspace)
#expect(workspace.focusedPane === first)
window.selectVault()
#expect(!app.isCommandWorkspaceFocused(firstID, windowIsFocused: { _ in true }))
let monitor = try #require(first.commandMonitor)
monitor.beginConnection()
monitor.receiveOSC133(exitCode: 0, durationNanos: 0)
#expect(workspace.hasUnreadCommandCompletion)
#expect(window.unreadCompletionCount == 1)
#expect(other.unreadCompletionCount == 0)
let moved = try #require(window.takePaneForMove(first.id))
other.open(moved)
#expect(!workspace.hasUnreadCommandCompletion)
#expect(moved.hasUnreadCommandCompletion)
#expect(window.unreadCompletionCount == 0)
#expect(other.unreadCompletionCount == 1)
#expect(app.commandNotificationTarget(firstID)?.0 === other)
other.selectVault()
#expect(app.selectCommandNotificationTarget(firstID) === other)
#expect(other.selectedWorkspace === moved)
#expect(moved.focusedPane === first)
moved.markCommandsRead()
#expect(!moved.hasUnreadCommandCompletion)
#expect(other.unreadCompletionCount == 0)
other.close(moved.id)
#expect(app.commandNotificationTarget(firstID) == nil)
#expect(app.selectCommandNotificationTarget(firstID) == nil)
other.reopenLastClosed()
#expect(app.commandNotificationTarget(firstID) == nil)
#expect(first.commandSessionID != firstID)
}
@Test func assistantFocusIncludesVisibleSidebarAndNotificationTargetsReuseTheConversation() throws {
let app = AppModel(hosts: HostLibraryModel())
let window = WindowModel()
let other = WindowModel()
app.registerWindow(window)
app.registerWindow(other)
let conversation = ConversationModel()
conversation.composer.draft = "Test"
conversation.sendDraft()
window.assistantLibrary.insertAndSelect(conversation)
window.openAssistantFullScreen(conversation.id)
#expect(app.isAssistantNotificationFocused(conversation.id))
#expect(app.isAssistantNotificationFocused(UUID()))
window.open(Workspace(pane: Pane(.empty)))
window.sidebarMode = .assistant
window.showsSidebar = true
#expect(window.isShowingAssistant(conversation.id))
window.showsSidebar = false
#expect(!window.isShowingAssistant(conversation.id))
#expect(!app.isAssistantNotificationFocused(conversation.id))
other.openAssistantFullScreen(conversation.id)
#expect(app.selectAssistantNotificationTarget(conversation.id) === other)
#expect(other.assistantLibrary.selectedConversation === conversation)
let peer = ConversationModel()
peer.composer.draft = "Second conversation"
peer.sendDraft()
window.assistantLibrary.insertAndSelect(peer)
window.openAssistantInSidebar(peer.id)
window.showsSidebar = true
other.open(Workspace(pane: Pane(.empty)))
other.sidebarMode = .assistant
other.showsSidebar = true
#expect(app.isAssistantNotificationFocused(peer.id))
#expect(app.isAssistantNotificationFocused(conversation.id))
#expect(!app.isAssistantNotificationFocused(UUID()))
#expect(other.assistantLibrary.conversation(id: peer.id) === peer)
other.isAssistantArchivePresented = true
#expect(!other.isShowingAssistant(conversation.id))
let target = try #require(app.selectAssistantNotificationTarget(UUID()))
#expect(target.selectedDestination == .assistant)
#expect(target.assistantLibrary.selectedConversation == nil)
}
@Test func ghosttyValuesAlreadyPublishedAreNotReplayedWhenObserving() async {
let state = TerminalViewState(configSource: .none)
state.terminalDidFinishCommand(exitCode: 0, durationNanos: 1)
try? await Task.sleep(for: .milliseconds(30))
let monitor = TerminalCommandMonitor()
monitor.beginConnection()
let observation = TerminalCommandObservation(state: state, monitor: monitor)
#expect(!monitor.hasUnreadCompletion)
monitor.endConnection()
state.terminalDidFinishCommand(exitCode: 0, durationNanos: 1)
try? await Task.sleep(for: .milliseconds(30))
#expect(!monitor.hasUnreadCompletion)
withExtendedLifetime(observation) {}
}
}