Files
Termind/TFITests/MobileAdaptiveLayoutTests.swift
T
Kaede Akino 8e3f1128da feat(sessions): add tmux persistence and terminal context tracking
Add extensible remote session backends, safe ownership and restore handling, and pane-scoped shell integration over the existing SSH connection.

Keep iOS workspaces in memory and rebuild expired Activity targets on demand. Integrate session selection into Connection Journey with consistent controls, cancellation behavior, and localized counts.
2026-10-16 01:04:12 +08:00

852 lines
45 KiB
Swift

import ListViewKit
import SwiftUI
import TermindApplication
import TermindDomain
import Testing
import UIKit
@testable import TermindIOS
@Suite(.serialized) @MainActor
struct MobileAdaptiveLayoutTests {
@Test(.requiresKeyWindow) func metricsRetainsItsNativeListAndScrollPositionAcrossFoldsAndVerticalMenuChanges()
async throws
{
let app = try MobileAppModel(inMemory: true)
let scene = MobileSceneModel(app: app, sessionID: "test")
scene.selectedDestination = .metrics
let server = ServerMetric(selection: MetricsSelection(id: UUID(), enabled: false), name: "Fold Metrics")
server.processes = (1...100).map {
MetricsProcess(pid: $0, started: 1, name: "process-\($0)", cpu: Double($0), memory: Double(101 - $0))
}
let regions = MobileRegionTestState()
var features = MobileFeatureRegistry()
features.register(
MobileFeatureRegistration(id: .metrics, title: "Metrics", symbol: "waveform.path.ecg") { app, _ in
AnyView(
MobileRegionTestHost(state: regions) {
MobileMetricsDetailView(app: app, server: server)
.environment(\.mobileToolbarLayout, regions.toolbarLayout)
})
})
try await withRoot(app: app, scene: scene, features: features, initialSizeClass: .compact) { controller in
let navigation = try #require(navigation(titled: server.name, in: controller))
let picker = try #require(find(UISegmentedControl.self, in: controller.view))
picker.selectedSegmentIndex = 1
picker.sendActions(for: .valueChanged)
try await settle()
let list = try #require(find(UICollectionView.self, in: navigation.view))
list.setContentOffset(CGPoint(x: 0, y: 350), animated: false)
try await settle()
for region in [
MobileReservedRegion(kind: .division, frame: CGRect(x: 0, y: 280, width: 2000, height: 24)),
MobileReservedRegion(kind: .division, frame: CGRect(x: 200, y: 0, width: 24, height: 2000)),
MobileReservedRegion(kind: .division, frame: .zero, isActive: false),
] {
regions.regions = [region]
try await settle()
#expect(find(UICollectionView.self, in: navigation.view) === list)
#expect(list.contentOffset.y > 200)
if region.isActive { #expect(find(UISegmentedControl.self, in: controller.view) == nil) }
}
regions.toolbarLayout.verticalEdge = .trailing
try await settle()
#expect(find(UISegmentedControl.self, in: controller.view) == nil)
let menuButton = try #require(toolbarButton(namedOneOf: ["Details"], in: navigation))
var actions = try await resolvedMenuActions(#require(menuButton.menu), in: navigation.view)
#expect(actions.contains { $0.title == "Processes" && $0.state == .on && $0.image != nil })
let status = try #require(actions.first { $0.title == "Status" })
UIButton(primaryAction: status).sendActions(for: .primaryActionTriggered)
try await settle()
#expect(find(UICollectionView.self, in: navigation.view) === list)
let updatedButton = try #require(toolbarButton(namedOneOf: ["Details"], in: navigation))
actions = try await resolvedMenuActions(#require(updatedButton.menu), in: navigation.view)
#expect(actions.contains { $0.title == "Status" && $0.state == .on })
let processes = try #require(actions.first { $0.title == "Processes" })
UIButton(primaryAction: processes).sendActions(for: .primaryActionTriggered)
try await settle()
#expect(find(UICollectionView.self, in: navigation.view) === list)
#expect(list.contentOffset.y > 200)
regions.toolbarLayout.verticalEdge = nil
try await settle()
#expect(try #require(find(UISegmentedControl.self, in: controller.view)).selectedSegmentIndex == 1)
#expect(toolbarButton(namedOneOf: ["Details"], in: navigation) == nil)
#expect(find(UICollectionView.self, in: navigation.view) === list)
#expect(list.contentOffset.y > 200)
}
}
@Test(.requiresKeyWindow, arguments: ["en", "zh-Hans", "zh-Hant"])
func foldingKeepsAssistantTranscriptComposerFocusAndMarkedText(language: String) async throws {
let app = try MobileAppModel(inMemory: true)
let scene = MobileSceneModel(app: app, sessionID: "test")
scene.selectedDestination = .assistant
await scene.assistant.load()
let conversation = scene.assistant.conversation
conversation.appendUserTurn("Keep this transcript while folding")
conversation.composer.draft = "Draft across folds"
let regions = MobileRegionTestState()
try await withRoot(app: app, scene: scene, language: language, regions: regions) { controller in
let composer = try #require(textInput(containing: conversation.composer.draft, in: controller.view))
let input = try #require(composer as? any UITextInput)
let transcript = try #require(find(ListScrollView.self, in: controller.view))
#expect(composer.becomeFirstResponder())
input.setMarkedText("输入中", selectedRange: NSRange(location: 3, length: 0))
try await settle()
for (index, region) in [
MobileReservedRegion(kind: .division, frame: CGRect(x: 0, y: 160, width: 2000, height: 24)),
MobileReservedRegion(kind: .division, frame: CGRect(x: 350, y: 0, width: 24, height: 2000)),
MobileReservedRegion(kind: .occlusion, frame: CGRect(x: 0, y: 0, width: 40, height: 40)),
MobileReservedRegion(kind: .division, frame: .zero, isActive: false),
].enumerated() {
regions.regions = [region]
try await settle()
#expect(scene.assistant.conversation === conversation)
#expect(find(ListScrollView.self, in: controller.view) === transcript)
#expect(composer.isFirstResponder)
#expect(composer.window != nil)
let marked = try #require(input.markedTextRange)
#expect(input.text(in: marked) == "输入中")
capture(controller, name: "assistant-fold-\(index)-\(language)")
}
input.unmarkText()
composer.resignFirstResponder()
}
}
private func textInput(containing text: String, in view: UIView) -> UIView? {
if let input = view as? any UITextInput,
let range = input.textRange(from: input.beginningOfDocument, to: input.endOfDocument),
input.text(in: range) == text
{
return view
}
return view.subviews.lazy.compactMap { textInput(containing: text, in: $0) }.first
}
@Test func widthClassificationDefaultsToCompact() {
#expect(MobileLayout(sizeClass: nil) == .compact)
#expect(MobileLayout(sizeClass: .compact) == .compact)
#expect(MobileLayout(sizeClass: .regular) == .regular)
}
@Test(.requiresKeyWindow, arguments: ["en", "zh-Hans", "zh-Hant"], [DynamicTypeSize.large, .accessibility3])
func fiveNativeTabsRemainAvailableAcrossWindowWidths(language: String, typeSize: DynamicTypeSize) async throws {
let app = try MobileAppModel(inMemory: true)
let scene = MobileSceneModel(app: app, sessionID: "test")
try await withRoot(app: app, scene: scene, language: language, typeSize: typeSize) { controller in
let tabs = try #require(find(UITabBarController.self, in: controller))
#expect(tabs.tabs.count == 5)
#expect(tabs.mode == .tabSidebar)
for (width, sizeClass): (CGFloat, UIUserInterfaceSizeClass) in [
(1024, .regular), (600, .regular), (375, .compact), (320, .compact), (1024, .regular),
] {
resize(controller, to: sizeClass, width: width)
tabs.sidebar.isHidden = true
try await settle()
#expect(find(UITabBarController.self, in: controller) === tabs)
let pages = tabs.tabs
#expect(pages.count == 5)
for (index, destination) in MobileDestination.allCases.enumerated() {
let page = try #require(pages.indices.contains(index) ? pages[index] : nil)
let previous = tabs.selectedTab
tabs.selectedTab = page
tabs.delegate?.tabBarController?(tabs, didSelectTab: page, previousTab: previous)
try await settle()
#expect(scene.selectedDestination == destination)
#expect(tabs.selectedTab === page)
let title = page.title
#expect(visibleLabels(in: tabs.view).contains { $0.text == title })
}
let items = tabs.tabs
#expect(items.count == 5)
capture(controller, name: "tabs-\(Int(width))-\(language)-\(typeSize)")
let labels = visibleLabels(in: tabs.view)
Attachment.record(
labels.map { "\($0.text ?? ""): \($0.bounds), natural \($0.intrinsicContentSize)" }
.joined(separator: "\n"), named: "tab-labels-\(Int(width))-\(language)-\(typeSize)")
// The regular-width iPad bar scrolls when its titles do not fit.
// Compact width puts all five destinations in the bottom bar.
if sizeClass == .compact {
for item in items {
#expect(
labels.contains { $0.text == item.title }, "Missing visible tab: \(item.title)")
}
}
}
}
}
@Test(.requiresKeyWindow) func compactLaunchKeepsTheConversationAndDraftAcrossResizing() async throws {
let app = try MobileAppModel(inMemory: true)
let scene = MobileSceneModel(app: app, sessionID: "test")
scene.selectedDestination = .assistant
let conversation = scene.assistant.conversation
conversation.composer.draft = "Keep this draft through rotation"
try await withRoot(app: app, scene: scene, initialSizeClass: .compact) { controller in
let navigation = try #require(navigation(titled: "Assistant", in: controller))
let detail = navigation.topViewController
for sizeClass: UIUserInterfaceSizeClass in [.regular, .compact, .regular] {
resize(controller, to: sizeClass)
try await settle()
#expect(scene.selectedDestination == .assistant)
#expect(scene.assistant.conversation === conversation)
#expect(conversation.composer.draft == "Keep this draft through rotation")
#expect(navigation.topViewController === detail)
}
}
}
@Test(.requiresKeyWindow) func metricsStaysSelectedAcrossResizingAndTabRoundTrips() async throws {
let app = try MobileAppModel(inMemory: true)
let scene = MobileSceneModel(app: app, sessionID: "test")
scene.selectedDestination = .metrics
try await withRoot(app: app, scene: scene) { controller in
let metrics = try #require(navigation(titled: "Metrics", in: controller))
let page = metrics.topViewController
for sizeClass: UIUserInterfaceSizeClass in [.compact, .regular, .compact] {
resize(controller, to: sizeClass)
try await settle()
#expect(scene.selectedDestination == .metrics)
#expect(navigation(titled: "Metrics", in: controller) === metrics)
#expect(metrics.topViewController === page)
}
scene.openSettings()
try await settle()
scene.selectedDestination = .metrics
try await settle()
#expect(navigation(titled: "Metrics", in: controller) === metrics)
#expect(metrics.topViewController === page)
}
}
@Test(.requiresKeyWindow, arguments: ["en", "zh-Hans", "zh-Hant"])
func assistantSwitchersReflectAvailableConversationsAcrossToolbarLayouts(language: String) async throws {
let app = try MobileAppModel(inMemory: true)
let scene = MobileSceneModel(app: app, sessionID: "test")
scene.selectedDestination = .assistant
await scene.assistant.load()
let conversation = scene.assistant.conversation
conversation.composer.draft = "Keep the draft while switchers update"
let regions = MobileRegionTestState()
var features = MobileFeatureRegistry()
features.register(
MobileFeatureRegistration(id: .assistant, title: "Assistant", symbol: "sparkles") { app, scene in
AnyView(
MobileAssistantView(
app: app, model: scene.assistant,
libraryRoute: Binding(
get: { scene.assistantLibraryRoute }, set: { scene.assistantLibraryRoute = $0 })
)
.environment(\.mobileToolbarLayout, regions.toolbarLayout))
})
try await withRoot(app: app, scene: scene, language: language, features: features, initialSizeClass: .compact) {
controller in
let navigation = try #require(navigation(titled: "Assistant", in: controller))
let page = try #require(navigation.topViewController)
for (state, enabled) in [
("empty", false), ("current", true), ("pinned", true), ("archived", false), ("restored", true),
] {
switch state {
case "current":
conversation.appendUserTurn("The only conversation")
scene.assistant.library.insertAndSelect(conversation)
case "pinned": conversation.isPinned = true
case "archived":
conversation.isPinned = false
conversation.archivedAt = .now
case "restored": conversation.archivedAt = nil
default: break
}
// Check a data-only update before changing toolbar placement.
try await settle()
try expectAssistantSwitcher(in: navigation, layout: regions.toolbarLayout, enabled: enabled)
for (name, layout) in [
("title", MobileToolbarLayout()),
("top", MobileToolbarLayout(isTopBar: true)),
("vertical", MobileToolbarLayout(verticalEdge: .trailing)),
] {
regions.toolbarLayout = layout
try await settle()
try expectAssistantSwitcher(in: navigation, layout: layout, enabled: enabled)
#expect(navigation.topViewController === page)
#expect(scene.assistant.conversation === conversation)
#expect(conversation.composer.draft == "Keep the draft while switchers update")
let newConversation = try #require(
toolbarButton(namedOneOf: ["New Conversation", "新建对话", "新增對話"], in: navigation))
#expect(newConversation.isEnabled)
capture(controller, name: "assistant-switcher-\(state)-\(name)-\(language)")
}
if !enabled {
let menu: UIMenu
if #available(iOS 27.0, *) {
menu = UIMenu(children: [try #require(page.navigationItem.additionalOverflowItems)])
} else {
menu = try #require(toolbarButton(namedOneOf: ["More", "更多"], in: navigation)?.menu)
}
let actions = try await resolvedMenuActions(menu, in: page.view)
for names: Set<String> in [["Conversations", "对话", "對話"], ["Archived", "已归档", "已封存"]] {
let action = try #require(actions.first { names.contains($0.title) })
#expect(!action.attributes.contains(.disabled))
}
}
}
}
}
private func expectAssistantSwitcher(
in navigation: UINavigationController, layout: MobileToolbarLayout, enabled: Bool
) throws {
let page = try #require(navigation.topViewController)
let switcher = toolbarButton(namedOneOf: ["Conversations", "对话", "對話"], in: navigation)
if layout.usesIconSwitcher {
#expect(try #require(switcher).isEnabled == enabled)
} else {
#expect(switcher == nil)
#expect((page.navigationItem.titleMenuProvider != nil) == enabled)
#expect(visibleLabels(in: navigation.navigationBar).contains { $0.text == page.navigationItem.title })
}
}
@Test(.requiresKeyWindow, arguments: ["en", "zh-Hans", "zh-Hant"])
func assistantTitleAdaptsWithoutLosingItsConversationMenuOrDrafts(language: String) async throws {
guard UIDevice.current.userInterfaceIdiom == .pad else { return }
let app = try MobileAppModel(inMemory: true)
let scene = MobileSceneModel(app: app, sessionID: "test")
scene.selectedDestination = .assistant
await scene.assistant.load()
let first = ConversationModel()
first.appendUserTurn("Investigate the production database latency")
first.customTitle = language == "en" ? "Investigate the production database latency" : "排查生产数据库延迟与连接问题"
first.isPinned = true
first.composer.draft = "Keep the first draft"
let second = ConversationModel()
second.appendUserTurn("Review the deployment")
second.composer.draft = "Keep the second draft"
scene.assistant.library.insertAndSelect(second)
scene.assistant.library.insertAndSelect(first)
try await withRoot(app: app, scene: scene, language: language) { controller in
let tabs = try #require(find(UITabBarController.self, in: controller))
let navigation = try #require(navigation(titled: first.derivedTitle, in: controller))
let page = try #require(navigation.topViewController)
for (sizeClass, sidebar): (UIUserInterfaceSizeClass, Bool) in [
(.regular, false), (.regular, true), (.compact, false), (.regular, false),
] {
resize(controller, to: sizeClass, width: sizeClass == .regular ? 1366 : 430)
tabs.sidebar.isHidden = !sidebar
try await settle()
let topBar = sizeClass == .regular && !sidebar
capture(controller, name: "assistant-title-\(sizeClass.rawValue)-\(sidebar)-\(language)")
#expect(navigation.topViewController === page)
#expect(scene.assistant.conversation === first)
#expect(first.composer.draft == "Keep the first draft")
#expect(
visibleLabels(in: navigation.navigationBar).contains { $0.text == first.derivedTitle } == !topBar)
let switcher = toolbarButton(namedOneOf: ["Conversations", "对话", "對話"], in: navigation)
#expect((switcher != nil) == topBar)
if let switcher {
let trailingButtons =
page.navigationItem.trailingItemGroups.flatMap(\.barButtonItems)
+ (page.navigationItem.rightBarButtonItems ?? [])
// System overflow is owned by UINavigationItem rather than a second custom bar button.
let expectedButtons = if #available(iOS 27.0, *) { 1 } else { 2 }
#expect(Set(trailingButtons.map(ObjectIdentifier.init)).count == expectedButtons)
if #available(iOS 27.0, *) { #expect(page.navigationItem.additionalOverflowItems != nil) }
let menu = try #require(switcher.menu)
let actions = try await resolvedMenuActions(menu, in: page.view)
#expect(actions.map(\.title).contains(first.derivedTitle))
let selectSecond = try #require(actions.first { $0.title == second.derivedTitle })
UIButton(primaryAction: selectSecond).sendActions(for: .primaryActionTriggered)
try await settle()
#expect(scene.assistant.conversation === second)
#expect(second.composer.draft == "Keep the second draft")
let updatedSwitcher = try #require(
toolbarButton(namedOneOf: ["Conversations", "对话", "對話"], in: navigation))
let updatedMenu = try #require(updatedSwitcher.menu)
let updatedActions = try await resolvedMenuActions(updatedMenu, in: page.view)
let selectFirst = try #require(updatedActions.first { $0.title == first.derivedTitle })
UIButton(primaryAction: selectFirst).sendActions(for: .primaryActionTriggered)
try await settle()
#expect(scene.assistant.conversation === first)
}
}
for route in [MobileAssistantLibraryRoute.conversations, .archived] {
scene.assistantLibraryRoute = route
try await settle()
let title = try #require(navigation.topViewController?.navigationItem.title)
#expect(!title.isEmpty && title != first.derivedTitle)
#expect(visibleLabels(in: navigation.navigationBar).contains { $0.text == title })
capture(controller, name: "assistant-library-\(route.rawValue)-\(language)")
scene.assistantLibraryRoute = nil
try await settle()
#expect(navigation.topViewController === page)
#expect(!visibleLabels(in: navigation.navigationBar).contains { $0.text == first.derivedTitle })
}
}
}
@Test(.requiresKeyWindow, arguments: ["en", "zh-Hans", "zh-Hant"])
func metricsDetailTitleAdaptsWhileItsPickerAndNavigationSurvive(language: String) async throws {
guard UIDevice.current.userInterfaceIdiom == .pad else { return }
let app = try MobileAppModel(inMemory: true)
let scene = MobileSceneModel(app: app, sessionID: "test")
scene.selectedDestination = .metrics
let server = ServerMetric(
selection: MetricsSelection(id: UUID(), enabled: false),
name: language == "en" ? "Production database server in Singapore" : "新加坡生产环境数据库服务器")
var features = MobileFeatureRegistry()
for feature in MobileFeatureRegistry.standard().registrations {
features.register(
feature.id == .metrics
? MobileFeatureRegistration(id: .metrics, title: "Metrics", symbol: "waveform.path.ecg") { app, _ in
AnyView(MetricsDetailFeature(app: app, server: server))
} : feature)
}
try await withRoot(app: app, scene: scene, language: language, features: features) { controller in
let tabs = try #require(find(UITabBarController.self, in: controller))
let navigation = try #require(navigation(titled: server.name, in: controller))
let page = try #require(navigation.topViewController)
var selectedProcesses = false
for (sizeClass, width, sidebar): (UIUserInterfaceSizeClass, CGFloat, Bool) in [
(.regular, 1024, false), (.regular, 600, false), (.regular, 1366, true),
(.compact, 430, false), (.regular, 1024, false), (.compact, 430, false),
] {
resize(controller, to: sizeClass, width: width)
tabs.sidebar.isHidden = !sidebar
try await settle()
capture(controller, name: "metrics-title-\(Int(width))-\(sidebar)-\(language)")
#expect(navigation.topViewController === page)
#expect(navigation.viewControllers.count == 2)
#expect(page.navigationItem.title == server.name)
if #available(iOS 27.0, *) {
#expect(page.navigationItem.additionalOverflowItems != nil)
#expect(page.navigationItem.overflowPresentationSource != nil)
} else {
#expect(toolbarButton(namedOneOf: ["Metrics", "监控", "監控"], in: navigation) != nil)
}
if sidebar {
#expect(visibleLabels(in: navigation.navigationBar).contains { $0.text == server.name })
} else if sizeClass == .regular {
#expect(!visibleLabels(in: navigation.navigationBar).contains { $0.text == server.name })
}
// The bottom bar leaves the principal toolbar slot available for the existing picker.
if sizeClass == .compact {
let picker = try #require(find(UISegmentedControl.self, in: controller.view))
#expect(picker.numberOfSegments == 2)
#expect(picker.selectedSegmentIndex == (selectedProcesses ? 1 : 0))
picker.selectedSegmentIndex = 1
picker.sendActions(for: .valueChanged)
selectedProcesses = true
try await settle()
#expect(find(UICollectionView.self, in: page.view) != nil)
}
}
navigation.popViewController(animated: false)
try await settle()
#expect(navigation.viewControllers.count == 1)
#expect(scene.selectedDestination == .metrics)
}
}
@Test(.requiresKeyWindow) func settingsDepthAndUnsavedFormsSurviveWidthChanges() async throws {
let app = try MobileAppModel(inMemory: true)
let scene = MobileSceneModel(app: app, sessionID: "test")
scene.openSettings()
try await withRoot(app: app, scene: scene) { controller in
let settings = try #require(navigation(titled: "Settings", in: controller))
try selectRow(4, inSectionWithCount: 6, in: settings.topViewController!)
try await settle()
let ai = try #require(navigation(titled: "AI", in: controller))
try selectRow(0, inSectionWithCount: 4, in: ai.topViewController!)
try await settle()
let models = try #require(navigation(titled: "Models", in: controller))
let detail = models.topViewController
for sizeClass: UIUserInterfaceSizeClass in [.compact, .regular, .compact] {
resize(controller, to: sizeClass)
try await settle()
#expect(navigation(titled: "Models", in: controller) === models)
#expect(models.topViewController === detail)
}
scene.openNewHost()
try await settle()
let hostID = try #require(scene.editingHost?.id)
let sheet = try #require(presented(in: controller))
let field = try #require(find(UITextField.self, in: sheet.view))
field.text = "Preserved draft"
field.sendActions(for: .editingChanged)
for sizeClass: UIUserInterfaceSizeClass in [.regular, .compact, .regular] {
resize(controller, to: sizeClass)
try await settle()
#expect(scene.editingHost?.id == hostID)
#expect(presented(in: controller) === sheet)
#expect(field.text == "Preserved draft")
#expect(app.library.hosts.isEmpty)
}
sheet.dismiss(animated: false)
try await settle()
scene.openSettings()
try await settle()
#expect(navigation(titled: "Models", in: controller) === models)
#expect(models.topViewController === detail)
}
}
@Test(.requiresKeyWindow) func vaultSearchAndScrollSurviveResizing() async throws {
let app = try MobileAppModel(inMemory: true)
for index in 0..<40 {
app.library.upsert(TermindDomain.Host(name: "Production \(index)", host: "server-\(index).example.invalid"))
}
let scene = MobileSceneModel(app: app, sessionID: "test")
let windowScene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let nativeSizeClass = windowScene.traitCollection.horizontalSizeClass
let nativeWidth: CGFloat = nativeSizeClass == .regular ? 1024 : 430
try await withRoot(app: app, scene: scene, initialSizeClass: nativeSizeClass) { controller in
let navigation = try #require(navigation(titled: "Vault", in: controller))
let page = try #require(navigation.topViewController)
let search = try #require(page.navigationItem.searchController)
search.searchBar.text = "Production"
search.searchBar.delegate?.searchBar?(search.searchBar, textDidChange: "Production")
search.searchResultsUpdater?.updateSearchResults(for: search)
try await settle()
let scroll = try #require(find(UIScrollView.self, in: page.view))
scroll.setContentOffset(CGPoint(x: 0, y: 300), animated: false)
try await settle()
capture(controller, name: "vault-scroll-before-resize")
// iPhone stays in its native width class; iPad also crosses regular/compact.
// Forcing regular width on a compact-only phone changes unsupported search-bar placement.
for (width, sizeClass): (CGFloat, UIUserInterfaceSizeClass) in [
(375, .compact), (nativeWidth, nativeSizeClass), (320, .compact),
] {
resize(controller, to: sizeClass, width: width)
try await settle()
capture(controller, name: "vault-scroll-\(sizeClass.rawValue)")
#expect(navigation.topViewController === page)
#expect(page.navigationItem.searchController === search)
#expect(search.searchBar.text == "Production")
#expect(find(UIScrollView.self, in: page.view) === scroll)
#expect(
scroll.contentOffset.y > 100,
"offset \(scroll.contentOffset), content \(scroll.contentSize), bounds \(scroll.bounds)")
}
}
}
@Test(.requiresKeyWindow) func twoWindowsKeepIndependentPagesAndDraftsDuringResizing() async throws {
let app = try MobileAppModel(inMemory: true)
let first = MobileSceneModel(app: app, sessionID: "test")
let second = MobileSceneModel(app: app, sessionID: "other")
first.selectedDestination = .metrics
second.openNewHost()
let hostID = second.editingHost?.id
try await withRoot(app: app, scene: first) { firstRoot in
let metrics = try #require(navigation(titled: "Metrics", in: firstRoot))
try await withRoot(app: app, scene: second) { secondRoot in
let sheet = try #require(presented(in: secondRoot))
resize(firstRoot, to: .compact)
try await settle()
#expect(first.selectedDestination == .metrics)
#expect(navigation(titled: "Metrics", in: firstRoot) === metrics)
#expect(second.editingHost?.id == hostID)
#expect(presented(in: secondRoot) === sheet)
resize(secondRoot, to: .compact)
resize(firstRoot, to: .regular)
try await settle()
#expect(presented(in: secondRoot) === sheet)
#expect(first.editingHost == nil)
#expect(second.selectedDestination == .vault)
}
}
}
@Test(.requiresKeyWindow, arguments: [false, true])
func savingSurvivesResizingThroughCompletionAndErrorFeedback(fails: Bool) async throws {
let app = try MobileAppModel(inMemory: true)
let scene = MobileSceneModel(app: app, sessionID: "test")
scene.openSettings()
let draft = SavingDraft(fails: fails)
var features = MobileFeatureRegistry()
features.register(
MobileFeatureRegistration(id: .settings, title: "Settings", symbol: "gearshape") { _, _ in
AnyView(SavingFeature(draft: draft))
})
defer { draft.finish() }
try await withRoot(app: app, scene: scene, features: features) { controller in
let sheet = try #require(presented(in: controller))
let editor = try #require(find(UINavigationController.self, in: sheet))
let item = try #require(editor.topViewController?.navigationItem)
let buttons = item.trailingItemGroups.flatMap(\.barButtonItems) + (item.rightBarButtonItems ?? [])
let save = try #require(buttons.first { ($0.accessibilityLabel ?? $0.title) == "Save" })
let action = try #require(save.action)
#expect(UIApplication.shared.sendAction(action, to: save.target, from: save, for: nil))
try await settle()
for sizeClass: UIUserInterfaceSizeClass in [.compact, .regular, .compact] {
resize(controller, to: sizeClass)
try await settle()
#expect(presented(in: controller) === sheet)
#expect(draft.saveCount == 1)
capture(sheet, name: "saving-\(sizeClass == .regular ? "wide" : "narrow")")
}
draft.finish()
try await settle()
if fails {
#expect(draft.isPresented)
#expect(draft.text == "Unsaved input")
let alert = try #require(find(UIAlertController.self, in: sheet))
resize(controller, to: .regular)
try await settle()
#expect(presented(in: controller) === sheet)
#expect(find(UIAlertController.self, in: sheet) === alert)
#expect(draft.text == "Unsaved input")
} else {
#expect(!draft.isPresented)
for _ in 0..<20 where presented(in: controller) != nil {
try await Task.sleep(for: .milliseconds(100))
}
#expect(presented(in: controller) == nil)
}
#expect(draft.saveCount == 1)
}
}
@Test(.requiresKeyWindow, arguments: ["en", "zh-Hans", "zh-Hant"])
func memoryWorkspaceListFocusesSelectedRowAndNavigates(language: String) async throws {
let suite = "termind.recent-workspace-ui.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let seed = try MobileAppModel(inMemory: true)
let host = TermindDomain.Host(name: "Production", host: "server.example.invalid")
seed.library.upsert(host)
_ = seed.connectionHistory.record(kind: .ssh, for: host.id)
let descriptors = [
MobileWorkspaceDescriptor(hostID: host.id, title: "Release Shell", kind: .ssh),
MobileWorkspaceDescriptor(hostID: host.id, title: "Project Files", kind: .sftp),
]
for sizeClass: UIUserInterfaceSizeClass in [.compact, .regular] {
var dependencies = MobileAppDependencies.testing()
dependencies.container = seed.container
let app = try MobileAppModel(dependencies: dependencies)
defer { app.testWorkspaceSession.closeAll() }
app.testWorkspaceSession.restoreClosedStates(descriptors.map { .init(descriptor: $0) })
let scene = MobileSceneModel(app: app, sessionID: "test")
let historyCount = app.connectionHistory.entries().count
scene.selectedDestination = .workspaces
try await withRoot(app: app, scene: scene, language: language, initialSizeClass: sizeClass) { root in
#expect(app.workspaces.count == 2)
#expect(scene.workspaceCount == 2)
#expect(scene.canCloseAllWorkspaces)
let title = language == "en" ? "Workspaces" : language == "zh-Hans" ? "工作区" : "工作區"
let navigation = try #require(navigation(titled: title, in: root))
let page = try #require(navigation.topViewController)
let list = try #require(find(UICollectionView.self, in: page.view))
#expect(list.numberOfSections == 2) // Transfers and the unified Workspaces section.
#expect((0..<list.numberOfSections).contains { list.numberOfItems(inSection: $0) == 2 })
#expect(app.testWorkspaceSession.orderedWorkspaceItems.map(\.id) == descriptors.map(\.id))
let width = sizeClass == .compact ? "narrow" : "wide"
capture(root, name: "recent-workspaces-\(width)-\(language)")
// SwiftUI Button uses its gesture action, not UICollectionView's selection delegate.
scene.selectWorkspace(descriptors[1].id)
try await settle()
#expect(scene.workspaceID == descriptors[1].id)
#expect(app.workspaces.map(\.id) == descriptors.map(\.id))
#expect(app.testWorkspaceSession.recentWorkspaces.isEmpty)
#expect(app.testWorkspaceSession.orderedWorkspaceItems.map(\.id) == descriptors.map(\.id))
#expect(list.numberOfSections == 2)
#expect((0..<list.numberOfSections).contains { list.numberOfItems(inSection: $0) == 2 })
#expect(navigation.viewControllers.count == 2)
capture(root, name: "recent-workspace-opened-\(width)-\(language)")
navigation.popViewController(animated: false)
try await settle()
#expect(scene.workspaceID == nil)
scene.closeAllWorkspaces()
try await settle()
#expect(app.testWorkspaceSession.recentWorkspaces.isEmpty)
#expect(!scene.canCloseAllWorkspaces)
#expect(app.connectionHistory.entries().count == historyCount)
#expect(MobileWorkspaceListView(app: app, scene: scene).historyRows.isEmpty == false)
}
}
}
private func selectRow(_ row: Int, inSectionWithCount count: Int, in controller: UIViewController) throws {
let list = try #require(find(UICollectionView.self, in: controller.view))
let section = try #require((0..<list.numberOfSections).first { list.numberOfItems(inSection: $0) == count })
let path = IndexPath(item: row, section: section)
list.selectItem(at: path, animated: false, scrollPosition: [])
list.delegate?.collectionView?(list, didSelectItemAt: path)
}
private func presented(in controller: UIViewController) -> UIViewController? {
if let presented = controller.presentedViewController { return presented }
return controller.children.lazy.compactMap { presented(in: $0) }.first
}
private func withRoot(
app: MobileAppModel, scene: MobileSceneModel, language: String = "en",
features: MobileFeatureRegistry = .standard(),
typeSize: DynamicTypeSize = .large,
initialSizeClass: UIUserInterfaceSizeClass = .regular,
regions: MobileRegionTestState? = nil,
check: (UIViewController) async throws -> Void
) async throws {
let windowScene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let previous = windowScene.keyWindow
let window = UIWindow(windowScene: windowScene)
let container = UIViewController()
let controller = UIHostingController(
rootView: MobileRegionTestHost(state: regions) {
MobileRootView(app: app, scene: scene, features: features)
}
.environment(\.locale, Locale(identifier: language))
.environment(\.dynamicTypeSize, typeSize))
window.rootViewController = container
container.addChild(controller)
container.view.addSubview(controller.view)
controller.didMove(toParent: container)
window.windowLevel = .alert + 3
window.makeKeyAndVisible()
resize(controller, to: initialSizeClass)
defer {
window.isHidden = true
window.rootViewController = nil
previous?.makeKey()
}
try await settle()
try await check(controller)
}
private func resize(_ controller: UIViewController, to sizeClass: UIUserInterfaceSizeClass, width: CGFloat? = nil) {
let frame = CGRect(x: 0, y: 0, width: width ?? (sizeClass == .regular ? 1024 : 430), height: 800)
controller.view.window?.frame = frame
controller.parent?.view.frame = frame
controller.traitOverrides.horizontalSizeClass = sizeClass
controller.view.frame = frame
controller.view.setNeedsLayout()
controller.view.layoutIfNeeded()
}
private func find<T: UIViewController>(_ type: T.Type, in controller: UIViewController) -> T? {
if let match = controller as? T { return match }
if let presented = controller.presentedViewController, let match = find(type, in: presented) { return match }
return controller.children.lazy.compactMap { find(type, in: $0) }.first
}
private func find<T: UIView>(_ type: T.Type, in view: UIView) -> T? {
if let match = view as? T { return match }
return view.subviews.lazy.compactMap { find(type, in: $0) }.first
}
private func navigation(titled title: String, in controller: UIViewController) -> UINavigationController? {
if let navigation = controller as? UINavigationController,
navigation.topViewController?.navigationItem.title == title
{
return navigation
}
return controller.children.lazy.compactMap { navigation(titled: title, in: $0) }.first
}
private func visibleLabels(in view: UIView) -> [UILabel] {
guard !view.isHidden, view.alpha > 0, view.bounds.width > 0, view.bounds.height > 0 else { return [] }
return (view as? UILabel).map { [$0] } ?? view.subviews.flatMap { visibleLabels(in: $0) }
}
private func toolbarButton(namedOneOf names: Set<String>, in navigation: UINavigationController) -> UIBarButtonItem?
{
guard let item = navigation.topViewController?.navigationItem else { return nil }
let buttons =
item.leadingItemGroups.flatMap(\.barButtonItems)
+ item.trailingItemGroups.flatMap(\.barButtonItems)
+ (item.leftBarButtonItems ?? []) + (item.rightBarButtonItems ?? [])
return buttons.first { button in
(button.accessibilityLabel ?? button.title ?? button.customView?.accessibilityLabel).map(names.contains)
== true
}
}
private func menuActions(_ menu: UIMenu) -> [UIAction] {
menu.children.flatMap { element -> [UIAction] in
if let action = element as? UIAction { return [action] }
if let submenu = element as? UIMenu { return menuActions(submenu) }
return []
}
}
private func resolvedMenuActions(_ menu: UIMenu, in view: UIView) async throws -> [UIAction] {
// SwiftUI supplies deferred menu elements. Present the native menu to ask UIKit to resolve them.
let button = UIButton(frame: CGRect(x: 20, y: 100, width: 44, height: 44))
button.menu = menu
button.showsMenuAsPrimaryAction = true
view.addSubview(button)
defer { button.removeFromSuperview() }
button.performPrimaryAction()
try await settle()
let interaction = try #require(button.contextMenuInteraction)
var actions: [UIAction] = []
interaction.updateVisibleMenu { resolved in
actions = menuActions(resolved)
return resolved
}
interaction.dismissMenu()
try await settle()
return actions
}
private func settle() async throws { try await Task.sleep(for: .milliseconds(500)) }
private func capture(_ controller: UIViewController, name: String) {
let data = UIGraphicsImageRenderer(bounds: controller.view.bounds).pngData { _ in
controller.view.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
}
Attachment.record(data, named: "\(name).png")
}
}
private struct MetricsDetailFeature: View {
let app: MobileAppModel
let server: ServerMetric
@State private var showsDetail = true
var body: some View {
Text("Metrics")
.navigationTitle("Metrics")
.navigationDestination(isPresented: $showsDetail) {
MobileMetricsDetailView(app: app, server: server)
}
}
}
@Observable @MainActor private final class SavingDraft {
enum Failure: Error { case expected }
var isPresented = true
var text = "Unsaved input"
var saveCount = 0
var continuation: CheckedContinuation<Void, Never>?
let fails: Bool
init(fails: Bool) { self.fails = fails }
func save() async throws {
saveCount += 1
await withCheckedContinuation { continuation = $0 }
try Task.checkCancellation()
if fails { throw Failure.expected }
}
func finish() {
continuation?.resume()
continuation = nil
}
}
private struct SavingFeature: View {
@Bindable var draft: SavingDraft
var body: some View {
Text("Settings")
.navigationTitle("Settings")
.sheet(isPresented: $draft.isPresented) {
MobileEditorSheet(title: "Snippet", isDirty: true, save: draft.save) {
TextField("Title", text: $draft.text)
}
}
}
}