// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import Foundation import SwiftData import SwiftUI import TermindApplication import TermindDomain import TermindPersistence import TermindPresentation import Testing import UIKit import struct TermindDomain.Host @testable import TermindIOS @Suite @MainActor struct MobileWorkspaceSessionTests { @Test func artworkRefreshesForOpenWorkspacesWithoutReconnecting() async throws { let app = try MobileAppModel(inMemory: true) let host = Host(name: "Mac", host: "example.invalid", shellIntegrationMode: .enabled) app.library.upsert(host) let session = app.testWorkspaceSession defer { session.closeAll() } let id = try #require(session.open(hostID: host.id, kind: .ssh)) let workspace = try #require(session.openWorkspace(id)) let originalStatus = workspace.status workspace.receiveShellEvent( .available( shell: "zsh", systemInfo: RemoteSystemInfo(kernelName: "Darwin"), homeDirectory: "/Users/test")) #expect(workspace.activitySession.iconAsset == "HostOS-macos") var draft = try #require(app.library.host(host.id)) draft.iconOverrideID = "windows" #expect(app.library.saveHost(draft)) for _ in 0..<50 where workspace.activitySession.iconAsset != "HostOS-windows" { try await Task.sleep(for: .milliseconds(10)) } #expect(workspace.activitySession.iconAsset == "HostOS-windows") workspace.receiveShellEvent( .systemInfo( RemoteSystemInfo(distributionID: "ubuntu", kernelName: "Linux"))) #expect(workspace.activitySession.iconAsset == "HostOS-windows") for (choice, symbol) in [ ("cloud", "cloud"), ("database", "cylinder"), ("terminal", "terminal"), ("database", "cylinder"), ("cloud", "cloud"), ] { draft = try #require(app.library.host(host.id)) draft.iconOverrideID = choice #expect(app.library.saveHost(draft)) #expect(app.library.host(host.id)?.iconOverrideID == choice) #expect(HostIconCatalog.resolvedArtwork(for: app.library.host(host.id)).systemImageName == symbol) for _ in 0..<50 where workspace.activitySession.iconSystemName != symbol { try await Task.sleep(for: .milliseconds(10)) } #expect(app.library.host(host.id)?.iconOverrideID == choice) #expect(HostIconCatalog.resolvedArtwork(for: app.library.host(host.id)).systemImageName == symbol) #expect(workspace.activitySession.iconAsset == nil) #expect(workspace.activitySession.iconSystemName == symbol) } draft = try #require(app.library.host(host.id)) draft.iconOverrideID = nil app.applyRemoteVault(.init(hosts: [draft]), keys: [.init(kind: .host, id: host.id)]) for _ in 0..<50 where workspace.activitySession.iconAsset != "LinuxDistro-ubuntu" { try await Task.sleep(for: .milliseconds(10)) } #expect(workspace.activitySession.iconAsset == "LinuxDistro-ubuntu") #expect(workspace.activitySession.iconSystemName == nil) workspace.receiveShellEvent(.systemInfo(RemoteSystemInfo(kernelName: "DragonFly"))) #expect(workspace.activitySession.iconAsset == "HostOS-bsd") #expect(app.library.host(host.id)?.detectedDistribution?.id == "dragonfly") workspace.receiveShellEvent(.systemInfo(RemoteSystemInfo(distributionID: "custom-linux", kernelName: "Linux"))) #expect(workspace.activitySession.iconAsset == "HostOS-linux") #expect(workspace.status == originalStatus) #expect(session.openWorkspace(id) === workspace) } @Test func recentlyClosedIsSharedOnlyWithinOneProcess() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let first = app.workspaceSession(for: "first") let other = app.workspaceSession(for: "other") let id = try #require(first.open(hostID: fixture.host.id, kind: .ssh)) first.close(id) #expect(other.canReopen) #expect(other.reopenLastClosed() == id) #expect(!first.canReopen) other.close(id) #expect(try fixture.makeApp().closedWorkspaceRecords.isEmpty) } @Test func discardedWindowsAreGroupedAndReopenedInANewSession() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let source = MobileSceneModel(app: app, sessionID: "source") source.open(hostID: fixture.host.id, kind: .ssh) source.open(hostID: fixture.host.id, kind: .sftp) let ids = source.workspaceSession.orderedWorkspaceItems.map(\.id) app.discardWorkspaceSessions(["source"]) let record = try #require(app.closedWorkspaceRecords.first) #expect(record.isWindow) #expect(record.items.map(\.id) == ids) #expect(app.closedWorkspaceRecords.count == 1) let current = MobileSceneModel(app: app, sessionID: "current") var requests: [UUID] = [] app.createWorkspaceWindow = { id, _ in requests.append(id) } current.reopenLastClosedWorkspace() current.reopenLastClosedWorkspace() #expect(requests == [record.id]) #expect(current.workspaceSession.orderedWorkspaceItems.isEmpty) #expect(app.closedWorkspaceRecords.count == 1) let destination = MobileSceneModel(app: app, sessionID: "destination") let selected = app.restoreClosedWindow(record.id, into: destination.workspaceSession) #expect(selected == ids.first) #expect(destination.workspaceSession.orderedWorkspaceItems.map(\.id) == ids) #expect(app.closedWorkspaceRecords.isEmpty) #expect(app.restoreClosedWindow(record.id, into: destination.workspaceSession) == nil) } @Test func windowChildRestoreAndFailedNewWindowRequestsPreserveRemainingRecords() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let source = MobileSceneModel(app: app, sessionID: "source") source.open(hostID: fixture.host.id, kind: .ssh) source.open(hostID: fixture.host.id, kind: .sftp) app.discardWorkspaceSessions(["source"]) let record = try #require(app.closedWorkspaceRecords.first) let current = MobileSceneModel(app: app, sessionID: "current") let first = try #require(record.items.first) #expect(current.workspaceSession.reopenClosedItem(first.id, in: record.id) == first.id) #expect(app.closedWorkspaceRecords.first?.items.count == 1) app.createWorkspaceWindow = { _, failed in failed() } current.reopenLastClosedWorkspace() #expect(app.closedWorkspaceRecords.first?.items.count == 1) #expect(app.pendingClosedWindows.isEmpty) } @Test func recentClosedCanRestoreAnOlderItemWithoutConsumingTheNewest() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let records = (0..<3).map { MobileWorkspaceDescriptor(hostID: fixture.host.id, title: "Workspace \($0)", kind: .ssh) } let app = try fixture.makeApp() let session = app.workspaceSession(for: "owner") session.restoreClosedStates(records.map { .init(descriptor: $0) }) session.closeAll() #expect(session.recentlyClosedRecords.flatMap(\.items).map(\.id) == records.reversed().map(\.id)) #expect(session.reopenClosedWorkspace(records[0].id) == records[0].id) #expect(session.recentlyClosedRecords.flatMap(\.items).map(\.id) == [records[2].id, records[1].id]) #expect(session.reopenClosedWorkspace(records[0].id) == nil) #expect(session.reopenLastClosed() == records[2].id) } @Test func reopenRestoresTheOwningWindowsWorkspaceWithFreshHistoryAndRouting() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let owner = MobileSceneModel(app: app, sessionID: "owner") let other = MobileSceneModel(app: app, sessionID: "other") owner.open(hostID: fixture.host.id, kind: .sftp) owner.activatePendingWorkspace() let original = try #require(owner.activeWorkspace) #expect(owner.workspaceSession.renameWorkspace(original.id, to: "Files")) other.open(hostID: fixture.host.id, kind: .ssh) other.activatePendingWorkspace() let otherWorkspace = try #require(other.activeWorkspace) owner.closeActiveWorkspace() #expect(owner.workspaceSession.canReopen) #expect(other.workspaceSession.canReopen) #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) owner.openSettings() owner.reopenLastClosedWorkspace() owner.activatePendingWorkspace() let reopened = try #require(owner.activeWorkspace) #expect(reopened !== original) #expect(reopened.descriptor == original.descriptor) #expect(owner.selectedDestination == .workspaces) reopened.sftpHistory.record(kind: .directory, path: "/srv") #expect(reopened.sftpHistory.entries().first?.path == "/srv") #expect(other.activeWorkspace === otherWorkspace) #expect(!owner.workspaceSession.canReopen) #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) owner.reopenLastClosedWorkspace() #expect(owner.workspaceSession.workspaces.count == 1) } @Test func reopenUsesLastClosedOrderForDormantAndBulkClosedWorkspaces() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let records = (0..<3).map { MobileWorkspaceDescriptor(hostID: fixture.host.id, title: "Workspace \($0)", kind: .ssh) } let app = try fixture.makeApp() let session = app.workspaceSession(for: "owner") session.restoreClosedStates(records.map { .init(descriptor: $0) }) session.closeOtherWorkspaces(than: records[0].id) #expect(session.reopenLastClosed() == records[2].id) #expect(session.reopenLastClosed() == records[1].id) #expect(!session.canReopen) session.closeAll() #expect(session.reopenLastClosed() == records[1].id) #expect(session.reopenLastClosed() == records[2].id) #expect(session.reopenLastClosed() == records[0].id) #expect(session.reopenLastClosed() == nil) } @Test func reopenKeepsExternalConnectionsTransientAndDoesNotReplayOneShotCommands() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let scene = MobileSceneModel(app: app, sessionID: "owner") scene.open(try #require(URL(string: "ssh://tester@external.invalid:2222"))) scene.activatePendingWorkspace() let external = try #require(scene.activeWorkspace) scene.closeActiveWorkspace() scene.reopenLastClosedWorkspace() let reopened = try #require(scene.activeWorkspace) #expect(reopened.id == external.id) #expect(reopened.isExternalTemporaryConnection) #expect(reopened.temporaryLoginAddress == external.temporaryLoginAddress) #expect(reopened.presentationHost?.port == 2222) #expect(scene.workspaceSession.savedWorkspaceDescriptors.isEmpty) #expect(app.library.host(external.descriptor.hostID) == nil) scene.execute(Snippet(title: "One shot", body: "echo one-shot"), on: [fixture.host.id]) #expect(scene.activeWorkspace?.pendingInitialCommand == "echo one-shot") scene.closeActiveWorkspace() scene.reopenLastClosedWorkspace() #expect(scene.activeWorkspace?.descriptor.hostID == fixture.host.id) #expect(scene.activeWorkspace?.pendingInitialCommand == nil) #expect(scene.activeWorkspace?.commandHistory.record("echo after-reopen") != nil) } @Test func reopenHistoryIsBoundedAndClearedWhenTheWindowIsDiscarded() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let session = app.workspaceSession(for: "owner") var closed: [UUID] = [] for _ in 0..<17 { let id = try #require(session.open(hostID: fixture.host.id, kind: .ssh)) session.close(id) closed.append(id) } var reopened: [UUID] = [] while let id = session.reopenLastClosed() { reopened.append(id) } #expect(reopened == Array(closed.suffix(15).reversed())) session.closeAll() #expect(session.canReopen) app.discardWorkspaceSessions(["owner"]) #expect(!session.canReopen) #expect(session.reopenLastClosed() == nil) } @Test func deletingAHostForgetsClosedWorkspacesAndDoesNotCreateNewReopenEntries() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let session = app.workspaceSession(for: "owner") let id = try #require(session.open(hostID: fixture.host.id, kind: .ssh)) session.close(id) _ = session.open(hostID: fixture.host.id, kind: .sftp) #expect(session.canReopen) #expect(app.deleteHost(fixture.host.id)) #expect(!session.canReopen) #expect(session.reopenLastClosed() == nil) #expect(session.workspaces.isEmpty) } @Test func deletingAHostBeforeBindingAWindowRemovesPersistedClosedWorkspaces() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let firstApp = try fixture.makeApp() let session = firstApp.workspaceSession(for: "owner") let id = try #require(session.open(hostID: fixture.host.id, kind: .ssh)) session.close(id) let nextApp = try fixture.makeApp() #expect(nextApp.recentlyClosedStore.entries.isEmpty) #expect(firstApp.closedWorkspaceRecords.flatMap(\.items).map(\.id) == [id]) #expect(nextApp.deleteHost(fixture.host.id)) #expect(nextApp.recentlyClosedStore.entries.isEmpty) let thirdApp = try fixture.makeApp() #expect(thirdApp.recentlyClosedStore.entries.isEmpty) } @Test func agentForwardingStaysWithTheConversationScene() async throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let owner = MobileSceneModel(app: app, sessionID: "agent-owner") let other = MobileSceneModel(app: app, sessionID: "agent-other") owner.agentForwarding.isActive = true other.agentForwarding.isActive = true let conversation = ConversationModel() conversation.composer.draft = "Agent forwarding" conversation.sendDraft() owner.assistant.library.insertAndSelect(conversation) owner.openAssistantNotification(conversation.id) let hostID = UUID() let request = SSHConnectionRequest( configuration: .init( hostID: hostID, host: "fixture", port: 22, username: "user", credentials: [], expectedHostKeys: []), agentForwardingContextID: conversation.id) let context = app.agentForwardingRouter.context(for: request) #expect(context.canInteract()) let handler = try #require(context.unlock) let pending = Task { try await handler( .init( connectionID: UUID(), hostID: hostID, hostname: "fixture", port: 22, username: "user", round: 0, name: "Agent Forwarding", instruction: "Fixture", prompts: [.init(text: "Passphrase", echo: false)])) } for _ in 0..<100 where owner.agentForwarding.current == nil { await Task.yield() } #expect(owner.agentForwarding.current != nil) #expect(other.agentForwarding.current == nil) owner.agentForwarding.isActive = false #expect(!context.canInteract()) await #expect(throws: CancellationError.self) { try await pending.value } } @Test(arguments: [true, false]) func assistantNotificationPrefersActiveThenStableOrderWithinMatchingScenes(_ visible: Bool) throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let scenes = [ MobileSceneModel(app: app, sessionID: "matching-a"), MobileSceneModel(app: app, sessionID: "matching-b"), ].sorted { $0.id.uuidString < $1.id.uuidString } let conversation = ConversationModel() conversation.composer.draft = "Target" conversation.sendDraft() scenes[0].assistant.library.insertAndSelect(conversation) for scene in scenes { scene.openAssistantNotification(conversation.id) if !visible { scene.selectedDestination = .vault } } app.sceneChanged(scenes[0].id, phase: .inactive) app.sceneChanged(scenes[1].id, phase: .active) #expect(app.assistantNotificationTarget(conversation.id) === scenes[1]) #expect(scenes[1].isShowingAssistant(conversation.id) == visible) app.sceneChanged(scenes[0].id, phase: .active) #expect(app.assistantNotificationTarget(conversation.id) === scenes[0]) app.removeScene(scenes[0].id) #expect(app.assistantNotificationTarget(conversation.id) === scenes[1]) } @Test(arguments: [true, false]) func assistantNotificationReusesHiddenSelectionBeforeAnUnrelatedActiveScene(_ libraryPresented: Bool) async throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let owner = MobileSceneModel(app: app, sessionID: "hidden-owner") let other = MobileSceneModel(app: app, sessionID: "active-other") let conversation = ConversationModel() conversation.composer.draft = "Target" conversation.sendDraft() owner.assistant.library.insertAndSelect(conversation) owner.openAssistantNotification(conversation.id) if libraryPresented { owner.assistantLibraryRoute = .conversations } else { owner.selectedDestination = .vault } app.sceneChanged(other.id, phase: .active) app.sceneChanged(owner.id, phase: .background) #expect(!app.isAssistantNotificationFocused(conversation.id)) var activations: [String] = [] let router = MobileNotificationRouter(app: app) { sessionID, _ in activations.append(sessionID) } router.open(.assistant(conversation.id)) #expect(activations == [owner.workspaceSession.id]) #expect(!owner.isShowingAssistant(conversation.id)) #expect(other.assistant.library.selectedConversationID == nil) router.resume() #expect(activations.count == 1) app.sceneChanged(owner.id, phase: .active) router.resume() try await waitUntil { owner.isShowingAssistant(conversation.id) } router.stop() owner.selectedDestination = .vault other.openAssistantNotification(conversation.id) app.sceneChanged(owner.id, phase: .active) app.sceneChanged(other.id, phase: .background) #expect(app.assistantNotificationTarget(conversation.id) === other) #expect(owner.selectedDestination == .vault) } @Test func notificationRouterWaitsForASceneAndConsumesTheTransferClickOnce() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() var activated: [String] = [] let router = MobileNotificationRouter(app: app) { sessionID, _ in activated.append(sessionID) } router.open(.transfers) #expect(activated.isEmpty) let scene = MobileSceneModel(app: app, sessionID: "notification-routing") router.resume() #expect(!scene.showsTransfers) #expect(activated.isEmpty) app.sceneChanged(scene.id, phase: .active) router.resume() #expect(scene.showsTransfers) #expect(activated.isEmpty) scene.workspaceDestination = nil router.resume() #expect(!scene.showsTransfers) router.stop() router.open(.transfers) #expect(!scene.showsTransfers) #expect(activated.isEmpty) let foregroundRouter = MobileNotificationRouter(app: app) { sessionID, _ in activated.append(sessionID) } foregroundRouter.open(.transfers) #expect(scene.showsTransfers) #expect(activated.isEmpty) foregroundRouter.stop() } @Test func assistantNotificationRouterUsesTheLatestClick() async throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let scene = MobileSceneModel(app: app, sessionID: "assistant-routing") let first = ConversationModel() let second = ConversationModel() first.composer.draft = "First" second.composer.draft = "Second" first.sendDraft() scene.assistant.library.insertAndSelect(first) second.sendDraft() scene.assistant.library.insertAndSelect(second) app.sceneChanged(scene.id, phase: .active) var activations: [String] = [] scene.assistant.library.select(first.id) scene.selectedDestination = .vault let router = MobileNotificationRouter(app: app) { sessionID, _ in activations.append(sessionID) } router.open(.assistant(first.id)) router.open(.assistant(second.id)) try await waitUntil { scene.isShowingAssistant(second.id) } #expect(activations.isEmpty) #expect(scene.assistant.library.selectedConversationID == second.id) router.stop() } @Test func notificationActivationRetriesAndIgnoresFailuresFromEarlierClicks() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let owner = MobileSceneModel(app: app, sessionID: "retry-notification") let other = MobileSceneModel(app: app, sessionID: "active-notification") owner.open(hostID: fixture.host.id, kind: .ssh) owner.activatePendingWorkspace() let workspaceID = try #require(owner.activeWorkspace?.id) owner.selectedDestination = .vault app.sceneChanged(owner.id, phase: .background) app.sceneChanged(other.id, phase: .active) var activations: [String] = [] var failures: [@MainActor () -> Void] = [] let router = MobileNotificationRouter(app: app) { sessionID, failed in activations.append(sessionID) failures.append(failed) } router.open(.terminal(workspaceID)) #expect(activations == [owner.workspaceSession.id]) #expect(owner.selectedDestination == .vault) let firstFailure = try #require(failures.first) firstFailure() router.resume() #expect(activations.count == 2) let retryFailure = try #require(failures.last) router.open(.terminal(workspaceID)) #expect(activations == Array(repeating: owner.workspaceSession.id, count: 3)) // The earlier request can fail after a new click has requested this same window. retryFailure() router.resume() #expect(activations.count == 3) app.sceneChanged(owner.id, phase: .active) router.resume() #expect(owner.selectedDestination == .workspaces) router.stop() } @Test func transferNotificationOpensTransfersWithoutMarkingUnread() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let scene = MobileSceneModel(app: app, sessionID: "transfers") scene.open(hostID: fixture.host.id, kind: .sftp) scene.activatePendingWorkspace() app.sceneChanged(scene.id, phase: .active) #expect(app.transferNotificationTarget() === scene) #expect(!scene.showsTransfers) let router = MobileNotificationRouter(app: app) { _, _ in Issue.record("Scene is already active") } defer { router.stop() } router.open(.transfers) #expect(scene.selectedDestination == .workspaces && scene.showsTransfers && scene.workspaceID == nil) #expect(app.unreadNotificationCount == 0) } @Test func manuallyUnreadWorkspacesFollowTheirWindowWithoutEnteringSavedDescriptors() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let first = app.workspaceSession(for: "unread-a") let second = app.workspaceSession(for: "unread-b") let id = try #require(first.open(hostID: fixture.host.id, kind: .sftp)) first.markUnread(id) first.markUnread(id) #expect(first.unreadCompletionCount == 1 && second.unreadCompletionCount == 0) #expect(app.unreadNotificationCount == 1) let fresh = MobileWorkspaceSessionModel(id: "fresh", app: app, descriptors: first.savedWorkspaceDescriptors) #expect(fresh.unreadCompletionCount == 0) let recovery = first.recovery first.releaseForRecovery() second.acceptRecovery(recovery) #expect(first.unreadCompletionCount == 0 && second.unreadCompletionCount == 1) second.markRead(id) #expect(app.unreadNotificationCount == 0) second.markUnread(id) second.close(id) #expect(second.unreadWorkspaceIDs.isEmpty) } @Test func assistantNotificationsRespectSceneFocusAndOpenTheExistingConversation() async throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let first = MobileSceneModel(app: app, sessionID: "assistant-a") let second = MobileSceneModel(app: app, sessionID: "assistant-b") let conversation = ConversationModel() conversation.composer.draft = "Test" conversation.sendDraft() first.assistant.library.insertAndSelect(conversation) first.openAssistantNotification(conversation.id) app.sceneChanged(first.id, phase: .active) #expect(app.isAssistantNotificationFocused(conversation.id)) first.assistantLibraryRoute = .conversations #expect(!app.isAssistantNotificationFocused(conversation.id)) first.assistantLibraryRoute = nil app.sceneChanged(second.id, phase: .active) let peer = ConversationModel() peer.composer.draft = "Second conversation" peer.sendDraft() second.assistant.library.insertAndSelect(peer) second.openAssistantNotification(peer.id) #expect(app.isAssistantNotificationFocused(conversation.id)) #expect(app.isAssistantNotificationFocused(peer.id)) #expect(first.assistant.library.conversation(id: peer.id) === peer) app.sceneChanged(first.id, phase: .background) #expect(app.isAssistantNotificationFocused(conversation.id)) #expect(app.assistantNotificationTarget(conversation.id) === first) #expect(first.assistant.conversation === conversation) first.selectedDestination = .vault #expect(!app.isAssistantNotificationFocused(conversation.id)) #expect(app.isAssistantNotificationFocused(peer.id)) let missingConversationID = UUID() #expect(app.assistantNotificationTarget(missingConversationID) === second) #expect(second.assistantLibraryRoute == nil) let router = MobileNotificationRouter(app: app) { _, _ in Issue.record("Scene is already active") } defer { router.stop() } router.open(.assistant(missingConversationID)) try await waitUntil { second.assistantLibraryRoute == .conversations } } @Test func commandCompletionDotsUseVisibleWorkspaceInItsOwningScene() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let first = MobileSceneModel(app: app, sessionID: "command-a") let second = MobileSceneModel(app: app, sessionID: "command-b") first.open(hostID: fixture.host.id, kind: .ssh) second.open(hostID: fixture.host.id, kind: .ssh) first.activatePendingWorkspace() second.activatePendingWorkspace() let workspace = try #require(first.activeWorkspace) app.sceneChanged(second.id, phase: .active) app.sceneChanged(first.id, phase: .background) workspace.commandMonitor.beginConnection() workspace.commandMonitor.receiveOSC133(exitCode: 1, durationNanos: 1) #expect(workspace.commandMonitor.hasUnreadCompletion) #expect(first.workspaceSession.hasUnreadCompletion) #expect(!second.workspaceSession.hasUnreadCompletion) #expect(!app.isCommandWorkspaceFocused(workspace.id)) #expect(!second.activeWorkspace!.commandMonitor.hasUnreadCompletion) app.sceneChanged(first.id, phase: .active) #expect(app.isCommandWorkspaceFocused(workspace.id)) #expect(!workspace.commandMonitor.hasUnreadCompletion) #expect(!first.workspaceSession.hasUnreadCompletion) workspace.commandMonitor.receiveOSC133(exitCode: 0, durationNanos: 1) #expect(!workspace.commandMonitor.hasUnreadCompletion) first.workspaceID = nil workspace.commandMonitor.receiveOSC133(exitCode: nil, durationNanos: 0) #expect(workspace.commandMonitor.hasUnreadCompletion) first.selectWorkspace(workspace.id) #expect(!workspace.commandMonitor.hasUnreadCompletion) first.closeWorkspace(workspace.id) workspace.commandMonitor.receiveOSC133(exitCode: 0, durationNanos: 1) #expect(!workspace.commandMonitor.hasUnreadCompletion) } @Test func commandNotificationWaitsForItsBackgroundWindowToActivate() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let owner = MobileSceneModel(app: app, sessionID: "command-owner") let other = MobileSceneModel(app: app, sessionID: "command-other") owner.open(hostID: fixture.host.id, kind: .ssh) owner.activatePendingWorkspace() let workspaceID = try #require(owner.activeWorkspace?.id) owner.selectedDestination = .vault app.sceneChanged(owner.id, phase: .background) var activations: [String] = [] let router = MobileNotificationRouter(app: app) { sessionID, _ in activations.append(sessionID) } router.open(.terminal(workspaceID)) // Wait for UIKit to foreground the app before requesting a different owner window. router.resume() #expect(activations.isEmpty) #expect(owner.selectedDestination == .vault) app.sceneChanged(other.id, phase: .active) router.resume() #expect(owner.selectedDestination == .vault) #expect(owner.workspaceID == workspaceID) #expect(other.selectedDestination == .vault) #expect(activations.count == 1) #expect(activations.first == owner.workspaceSession.id) // UIKit activates the owner, then the pending route selects its workspace. app.sceneChanged(owner.id, phase: .active) #expect(app.workspaceOwner(workspaceID) == owner.workspaceSession.id) router.resume() #expect(owner.selectedDestination == .workspaces) owner.activatePendingWorkspace() #expect(owner.activeWorkspace?.id == workspaceID) #expect(activations.count == 1) owner.selectedDestination = .vault router.open(.terminal(workspaceID)) #expect(owner.selectedDestination == .workspaces) owner.activatePendingWorkspace() #expect(owner.activeWorkspace?.id == workspaceID) #expect(activations.count == 1) } @Test func paneAccessExcludesDormantAndDisconnectedWorkspacesAcrossScenes() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let first = MobileSceneModel(app: app, sessionID: "pane-a") let second = MobileSceneModel(app: app, sessionID: "pane-b") first.open(hostID: fixture.host.id, kind: .ssh) second.open(try #require(URL(string: "telnet://test.invalid"))) first.activatePendingWorkspace() second.activatePendingWorkspace() let firstID = try #require(first.activeWorkspace?.id) let secondID = try #require(second.activeWorkspace?.id) let access = try #require(first.assistant.paneAccess) #expect(access.list().isEmpty) #expect(access.read(firstID) == .notConnected) #expect(access.read(secondID) == .notConnected) second.closeWorkspace(secondID) #expect(access.read(secondID) == .closed) } @Test func windowsOwnIndependentSessionsAndLifecycleActions() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let first = MobileSceneModel(app: app, sessionID: "a") let second = MobileSceneModel(app: app, sessionID: "b") first.activateHost(fixture.host.id) second.activateHost(fixture.host.id) first.activatePendingWorkspace() second.activatePendingWorkspace() let a = try #require(first.activeWorkspace) let b = try #require(second.activeWorkspace) #expect(a !== b) #expect(a.terminal !== b.terminal) #expect(first.workspaceSession.orderedWorkspaceItems.map(\.id) == [a.id]) #expect(second.workspaceSession.orderedWorkspaceItems.map(\.id) == [b.id]) first.activateHost(fixture.host.id) #expect(first.activeWorkspace === a) #expect(!first.workspaceSession.renameWorkspace(b.id, to: "Other")) first.closeOtherWorkspaces(than: b.id) #expect(first.activeWorkspace === a) first.closeWorkspace(b.id) #expect(second.activeWorkspace === b) first.closeAllWorkspaces() #expect(first.workspaceSession.orderedWorkspaceItems.isEmpty) #expect(second.activeWorkspace === b) #expect(app.connectionHistory.entries().filter { $0.endedAt == nil }.count == 1) second.selectWorkspace(a.id) #expect(second.isWorkspaceUnavailable) } @Test(arguments: HostTerminalProtocol.allCases) func vaultHostIndicatorFollowsTheWorkspaceOpenedByItsCard(transport: HostTerminalProtocol) throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let scene = MobileSceneModel(app: app, sessionID: "vault-card") let other = MobileSceneModel(app: app, sessionID: "other-window") var host = fixture.host host.telnetEnabled = true host.terminalTransports.moshEnabled = true host.terminalTransports.eternalTerminalEnabled = true app.library.upsert(host) other.open(hostID: host.id, kind: .ssh) #expect(scene.workspaceActivatedByHostRow(for: host) == nil) scene.open(hostID: host.id, kind: .sftp) #expect(scene.workspaceActivatedByHostRow(for: host) == nil) scene.open(hostID: host.id, kind: .ssh) let first = try #require(scene.workspaceActivatedByHostRow(for: host)?.workspace) scene.open(hostID: host.id, kind: .terminal(transport)) let second = try #require(scene.workspaceActivatedByHostRow(for: host)?.workspace) #expect(first !== second) #expect(second.descriptor.kind == .terminal(transport)) scene.selectWorkspace(first.id) #expect(scene.workspaceActivatedByHostRow(for: host)?.workspace === first) scene.activateHost(host.id) #expect(scene.activeWorkspace === first) } @Test(arguments: [false, true], HostTerminalProtocol.allCases) func vaultHostReopensDormantWorkspaceWithoutCreatingADuplicate( olderWorkspaceIsLive: Bool, transport: HostTerminalProtocol ) throws { let fixture = try Fixture() defer { fixture.cleanUp() } let kind = ConnectionHistoryEntry.Kind.terminal(transport) let saved = MobileWorkspaceDescriptor(hostID: fixture.host.id, title: "Saved shell", kind: kind) var host = fixture.host host.telnetEnabled = true host.terminalTransports.moshEnabled = true host.terminalTransports.eternalTerminalEnabled = true host.terminalTransports.defaultTransport = transport == .ssh ? .mosh : .ssh let older = MobileWorkspaceDescriptor( hostID: host.id, title: "Older default terminal", kind: .terminal(host.terminalTransports.defaultTransport)) let files = MobileWorkspaceDescriptor(hostID: host.id, title: "Files", kind: .sftp) let unrelated = MobileWorkspaceDescriptor(hostID: UUID(), title: "Other host", kind: .ssh) let otherWindow = MobileWorkspaceDescriptor(hostID: host.id, title: "Other window", kind: kind) let records = [older, saved, files, unrelated] try MobileWorkspaceStore(defaults: fixture.defaults).save( .init(sessions: ["vault-card": records, "other-window": [otherWindow]])) let app = try fixture.makeApp() app.library.upsert(host) let scene = MobileSceneModel(app: app, sessionID: "vault-card") if olderWorkspaceIsLive { #expect(scene.workspaceSession.openWorkspace(older.id) != nil) } let item = try #require(scene.workspaceActivatedByHostRow(for: host)) #expect(item.id == saved.id) #expect(item.workspace == nil) #expect(app.workspaces.count == (olderWorkspaceIsLive ? 1 : 0)) scene.activateHost(host.id) scene.activatePendingWorkspace() let reopened = try #require(scene.activeWorkspace) #expect(reopened.descriptor == saved) #expect(scene.workspaceSession.orderedWorkspaceItems.map(\.id) == records.map(\.id)) #expect(scene.workspaceActivatedByHostRow(for: host)?.workspace === reopened) #expect(app.workspaceSession(for: "other-window").workspaces.isEmpty) scene.selectedDestination = .vault scene.activateHost(host.id) scene.activatePendingWorkspace() #expect(scene.activeWorkspace === reopened) #expect(app.workspaces.count == (olderWorkspaceIsLive ? 2 : 1)) } @Test func everyCreationEntryPointUsesTheReceivingWindow() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let first = MobileSceneModel(app: app, sessionID: "a") let second = MobileSceneModel(app: app, sessionID: "b") second.open(hostID: fixture.host.id, kind: .ssh) first.open(hostID: fixture.host.id, kind: .sftp) first.openSameDirectory(.init(hostID: fixture.host.id, kind: .ssh, path: "/srv")) first.execute(Snippet(title: "Test", body: "echo test"), on: [fixture.host.id]) first.openBatchStartup() first.prepareBatchStartup(hostIDs: [fixture.host.id], connectionProtocol: .ssh) first.completeBatchStartup() #expect(first.workspaceID == nil) #expect(first.selectedDestination == .workspaces) #expect(first.perform(.history(hostID: fixture.host.id))) first.open(try #require(URL(string: "ssh://transient.invalid"))) #expect(first.workspaceSession.workspaces.count == 6) #expect(first.workspaceSession.savedWorkspaceDescriptors.count == 5) #expect(second.workspaceSession.workspaces.count == 1) let otherID = try #require(second.workspaceSession.workspaces.first?.id) for _ in 0..<8 { first.selectNextWorkspace() #expect(first.workspaceID != otherID) } } @Test(arguments: [false, true]) func oldSnapshotsAreClaimedOnceWithIdentityAndOrderPreserved(versioned: Bool) throws { let fixture = try Fixture() defer { fixture.cleanUp() } let records = [ MobileWorkspaceDescriptor(hostID: fixture.host.id, title: "Shell", kind: .ssh), MobileWorkspaceDescriptor(hostID: fixture.host.id, title: "Files", kind: .sftp), ] let missing = MobileWorkspaceDescriptor(hostID: UUID(), title: "Removed", kind: .ssh) let array = String(decoding: try JSONEncoder().encode([missing] + records), as: UTF8.self) let raw = Data((versioned ? "{\"version\":1,\"workspaces\":\(array)}" : array).utf8) fixture.defaults.set(raw, forKey: MobileWorkspaceStore.key) let app = try fixture.makeApp() #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) #expect(app.workspaceSession(for: "a").orderedWorkspaceItems.isEmpty) #expect(app.workspaceSession(for: "b").orderedWorkspaceItems.isEmpty) #expect(app.workspaces.isEmpty) #expect(app.connectionHistory.entries().isEmpty) } @Test( arguments: [false, true], [ ConnectionHistoryEntry.Kind.ssh, .sftp, .telnet, .mosh, .eternalTerminal, ]) func coldLaunchKeepsRecordsWhenOldNativeWindowIsDiscarded( afterHandoff: Bool, kind: ConnectionHistoryEntry.Kind ) throws { let fixture = try Fixture() defer { fixture.cleanUp() } let original = try fixture.makeApp() var host = fixture.host host.telnetEnabled = true host.terminalTransports.moshEnabled = true host.terminalTransports.eternalTerminalEnabled = true original.library.upsert(host) let id = try #require(original.workspaceSession(for: "old").open(hostID: host.id, kind: kind)) let app = try fixture.makeApp() if !afterHandoff { app.discardWorkspaceSessions(["old"]) } let scene = MobileSceneModel(app: app, sessionID: "new") app.reconcileWorkspaceSessions(openSessionIDs: ["new"], activeSessionID: "new") if afterHandoff { app.discardWorkspaceSessions(["old"]) } #expect(scene.workspaceSession.savedWorkspaceDescriptors.map(\.id) == [id]) #expect(app.workspaces.isEmpty) #expect(try MobileWorkspaceStore(defaults: fixture.defaults).load().sessions["new"]?.map(\.id) == [id]) scene.open(WorkspaceActivityAttributes.destinationURL(for: id)) scene.activatePendingWorkspace() #expect(!scene.isWorkspaceUnavailable) #expect(scene.activeWorkspace?.id == id) #expect(scene.activeWorkspace?.sessionPersistence.requiresSelection == false) #expect(app.closedWorkspaceRecords.isEmpty) } @Test func coldLaunchKeepsExistingNativeOwnersUntilTheyBindAndExplicitlyClose() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let first = MobileWorkspaceDescriptor(hostID: fixture.host.id, title: "Shell", kind: .ssh) let second = MobileWorkspaceDescriptor(hostID: fixture.host.id, title: "Files", kind: .sftp) try MobileWorkspaceStore(defaults: fixture.defaults).save(.init(sessions: ["a": [first], "b": [second]])) let app = try fixture.makeApp() let active = MobileSceneModel(app: app, sessionID: "a") app.reconcileWorkspaceSessions(openSessionIDs: ["a", "b"], activeSessionID: "a") var activated: [String] = [] app.activateWorkspaceSession = { owner, _, _ in activated.append(owner) } active.open(WorkspaceActivityAttributes.destinationURL(for: second.id)) #expect(activated == ["b"]) #expect(app.workspaceOwner(first.id) == "a") #expect(app.workspaceOwner(second.id) == "b") #expect(app.workspaces.isEmpty) let bound = MobileSceneModel(app: app, sessionID: "b") app.discardWorkspaceSessions(["b"]) #expect(bound.workspaceSession.isDiscarded) #expect(!app.containsWorkspace(second.id)) #expect(app.containsWorkspace(first.id)) #expect(try MobileWorkspaceStore(defaults: fixture.defaults).load().sessions["b"] == nil) #expect(app.closedWorkspaceRecords.first?.items == [second]) } @Test func orphanedColdWindowsTransferAsOneCompleteSnapshotAndIgnoreLateDiscards() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let records = (0..<4).map { MobileWorkspaceDescriptor(hostID: fixture.host.id, title: "Workspace \($0)", kind: .ssh) } let store = MobileWorkspaceStore(defaults: fixture.defaults) try store.save( .init(sessions: ["active": [records[0]], "old-a": [records[1]], "old-b": [records[2], records[3]]])) let app = try fixture.makeApp() app.discardWorkspaceSessions(["old-a"]) app.reconcileWorkspaceSessions(openSessionIDs: [], activeSessionID: nil) #expect(app.workspaces.isEmpty) #expect(app.workspaceOwner(records[1].id) == "old-a") let active = MobileSceneModel(app: app, sessionID: "active") // UIKit can briefly keep a discarded cold session in its openSessions set. app.reconcileWorkspaceSessions(openSessionIDs: ["active", "old-a"], activeSessionID: "active") #expect(active.workspaceSession.savedWorkspaceDescriptors == records) #expect(try store.load().sessions["active"] == records) #expect(try store.load().sessions.values.flatMap { $0 }.count == records.count) app.discardWorkspaceSessions(["old-a", "old-b"]) app.persistWorkspaceDescriptors() #expect(active.workspaceSession.savedWorkspaceDescriptors == records) #expect(try store.load().sessions["active"] == records) #expect(app.closedWorkspaceRecords.isEmpty) } @Test func failedHandoffKeepsTheSourceRuntimeAndOwnership() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let damaged = Data("unreadable".utf8) fixture.defaults.set(damaged, forKey: MobileWorkspaceStore.restorationKey) let app = try fixture.makeApp() let first = MobileSceneModel(app: app, sessionID: "a") let second = MobileSceneModel(app: app, sessionID: "b") let third = MobileSceneModel(app: app, sessionID: "c") first.open(hostID: fixture.host.id, kind: .ssh) third.open(hostID: fixture.host.id, kind: .sftp) first.activatePendingWorkspace() third.activatePendingWorkspace() let workspace = try #require(first.activeWorkspace) let other = try #require(third.activeWorkspace) app.reconcileWorkspaceSessions(openSessionIDs: ["b"], activeSessionID: "b") #expect(first.activeWorkspace === workspace) #expect(app.workspaceOwner(workspace.id) == "a") #expect(third.activeWorkspace === other) #expect(app.workspaceOwner(other.id) == "c") #expect(second.workspaceSession.orderedWorkspaceItems.isEmpty) #expect(fixture.defaults.data(forKey: MobileWorkspaceStore.restorationKey) == damaged) #expect(app.error != nil) } @Test func missingNativeSessionHandsOffLiveWorkspacesWithoutReconnecting() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let first = MobileSceneModel(app: app, sessionID: "a") let second = MobileSceneModel(app: app, sessionID: "b") first.open(hostID: fixture.host.id, kind: .ssh) second.open(hostID: fixture.host.id, kind: .ssh) let original = try #require(second.workspaceSession.workspaces.first) let reference = managedReference(hostID: fixture.host.id) original.restoreRemoteSession(reference) let history = app.connectionHistory.entries() app.sceneChanged(first.id, phase: .active) app.sceneChanged(second.id, phase: .background) app.disconnectWorkspaceScene("b") app.reconcileWorkspaceSessions(openSessionIDs: ["a"], activeSessionID: "a") #expect(first.workspaceSession.workspaces.last === original) #expect(second.workspaceSession.orderedWorkspaceItems.isEmpty) #expect(app.connectionHistory.entries() == history) #expect(app.workspaceOwner(original.id) == "a") #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) // A late user-close callback must still remove only the workspaces from that window. app.discardWorkspaceSessions(["b"]) #expect(first.workspaceSession.workspaces.count == 1) #expect(first.workspaceSession.workspaces.first?.id != original.id) #expect(app.workspaceOwner(original.id) == nil) #expect(app.closedWorkspaceRecords.first?.isWindow == true) #expect(app.closedWorkspaceRecords.first?.items.map(\.id) == [original.id]) #expect(app.closedWorkspaceRecords.first?.items.first?.remoteSession == reference) #expect(second.workspaceSession.open(hostID: fixture.host.id, kind: .ssh) == nil) } @Test func systemReclaimKeepsOwnershipWhenBothNativeSessionsStillExist() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let first = MobileSceneModel(app: app, sessionID: "a") let second = MobileSceneModel(app: app, sessionID: "b") first.open(hostID: fixture.host.id, kind: .ssh) second.open(hostID: fixture.host.id, kind: .sftp) let original = try #require(second.workspaceSession.workspaces.first) app.sceneChanged(first.id, phase: .active, workspaceSessionID: "a") app.sceneChanged(second.id, phase: .background, workspaceSessionID: "b") app.disconnectWorkspaceScene("b") app.reconcileWorkspaceSessions(openSessionIDs: ["a", "b"], activeSessionID: "a") #expect(first.workspaceSession.workspaces.count == 1) #expect(second.workspaceSession.workspaces.first === original) #expect(app.workspaceOwner(original.id) == "b") var activatedOwners: [String] = [] app.activateWorkspaceSession = { owner, _, _ in activatedOwners.append(owner) } first.open(WorkspaceActivityAttributes.destinationURL(for: original.id)) #expect(activatedOwners == ["b"]) let restored = MobileSceneModel(app: app, sessionID: "b") #expect(restored.workspaceSession === second.workspaceSession) #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) } @Test func missingLastWindowWaitsForTheNextActiveWindow() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let first = MobileSceneModel(app: app, sessionID: "a") first.open(hostID: fixture.host.id, kind: .ssh) let original = try #require(first.workspaceSession.workspaces.first) app.sceneChanged(first.id, phase: .background) app.disconnectWorkspaceScene("a") app.reconcileWorkspaceSessions(openSessionIDs: [], activeSessionID: nil) #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) let next = MobileSceneModel(app: app, sessionID: "b") #expect(next.workspaceSession.workspaces.isEmpty) app.sceneChanged(next.id, phase: .active) app.reconcileWorkspaceSessions(openSessionIDs: ["b"], activeSessionID: "b") #expect(next.workspaceSession.workspaces.first === original) #expect(first.workspaceSession.workspaces.isEmpty) #expect(app.workspaceOwner(original.id) == "b") } @Test func backgroundAndViewRemovalPreserveOwnershipButExplicitDiscardRemovesIt() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let first = MobileSceneModel(app: app, sessionID: "a") let second = MobileSceneModel(app: app, sessionID: "b") first.open(hostID: fixture.host.id, kind: .ssh) second.open(hostID: fixture.host.id, kind: .sftp) let id = try #require(first.workspaceSession.workspaces.first?.id) app.sceneChanged(first.id, phase: .background) app.removeScene(first.id) #expect(app.workspaceSession(for: "a") === first.workspaceSession) #expect(app.workspaceOwner(id) == "a") let delegate = MobileAppDelegate() delegate.app = app delegate.discardSessions(["a"]) #expect(first.workspaceSession.orderedWorkspaceItems.isEmpty) #expect(second.workspaceSession.workspaces.count == 1) #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) first.open(WorkspaceActivityAttributes.destinationURL(for: id)) #expect(first.isWorkspaceUnavailable) } @Test func windowCloseWaitsForNativeDiscardBeforeRemovingItsWorkspaces() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let first = MobileSceneModel(app: app, sessionID: "a") let second = MobileSceneModel(app: app, sessionID: "b") first.open(hostID: fixture.host.id, kind: .ssh) second.open(hostID: fixture.host.id, kind: .sftp) let firstID = try #require(first.workspaceSession.workspaces.first?.id) let secondID = try #require(second.workspaceSession.workspaces.first?.id) var requestedSessions: [String] = [] app.closeWorkspaceSession = { id, _ in requestedSessions.append(id) } first.openSettings() first.closeSelectionOrWindow() #expect(requestedSessions == ["a"]) #expect(first.workspaceSession.workspaces.map(\.id) == [firstID]) #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) let delegate = MobileAppDelegate() delegate.app = app delegate.discardSessions(["a"]) #expect(first.workspaceSession.isDiscarded) #expect(first.workspaceSession.orderedWorkspaceItems.isEmpty) #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) #expect(second.workspaceSession.workspaces.map(\.id) == [secondID]) #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) #expect(app.error == nil) first.closeSelectionOrWindow() #expect(requestedSessions == ["a"]) } @Test func explicitWindowCloseKeepsSelectedWorkspaceUntilNativeDiscard() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let scene = MobileSceneModel(app: app, sessionID: "owner") scene.open(hostID: fixture.host.id, kind: .ssh) scene.activatePendingWorkspace() let workspace = try #require(scene.activeWorkspace) let reference = managedReference(hostID: fixture.host.id) workspace.restoreRemoteSession(reference) var requestedSessions: [String] = [] app.closeWorkspaceSession = { id, _ in requestedSessions.append(id) } scene.closeWindow() #expect(requestedSessions == ["owner"]) #expect(scene.activeWorkspace === workspace) #expect(workspace.sessionPersistence.attachment == reference) #expect(!scene.workspaceSession.isDiscarded) app.discardWorkspaceSessions(["owner"]) #expect(scene.workspaceSession.workspaces.isEmpty) #expect(scene.workspaceSession.isDiscarded) #expect(app.closedWorkspaceRecords.first?.items.first?.remoteSession == reference) } @Test func closeAllWindowsSnapshotsNativeSessionsAndRetainsRejectedWindows() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let first = MobileSceneModel(app: app, sessionID: "first") let second = MobileSceneModel(app: app, sessionID: "second") first.open(hostID: fixture.host.id, kind: .ssh) second.open(hostID: fixture.host.id, kind: .ssh) let firstReference = managedReference(hostID: fixture.host.id) first.workspaceSession.workspaces.first?.restoreRemoteSession(firstReference) let retainedWorkspace = try #require(second.workspaceSession.workspaces.first) let retainedReference = managedReference(hostID: fixture.host.id) retainedWorkspace.restoreRemoteSession(retainedReference) var nativeSessions: Set = ["first", "second", "background"] var requestedSessions: [String] = [] app.openWorkspaceSessionIDs = { nativeSessions } app.closeWorkspaceSession = { id, failed in requestedSessions.append(id) if id == "second" { failed(NSError(domain: "WindowCloseTest", code: 1)) } else { nativeSessions.remove(id) app.discardWorkspaceSessions([id]) } } first.closeAllWindows() #expect(requestedSessions == ["background", "second", "first"]) #expect(first.workspaceSession.isDiscarded) #expect(second.workspaceSession.workspaces.first === retainedWorkspace) #expect(retainedWorkspace.sessionPersistence.attachment == retainedReference) #expect(app.closedWorkspaceRecords.first?.items.first?.remoteSession == firstReference) #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) #expect(!second.workspaceSession.isDiscarded) #expect(app.error == nil) } @Test func rejectedWindowCloseKeepsWorkspacesAndSavedRecordsWithoutAnAlert() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let scene = MobileSceneModel(app: app, sessionID: "a") scene.open(hostID: fixture.host.id, kind: .ssh) let workspace = try #require(scene.workspaceSession.workspaces.first) let reference = managedReference(hostID: fixture.host.id) workspace.restoreRemoteSession(reference) var reportFailure: (@MainActor (any Error) -> Void)? app.closeWorkspaceSession = { _, failed in reportFailure = failed } scene.openSettings() scene.closeSelectionOrWindow() let failed = try #require(reportFailure) failed(NSError(domain: "WindowCloseTest", code: 1)) #expect(scene.workspaceSession.workspaces.first === workspace) #expect(workspace.sessionPersistence.attachment == reference) #expect(!scene.workspaceSession.isDiscarded) #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) #expect(scene.selectedDestination == .settings) #expect(!scene.isWorkspaceUnavailable) #expect(app.error == nil) } @Test func closingMissingNativeSessionReportsFailureWithoutDiscardingSavedWorkspaces() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let id = "missing-\(UUID())" let scene = MobileSceneModel(app: app, sessionID: id) scene.open(hostID: fixture.host.id, kind: .ssh) let workspaceID = try #require(scene.workspaceSession.workspaces.first?.id) var failure: (any Error)? MobileWorkspaceSceneRouting.close(sessionID: id) { failure = $0 } #expect(failure as? MobileWorkspaceSceneRouting.CloseError == .sessionNotFound) #expect(scene.workspaceSession.workspaces.map(\.id) == [workspaceID]) #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) #expect(app.error == nil) } @Test func missingNativeWindowReportsUnavailableWithoutCreatingAConnection() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let first = MobileSceneModel(app: app, sessionID: "a") let second = MobileSceneModel(app: app, sessionID: "b") first.open(hostID: fixture.host.id, kind: .ssh) let id = try #require(first.workspaceSession.workspaces.first?.id) app.activateWorkspaceSession = { _, _, unavailable in unavailable() } second.open(WorkspaceActivityAttributes.destinationURL(for: id)) #expect(second.isWorkspaceUnavailable) #expect(second.workspaceSession.workspaces.isEmpty) #expect(app.workspaces.count == 1) } @Test func hostDeletionClosesEveryWindowAndRecoveryRecord() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() for id in ["a", "b"] { _ = app.workspaceSession(for: id).open(hostID: fixture.host.id, kind: .ssh) } #expect(app.deleteHost(fixture.host.id)) #expect(app.workspaces.isEmpty) #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) } private final class Background: MobileBackgroundTaskManaging { func begin(expiration: @escaping @MainActor () -> Void) {} func finish() {} } private func waitUntil(_ condition: () -> Bool) async throws { for _ in 0..<100 { if condition() { return } try await Task.sleep(for: .milliseconds(10)) } try #require(condition()) } private func managedReference(hostID: UUID) -> RemoteSessionReference { .init( backend: .tmux, session: .init(id: "$1", name: "build", identity: Data("identity".utf8)), hostID: hostID, endpoint: "test.invalid:22", username: "user", ownershipToken: UUID()) } private final class Fixture { let name = "WorkspaceSessions.\(UUID())" let defaults: UserDefaults let container: ModelContainer let host = Host(name: "Server", host: "test.invalid") var apps: [MobileAppModel] = [] init() throws { defaults = try #require(UserDefaults(suiteName: name)) container = try TermindPersistence.makeContainer(inMemory: true) var dependencies = MobileAppDependencies.testing() dependencies.container = container let seed = try MobileAppModel(dependencies: dependencies) seed.library.upsert(host) seed.outbox.stop() } func makeApp() throws -> MobileAppModel { var dependencies = MobileAppDependencies.testing(background: Background()) dependencies.container = container dependencies.defaults = defaults let app = try MobileAppModel(dependencies: dependencies) apps.append(app) return app } func cleanUp() { for app in apps { for workspace in app.workspaces { workspace.disconnect() } app.outbox.stop() } defaults.removePersistentDomain(forName: name) } } }