454 lines
19 KiB
Swift
454 lines
19 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import AppKit
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindInfrastructure
|
|
import Testing
|
|
|
|
@testable import Termind
|
|
|
|
@Suite(.serialized)
|
|
@MainActor
|
|
struct MultiWindowTests {
|
|
@Test
|
|
func moveWaitsForDestinationAndPreservesWorkspaceIdentityAndRouting() throws {
|
|
let host = Host(name: "Server", host: "server.example")
|
|
let app = AppModel(hosts: HostLibraryModel(hosts: [host]))
|
|
let workspace = Workspace.sshSession(host: host)
|
|
let second = Pane.sshSession(host: host)
|
|
workspace.layout = .split(
|
|
SplitNode.Branch(axis: .horizontal, fraction: 0.37, first: workspace.layout, second: .leaf(second)))
|
|
workspace.focusedPaneID = second.id
|
|
workspace.customTitle = "Two terminals"
|
|
workspace.broadcastsInput = true
|
|
let sessions = workspace.panes.compactMap(\.sshSession)
|
|
for session in sessions { session.status = .connected }
|
|
let source = WindowModel(workspaces: [workspace])
|
|
let target = WindowModel()
|
|
let sourceNative = nativeWindow()
|
|
let targetNative = nativeWindow()
|
|
defer {
|
|
sourceNative.close()
|
|
targetNative.close()
|
|
}
|
|
app.mainWindows.register(sourceNative, model: source, requestID: nil)
|
|
var created: [UUID] = []
|
|
app.mainWindows.createScene = { created.append($0) }
|
|
|
|
let request = try #require(app.mainWindows.moveWorkspace(workspace.id, from: source))
|
|
#expect(source.workspaces.first === workspace)
|
|
#expect(target.workspaces.isEmpty)
|
|
#expect(app.mainWindows.moveWorkspace(workspace.id, from: source) == request)
|
|
#expect(Set(created) == [request])
|
|
|
|
app.mainWindows.register(targetNative, model: target, requestID: request)
|
|
#expect(source.workspaces.isEmpty)
|
|
#expect(app.closedWorkspaceRecords.isEmpty)
|
|
#expect(!source.isClosed)
|
|
#expect(source.vaultDestination == .welcome)
|
|
#expect(target.selectedWorkspace === workspace)
|
|
#expect(workspace.lifecycleRouter === target)
|
|
#expect(workspace.focusedPaneID == second.id)
|
|
#expect(workspace.customTitle == "Two terminals")
|
|
#expect(workspace.broadcastsInput)
|
|
for (actual, expected) in zip(workspace.panes.compactMap(\.sshSession), sessions) {
|
|
#expect(actual === expected)
|
|
#expect(actual.status == .connected)
|
|
}
|
|
if case .split(let branch) = workspace.layout {
|
|
#expect(branch.fraction == 0.37)
|
|
} else {
|
|
Issue.record("Split layout was lost")
|
|
}
|
|
|
|
app.mainWindows.register(targetNative, model: target, requestID: request)
|
|
#expect(target.workspaces.count == 1)
|
|
sourceNative.close()
|
|
#expect(sessions.allSatisfy { $0.status == .connected })
|
|
target.workspace(workspace, closePaneAfterSSHSessionExit: second.id)
|
|
#expect(workspace.panes.count == 1)
|
|
#expect(source.workspaces.isEmpty)
|
|
targetNative.close()
|
|
#expect(sessions.allSatisfy { $0.status == .disconnected })
|
|
}
|
|
|
|
@Test
|
|
func concurrentWelcomeAndMetricsRequestsCannotConsumeEachOthersDestination() throws {
|
|
let app = AppModel(hosts: HostLibraryModel())
|
|
app.mainWindows.createScene = { _ in }
|
|
let welcomeRequest = try #require(app.mainWindows.newWindow())
|
|
let metricsRequest = try #require(app.mainWindows.newWindow(showsMetrics: true))
|
|
let welcome = WindowModel()
|
|
let metrics = WindowModel()
|
|
let first = nativeWindow()
|
|
let second = nativeWindow()
|
|
defer {
|
|
first.close()
|
|
second.close()
|
|
}
|
|
app.mainWindows.register(second, model: metrics, requestID: metricsRequest)
|
|
app.mainWindows.register(first, model: welcome, requestID: welcomeRequest)
|
|
#expect(welcome.selectedDestination == .vault)
|
|
#expect(metrics.selectedDestination == .metrics)
|
|
#expect(first.tabbingMode == .disallowed)
|
|
#expect(second.tabbingMode == .disallowed)
|
|
let chat = welcome.assistantLibrary.createConversation()
|
|
#expect(metrics.assistantLibrary.conversation(id: chat.id) === chat)
|
|
welcome.assistantQuery = "local search"
|
|
welcome.isQuickOpenPresented = true
|
|
#expect(metrics.assistantQuery.isEmpty)
|
|
#expect(!metrics.isQuickOpenPresented)
|
|
}
|
|
|
|
@Test
|
|
func closingTheSourceCancelsAnUnclaimedMoveAndCannotResurrectSessions() throws {
|
|
let host = Host(name: "Server", host: "server.example")
|
|
let app = AppModel(hosts: HostLibraryModel(hosts: [host]))
|
|
app.mainWindows.createScene = { _ in }
|
|
let workspace = Workspace.sshSession(host: host)
|
|
let session = try #require(workspace.focusedPane.sshSession)
|
|
session.status = .connected
|
|
let source = WindowModel(workspaces: [workspace])
|
|
let target = WindowModel()
|
|
let first = nativeWindow()
|
|
let second = nativeWindow()
|
|
defer {
|
|
first.close()
|
|
second.close()
|
|
}
|
|
app.mainWindows.register(first, model: source, requestID: nil)
|
|
let request = try #require(app.mainWindows.moveWorkspace(workspace.id, from: source))
|
|
first.close()
|
|
#expect(source.isClosed)
|
|
#expect(session.status == .disconnected)
|
|
app.mainWindows.register(second, model: target, requestID: request)
|
|
#expect(target.workspaces.isEmpty)
|
|
#expect(app.closedWorkspaceRecords.first?.isWindow == true)
|
|
source.closeForWindow()
|
|
session.status = .connecting
|
|
let phase = session.connectionJourney.phase
|
|
session.connectIfNeeded(using: app.hostLibrary)
|
|
#expect(session.connectionJourney.phase == phase)
|
|
}
|
|
|
|
@Test
|
|
func missingFactoryNeverRemovesWorkspaceAndStaleRestorationStartsEmpty() {
|
|
let workspace = Workspace.sshSession(host: Host(name: "Server", host: "server.example"))
|
|
let source = WindowModel(workspaces: [workspace])
|
|
let app = AppModel(hosts: HostLibraryModel())
|
|
#expect(app.mainWindows.moveWorkspace(workspace.id, from: source) == nil)
|
|
#expect(source.selectedWorkspace === workspace)
|
|
let restored = WindowModel()
|
|
let native = nativeWindow()
|
|
defer { native.close() }
|
|
app.mainWindows.register(native, model: restored, requestID: UUID())
|
|
#expect(restored.workspaces.isEmpty)
|
|
#expect(restored.selectedDestination == .vault)
|
|
source.closeForWindow()
|
|
}
|
|
|
|
@Test
|
|
func paywallStaysWithOneWindowAndClosesWithItsOwner() {
|
|
let app = AppModel(hosts: HostLibraryModel())
|
|
let first = nativeWindow()
|
|
let second = nativeWindow()
|
|
let firstModel = WindowModel()
|
|
let secondModel = WindowModel()
|
|
defer {
|
|
first.close()
|
|
second.close()
|
|
}
|
|
app.mainWindows.register(first, model: firstModel, requestID: nil)
|
|
app.mainWindows.register(second, model: secondModel, requestID: nil)
|
|
NotificationCenter.default.post(name: NSWindow.didBecomeKeyNotification, object: first)
|
|
app.proAccess.isPaywallPresented = true
|
|
app.mainWindows.synchronizePaywall()
|
|
#expect(app.mainWindows.paywallOwnerID == ObjectIdentifier(firstModel))
|
|
NotificationCenter.default.post(name: NSWindow.didBecomeKeyNotification, object: second)
|
|
#expect(app.mainWindows.paywallOwnerID == ObjectIdentifier(firstModel))
|
|
first.close()
|
|
#expect(!app.proAccess.isPaywallPresented)
|
|
#expect(app.mainWindows.paywallOwnerID == nil)
|
|
#expect(!secondModel.isClosed)
|
|
}
|
|
|
|
@Test
|
|
func closingDestinationDoesNotCloseAnotherWindowsConnection() throws {
|
|
let host = Host(name: "Server", host: "server.example")
|
|
let app = AppModel(hosts: HostLibraryModel(hosts: [host]))
|
|
let firstWorkspace = Workspace.sshSession(host: host)
|
|
let secondWorkspace = Workspace.sshSession(host: host)
|
|
let firstSession = try #require(firstWorkspace.focusedPane.sshSession)
|
|
let secondSession = try #require(secondWorkspace.focusedPane.sshSession)
|
|
firstSession.status = .connected
|
|
secondSession.status = .connected
|
|
let firstModel = WindowModel(workspaces: [firstWorkspace])
|
|
let secondModel = WindowModel(workspaces: [secondWorkspace])
|
|
let first = nativeWindow()
|
|
let second = nativeWindow()
|
|
defer {
|
|
first.close()
|
|
second.close()
|
|
}
|
|
app.mainWindows.register(first, model: firstModel, requestID: nil)
|
|
app.mainWindows.register(second, model: secondModel, requestID: nil)
|
|
first.close()
|
|
#expect(firstSession.status == .disconnected)
|
|
#expect(secondSession.status == .connected)
|
|
#expect(secondModel.selectedWorkspace === secondWorkspace)
|
|
}
|
|
|
|
@Test
|
|
func remountingMigratedSSHAndSFTPPanesPreservesAuthenticationPrompts() async throws {
|
|
let host = Host(name: "Server", host: "server.example")
|
|
let app = AppModel(hosts: HostLibraryModel(hosts: [host]))
|
|
let sshWorkspace = Workspace.sshSession(host: host)
|
|
let sftpWorkspace = Workspace.sftpChannel(host: host, initialPath: "/srv")
|
|
let ssh = try #require(sshWorkspace.focusedPane.sshSession)
|
|
let sftp = try #require(sftpWorkspace.focusedPane.sftpChannel)
|
|
ssh.connectIfNeeded(using: app.hostLibrary)
|
|
sftp.connectIfNeeded(using: app.hostLibrary)
|
|
let sshPrompt = try #require(ssh.pendingAuthentication)
|
|
for _ in 0..<100 where sftp.pendingAuthentication == nil {
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
let sftpPrompt = try #require(sftp.pendingAuthentication)
|
|
let source = WindowModel(workspaces: [sshWorkspace, sftpWorkspace])
|
|
let destination = WindowModel()
|
|
defer {
|
|
source.closeForWindow()
|
|
destination.closeForWindow()
|
|
}
|
|
destination.open(try #require(source.takeWorkspaceForMove(sshWorkspace.id)))
|
|
destination.open(try #require(source.takeWorkspaceForMove(sftpWorkspace.id)))
|
|
ssh.connectIfNeeded(using: app.hostLibrary)
|
|
sftp.connectIfNeeded(using: app.hostLibrary)
|
|
#expect(ssh.pendingAuthentication?.id == sshPrompt.id)
|
|
#expect(sftp.pendingAuthentication?.id == sftpPrompt.id)
|
|
#expect(sftp.path == "/srv")
|
|
#expect(source.workspaces.isEmpty)
|
|
#expect(destination.workspaces.count == 2)
|
|
}
|
|
|
|
@Test
|
|
func quickOpenUsesTheSameWindowFactoryAndMoveRequest() throws {
|
|
let app = AppModel(hosts: HostLibraryModel())
|
|
var requests: [UUID] = []
|
|
app.mainWindows.createScene = { requests.append($0) }
|
|
let workspace = Workspace.sshSession(host: Host(name: "Server", host: "server.example"))
|
|
let model = WindowModel(workspaces: [workspace])
|
|
defer { model.closeForWindow() }
|
|
let commands = app.commands.commands(app: app, window: model)
|
|
#expect(commands.contains { $0.target == .newWindow })
|
|
#expect(commands.contains { $0.target == .moveWorkspaceToNewWindow(workspace.id) })
|
|
app.commands.perform(.newWindow, app: app, window: model)
|
|
app.commands.perform(.moveWorkspaceToNewWindow(workspace.id), app: app, window: model)
|
|
#expect(requests.count == 2)
|
|
#expect(requests[0] != requests[1])
|
|
#expect(model.selectedWorkspace === workspace)
|
|
}
|
|
|
|
@Test
|
|
func closeCommandConsumesOnePaneAtATimeBeforeClosingTheEmptyWindow() throws {
|
|
let host = Host(name: "Server", host: "server.example")
|
|
let app = AppModel(hosts: HostLibraryModel(hosts: [host]))
|
|
let workspace = Workspace.sshSession(host: host)
|
|
let secondPane = Pane.sshSession(host: host)
|
|
workspace.layout = .split(
|
|
SplitNode.Branch(axis: .horizontal, first: workspace.layout, second: .leaf(secondPane)))
|
|
workspace.focusedPaneID = secondPane.id
|
|
let firstSession = try #require(workspace.layout.firstPane.sshSession)
|
|
let secondSession = try #require(secondPane.sshSession)
|
|
firstSession.status = .connected
|
|
secondSession.status = .connected
|
|
let model = WindowModel(workspaces: [workspace])
|
|
let native = nativeWindow()
|
|
defer { native.close() }
|
|
app.mainWindows.register(native, model: model, requestID: nil)
|
|
|
|
app.mainWindows.closeSelectionOrWindow(in: model)
|
|
|
|
#expect(model.selectedWorkspace === workspace)
|
|
#expect(workspace.panes.count == 1)
|
|
#expect(firstSession.status == .connected)
|
|
#expect(secondSession.status == .disconnected)
|
|
#expect(!model.isClosed)
|
|
|
|
app.mainWindows.closeSelectionOrWindow(in: model)
|
|
|
|
#expect(model.workspaces.isEmpty)
|
|
#expect(app.closedWorkspaceRecords.count == 2)
|
|
#expect(firstSession.status == .disconnected)
|
|
#expect(!model.isClosed)
|
|
|
|
app.mainWindows.closeSelectionOrWindow(in: model)
|
|
|
|
#expect(model.isClosed)
|
|
#expect(app.closedWorkspaceRecords.count == 2)
|
|
}
|
|
|
|
@Test(arguments: [WindowDestination.vault, .settings, .assistant, .metrics])
|
|
func closeCommandFromAPinnedPageRespectsNativeCancellation(destination: WindowDestination) throws {
|
|
let host = Host(name: "Server", host: "server.example")
|
|
let app = AppModel(hosts: HostLibraryModel(hosts: [host]))
|
|
let workspace = Workspace.sshSession(host: host)
|
|
let session = try #require(workspace.focusedPane.sshSession)
|
|
session.status = .connected
|
|
let model = WindowModel(workspaces: [workspace])
|
|
model.selectedDestination = destination
|
|
let native = nativeWindow()
|
|
let approval = CloseApproval()
|
|
native.delegate = approval
|
|
defer { native.close() }
|
|
native.contentView = MainWindowRegistrationView(coordinator: app.mainWindows, model: model, requestID: nil)
|
|
native.isDocumentEdited = true
|
|
#expect(!native.isDocumentEdited)
|
|
|
|
app.mainWindows.closeSelectionOrWindow(in: model)
|
|
|
|
#expect(approval.requests == 1)
|
|
#expect(!model.isClosed)
|
|
#expect(model.selectedDestination == destination)
|
|
#expect(model.workspaces.first === workspace)
|
|
#expect(session.status == .connected)
|
|
#expect(app.closedWorkspaceRecords.isEmpty)
|
|
}
|
|
|
|
@Test(arguments: [false, true])
|
|
func approvedWindowCloseOnlyDisconnectsTheRequestedWindowsSessions(directClose: Bool) throws {
|
|
let host = Host(name: "Server", host: "server.example")
|
|
let app = AppModel(hosts: HostLibraryModel(hosts: [host]))
|
|
let firstWorkspace = Workspace.sshSession(host: host)
|
|
let secondWorkspace = Workspace.sshSession(host: host)
|
|
let firstSession = try #require(firstWorkspace.focusedPane.sshSession)
|
|
let secondSession = try #require(secondWorkspace.focusedPane.sshSession)
|
|
firstSession.status = .connected
|
|
secondSession.status = .connected
|
|
let firstModel = WindowModel(workspaces: [firstWorkspace])
|
|
let secondModel = WindowModel(workspaces: [secondWorkspace])
|
|
if !directClose { firstModel.openSettings() }
|
|
let first = nativeWindow()
|
|
let second = nativeWindow()
|
|
let approval = CloseApproval()
|
|
approval.allowsClose = true
|
|
first.delegate = approval
|
|
defer {
|
|
first.close()
|
|
second.close()
|
|
}
|
|
app.mainWindows.register(first, model: firstModel, requestID: nil)
|
|
app.mainWindows.register(second, model: secondModel, requestID: nil)
|
|
|
|
if directClose {
|
|
app.mainWindows.closeWindow(in: firstModel)
|
|
} else {
|
|
app.mainWindows.closeSelectionOrWindow(in: firstModel)
|
|
}
|
|
|
|
#expect(approval.requests == 1)
|
|
#expect(firstModel.isClosed)
|
|
#expect(firstSession.status == .disconnected)
|
|
#expect(!secondModel.isClosed)
|
|
#expect(secondModel.selectedWorkspace === secondWorkspace)
|
|
#expect(secondSession.status == .connected)
|
|
}
|
|
|
|
@Test
|
|
func registrationSuppressesOnlyItsWindowsEditedIndicatorUntilDetached() {
|
|
let app = AppModel(hosts: HostLibraryModel())
|
|
let model = WindowModel()
|
|
let native = nativeWindow()
|
|
let auxiliary = nativeWindow()
|
|
defer {
|
|
native.close()
|
|
auxiliary.close()
|
|
}
|
|
native.isDocumentEdited = true
|
|
auxiliary.isDocumentEdited = true
|
|
native.contentView = MainWindowRegistrationView(coordinator: app.mainWindows, model: model, requestID: nil)
|
|
#expect(!native.isDocumentEdited)
|
|
native.isDocumentEdited = true
|
|
#expect(!native.isDocumentEdited)
|
|
#expect(auxiliary.isDocumentEdited)
|
|
|
|
native.contentView = nil
|
|
native.isDocumentEdited = true
|
|
#expect(native.isDocumentEdited)
|
|
}
|
|
|
|
@Test(arguments: [WindowDestination.vault, .settings, .assistant, .metrics])
|
|
func closeCommandClosesAnEmptyWindowFromAnyPinnedPage(destination: WindowDestination) {
|
|
let app = AppModel(hosts: HostLibraryModel())
|
|
let model = WindowModel()
|
|
model.selectedDestination = destination
|
|
let native = nativeWindow()
|
|
defer { native.close() }
|
|
app.mainWindows.register(native, model: model, requestID: nil)
|
|
|
|
app.mainWindows.closeSelectionOrWindow(in: model)
|
|
|
|
#expect(model.isClosed)
|
|
}
|
|
|
|
@Test
|
|
func unregisteredWindowFallbackCannotCloseAnotherWindow() {
|
|
let app = AppModel(hosts: HostLibraryModel())
|
|
let registered = WindowModel()
|
|
let unregistered = WindowModel()
|
|
let native = nativeWindow()
|
|
defer { native.close() }
|
|
app.mainWindows.register(native, model: registered, requestID: nil)
|
|
|
|
app.mainWindows.closeSelectionOrWindow(in: unregistered)
|
|
|
|
#expect(!registered.isClosed)
|
|
#expect(!unregistered.isClosed)
|
|
}
|
|
|
|
@Test
|
|
func closeAllWindowsRespectsEachNativeVetoAndClosesEveryApprovedWindow() throws {
|
|
let app = AppModel(hosts: HostLibraryModel())
|
|
let models = (0..<3).map { _ in WindowModel() }
|
|
let windows = models.map { _ in nativeWindow() }
|
|
let approvals = models.map { _ in CloseApproval() }
|
|
defer { for window in windows { window.close() } }
|
|
for index in models.indices {
|
|
approvals[index].allowsClose = index != 1
|
|
windows[index].delegate = approvals[index]
|
|
app.mainWindows.register(windows[index], model: models[index], requestID: nil)
|
|
}
|
|
|
|
app.mainWindows.closeAllWindows()
|
|
|
|
#expect(approvals.map(\.requests) == [1, 1, 1])
|
|
#expect(models.map(\.isClosed) == [true, false, true])
|
|
approvals[1].allowsClose = true
|
|
|
|
app.mainWindows.closeAllWindows()
|
|
|
|
#expect(approvals.map(\.requests) == [1, 2, 1])
|
|
#expect(models.allSatisfy { $0.isClosed })
|
|
}
|
|
|
|
private final class CloseApproval: NSObject, NSWindowDelegate {
|
|
var allowsClose = false
|
|
var requests = 0
|
|
|
|
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
|
requests += 1
|
|
return allowsClose
|
|
}
|
|
}
|
|
|
|
private func nativeWindow() -> NSWindow {
|
|
let window = NSWindow(
|
|
contentRect: CGRect(x: 0, y: 0, width: 800, height: 600),
|
|
styleMask: [.titled, .closable], backing: .buffered, defer: false)
|
|
window.isReleasedWhenClosed = false
|
|
window.animationBehavior = .none
|
|
return window
|
|
}
|
|
}
|