246 lines
11 KiB
Swift
246 lines
11 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import AgentKit
|
|
import Foundation
|
|
import SwiftData
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPersistence
|
|
import TermindRemoteInfrastructure
|
|
import Testing
|
|
|
|
@testable import TermindIOS
|
|
|
|
@Suite @MainActor
|
|
struct MobileSettingsPortTests {
|
|
@Test func sftpSettingsUseTheSharedPreferencesAndUpdateWithoutOpeningSettings() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
#expect(app.sftpSettings.preferences == SFTPPreferences(globalLimit: 30, perHostLimit: 10))
|
|
#expect(app.transfers.globalLimit == 30)
|
|
#expect(app.transfers.perHostLimit == 10)
|
|
#expect(app.transfers.strategy == .smart)
|
|
#expect(app.sftpSettings.set(100, for: \.globalLimit))
|
|
#expect(app.sftpSettings.set(50, for: \.perHostLimit))
|
|
#expect(app.sftpSettings.setStrategy(.connectionsPreferred))
|
|
#expect(app.transfers.globalLimit == 100)
|
|
#expect(app.transfers.perHostLimit == 50)
|
|
#expect(app.transfers.strategy == .connectionsPreferred)
|
|
app.sftpSettings.applyRemoteSnapshot(
|
|
SFTPPreferences(globalLimit: 3, perHostLimit: 2, strategy: .channelsOnly))
|
|
#expect(app.transfers.globalLimit == 3)
|
|
#expect(app.transfers.perHostLimit == 2)
|
|
#expect(app.transfers.strategy == .channelsOnly)
|
|
}
|
|
|
|
@Test func sftpCoordinatorAppliesNormalizedSettingsImmediately() {
|
|
let coordinator = SFTPTransferCoordinator(
|
|
store: HostLibraryModel(), clientFactory: { SFTPClient() }, globalLimit: 3, perHostLimit: 2)
|
|
let clampedAtInitialization = SFTPTransferCoordinator(
|
|
store: HostLibraryModel(), clientFactory: { SFTPClient() }, globalLimit: 1, perHostLimit: 7)
|
|
|
|
#expect(coordinator.globalLimit == 3)
|
|
#expect(coordinator.perHostLimit == 2)
|
|
#expect(clampedAtInitialization.globalLimit == 1)
|
|
#expect(clampedAtInitialization.perHostLimit == 1)
|
|
coordinator.updateLimits(global: 0, perHost: 7)
|
|
|
|
#expect(coordinator.globalLimit == 1)
|
|
#expect(coordinator.perHostLimit == 1)
|
|
}
|
|
|
|
@Test func testsNeverStartCloudEngines() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
app.sceneChanged(UUID(), phase: .active)
|
|
#expect(app.vaultSync == nil)
|
|
#expect(app.connectionHistorySync == nil)
|
|
#expect(app.deviceSync == nil)
|
|
#expect(app.settingsSync == nil)
|
|
}
|
|
|
|
@Test func providerCanBeSavedWithoutModelsAndDoesNotChangeSelection() throws {
|
|
let ai = AIConfigurationModel()
|
|
let chosen = AIModelRef(modelProviderID: UUID(), modelID: "chosen")
|
|
ai.select(chosen)
|
|
let editor = MobileProviderEditorModel(ai: ai, provider: ModelProvider(name: "Gateway"))
|
|
editor.endpoint.url = "https://example.invalid/v1"
|
|
editor.endpoint.apiType = .responses
|
|
editor.secret = "test-key"
|
|
try editor.save()
|
|
let saved = try #require(ai.modelProvider(editor.original.id))
|
|
#expect(saved.models.isEmpty)
|
|
#expect(saved.inferenceURL == "https://example.invalid/v1/responses")
|
|
#expect(!saved.credentialRef.isEmpty)
|
|
#expect(ai.lastSelectedModel == chosen)
|
|
}
|
|
|
|
@Test func secretFailureKeepsDraftAndDoesNotPersistProvider() throws {
|
|
let secrets = EditorSecrets()
|
|
secrets.failWrites = true
|
|
let ai = AIConfigurationModel(aiSecrets: secrets)
|
|
let editor = MobileProviderEditorModel(ai: ai, provider: ModelProvider(name: "Gateway"))
|
|
editor.endpoint.url = "https://example.invalid/v1"
|
|
editor.secret = "test-key"
|
|
#expect(throws: (any Error).self) { try editor.save() }
|
|
#expect(ai.modelProviders.isEmpty)
|
|
#expect(editor.secret == "test-key")
|
|
#expect(ai.secretStorageError != nil)
|
|
}
|
|
|
|
@Test func metadataFailureRestoresPreviousSecret() throws {
|
|
let provider = ModelProvider(
|
|
name: "Before", inferenceURL: "https://example.invalid/v1/responses", credentialRef: "ref")
|
|
let persistence = EditorPersistence(provider: provider)
|
|
let secrets = EditorSecrets()
|
|
secrets.values["ref"] = "before"
|
|
let ai = AIConfigurationModel(persistence: persistence, aiSecrets: secrets)
|
|
let editor = MobileProviderEditorModel(ai: ai, provider: provider)
|
|
editor.draft.name = "After"
|
|
editor.secret = "after"
|
|
persistence.failWrites = true
|
|
#expect(throws: (any Error).self) { try editor.save() }
|
|
#expect(ai.modelProvider(provider.id)?.name == "Before")
|
|
#expect(secrets.values["ref"] == "before")
|
|
#expect(editor.isDirty)
|
|
}
|
|
|
|
@Test func editingPreservesRefreshedModelsAndCanClearAKey() throws {
|
|
let provider = ModelProvider(
|
|
name: "Gateway", inferenceURL: "https://example.invalid/v1/responses", credentialRef: "ref")
|
|
let secrets = EditorSecrets()
|
|
secrets.values["ref"] = "before"
|
|
let ai = AIConfigurationModel(modelProviders: [provider], aiSecrets: secrets)
|
|
let editor = MobileProviderEditorModel(ai: ai, provider: provider)
|
|
ai.addModel(AIModel(id: "new-model"), to: provider.id)
|
|
editor.secret = ""
|
|
try editor.save()
|
|
#expect(ai.modelProvider(provider.id)?.models.map(\.id) == ["new-model"])
|
|
#expect(ai.modelProvider(provider.id)?.credentialRef == "")
|
|
#expect(secrets.values["ref"] == nil)
|
|
}
|
|
|
|
@Test func remoteDeletionDoesNotResurrectAnOpenDraft() throws {
|
|
let provider = ModelProvider(name: "Gateway", inferenceURL: "https://example.invalid/v1/responses")
|
|
let ai = AIConfigurationModel(modelProviders: [provider])
|
|
let editor = MobileProviderEditorModel(ai: ai, provider: provider)
|
|
ai.delete(modelProviderID: provider.id)
|
|
#expect(throws: (any Error).self) { try editor.save() }
|
|
#expect(ai.modelProviders.isEmpty)
|
|
}
|
|
|
|
@Test func cancelledDiscoveryIgnoresAnUncooperativeResponse() async {
|
|
let latch = DiscoveryLatch()
|
|
let provider = ModelProvider(name: "Gateway")
|
|
let ai = AIConfigurationModel(modelProviders: [provider])
|
|
let discovery = MobileModelDiscovery(fetch: { _, _ in await latch.wait() })
|
|
discovery.start(ai: ai, provider: provider)
|
|
await latch.awaitStarted()
|
|
discovery.cancel()
|
|
await latch.finish()
|
|
for _ in 0..<10 { await Task.yield() }
|
|
#expect(discovery.candidates.isEmpty)
|
|
#expect(!discovery.running)
|
|
#expect(ai.modelProvider(provider.id)?.models.isEmpty == true)
|
|
}
|
|
|
|
@Test func deletedProviderIgnoresDiscoveryResponse() async {
|
|
let latch = DiscoveryLatch()
|
|
let provider = ModelProvider(name: "Gateway")
|
|
let ai = AIConfigurationModel(modelProviders: [provider])
|
|
let discovery = MobileModelDiscovery(fetch: { _, _ in await latch.wait() })
|
|
discovery.start(ai: ai, provider: provider)
|
|
await latch.awaitStarted()
|
|
ai.delete(modelProviderID: provider.id)
|
|
await latch.finish()
|
|
for _ in 0..<10 { await Task.yield() }
|
|
#expect(discovery.candidates.isEmpty)
|
|
}
|
|
|
|
@Test func terminalHistoryRequiresIntegrationAndPairsCompletionOnlyOnce() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let host = TermindDomain.Host(name: "Test", host: "example.invalid")
|
|
app.library.upsert(host)
|
|
let workspace = MobileWorkspace(
|
|
descriptor: .init(hostID: host.id, title: host.name, kind: .ssh),
|
|
hosts: app.library, terminalSettings: app.terminalSettings)
|
|
workspace.receiveShellEvent(.commandStarted("not-authenticated"))
|
|
#expect(workspace.commandHistory.entries().isEmpty)
|
|
workspace.receiveShellEvent(.available(shell: "bash", systemInfo: nil, homeDirectory: nil))
|
|
workspace.receiveShellEvent(
|
|
.commandStarted("echo hello \\\n world\nprintf retained")
|
|
)
|
|
workspace.receiveShellEvent(.commandFinished(0, duration: 1.25))
|
|
workspace.receiveShellEvent(.commandFinished(1, duration: 9))
|
|
let entry = try #require(workspace.commandHistory.entries().first)
|
|
#expect(entry.command == "echo hello world\nprintf retained")
|
|
#expect(entry.exitStatus == 0)
|
|
#expect(entry.duration == 1.25)
|
|
workspace.receiveShellEvent(.commandStarted("interrupted"))
|
|
workspace.disconnect()
|
|
workspace.receiveShellEvent(.commandFinished(0, duration: 1))
|
|
#expect(workspace.commandHistory.entries().first?.exitStatus == nil)
|
|
}
|
|
|
|
@Test func findQuerySurvivesDismissalAndNavigationReopensSearch() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let host = TermindDomain.Host(name: "Test", host: "example.invalid")
|
|
app.library.upsert(host)
|
|
let workspace = MobileWorkspace(
|
|
descriptor: .init(hostID: host.id, title: host.name, kind: .ssh), hosts: app.library)
|
|
workspace.find.present()
|
|
workspace.find.updateQuery("retained")
|
|
workspace.find.dismiss()
|
|
workspace.find.present()
|
|
#expect(workspace.find.query == "retained")
|
|
#expect(workspace.find.isPresented)
|
|
|
|
workspace.find.dismiss()
|
|
workspace.find.presentAndNavigate(.next)
|
|
#expect(workspace.find.query == "retained")
|
|
#expect(workspace.find.isPresented)
|
|
}
|
|
|
|
@Test func remoteHostDeletionClearsCommandHistory() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let host = TermindDomain.Host(name: "Test", host: "example.invalid")
|
|
app.library.upsert(host)
|
|
let id = try #require(app.testWorkspaceSession.open(hostID: host.id, kind: .ssh))
|
|
let workspace = try #require(app.workspaces.first { $0.id == id })
|
|
workspace.commandHistory.record("test")
|
|
app.applyRemoteVault(.init(), keys: [VaultRecordKey(kind: .host, id: host.id)])
|
|
#expect(app.library.host(host.id) == nil)
|
|
#expect(workspace.commandHistory.entries().isEmpty)
|
|
}
|
|
}
|
|
|
|
@MainActor private final class EditorSecrets: AISecretStoring {
|
|
var values: [String: String] = [:]
|
|
var failWrites = false
|
|
func string(for reference: String) throws -> String? { values[reference] }
|
|
func setString(_ value: String?, for reference: String) throws {
|
|
if failWrites { throw CocoaError(.fileWriteNoPermission) }
|
|
values[reference] = value
|
|
}
|
|
}
|
|
|
|
@MainActor private final class EditorPersistence: AIConfigurationPersisting {
|
|
var value: AIConfigurationSnapshot
|
|
var failWrites = false
|
|
init(provider: ModelProvider) { value = .init(providers: [provider], roles: [:]) }
|
|
func load() throws -> AIConfigurationSnapshot { value }
|
|
func save(_ snapshot: AIConfigurationSnapshot) throws {
|
|
if failWrites { throw CocoaError(.fileWriteNoPermission) }
|
|
value = snapshot
|
|
}
|
|
}
|
|
|
|
private actor DiscoveryLatch {
|
|
private var continuation: CheckedContinuation<[AIModel], Never>?
|
|
func wait() async -> [AIModel] { await withCheckedContinuation { continuation = $0 } }
|
|
func awaitStarted() async { while continuation == nil { await Task.yield() } }
|
|
func finish() {
|
|
continuation?.resume(returning: [AIModel(id: "late")])
|
|
continuation = nil
|
|
}
|
|
}
|