444 lines
19 KiB
Swift
444 lines
19 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import AgentKit
|
|
import Foundation
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindInfrastructure
|
|
import Testing
|
|
|
|
@testable import Termind
|
|
|
|
struct SettingsNavigationTests {
|
|
@Test @MainActor
|
|
func autosaveDoesNotRemoveTheSpaceBetweenWordsWhileTypingAName() throws {
|
|
let model = TerminalSettingsModel()
|
|
let window = WindowModel()
|
|
let theme = TerminalCustomTheme(name: "", appearance: .both, baseThemeName: "Termind Dark")
|
|
window.openSettings(route: .terminalCustomThemes)
|
|
window.beginEditingTerminalTheme(theme, isNew: true, in: model)
|
|
let draft = try #require(window.terminalThemeDraft)
|
|
draft.value.name = "Ocean "
|
|
window.saveTerminalThemeChanges(allowNameTrimming: false)
|
|
#expect(draft.value.name == "Ocean ")
|
|
draft.value.name += "Blue "
|
|
window.navigateBackInSettings()
|
|
#expect(model.themeLibrary.themes.first?.name == "Ocean Blue")
|
|
#expect(!window.showsDiscardTerminalTheme)
|
|
}
|
|
|
|
@Test(arguments: [false, true]) @MainActor
|
|
func themeEditsSaveBeforeBackOrChangingDestinations(changesDestination: Bool) throws {
|
|
let model = TerminalSettingsModel()
|
|
let window = WindowModel()
|
|
let theme = TerminalCustomTheme(name: "Personal", appearance: .both, baseThemeName: "Termind Dark")
|
|
model.saveCustomTheme(theme, isNew: true)
|
|
window.openSettings(route: .terminalCustomThemes)
|
|
window.beginEditingTerminalTheme(theme, isNew: false, in: model)
|
|
let draft = try #require(window.terminalThemeDraft)
|
|
draft.value.configuration = "background = 123456"
|
|
if changesDestination { window.selectVault() } else { window.navigateBackInSettings() }
|
|
#expect(model.themeLibrary.themes.first?.configuration == "background = 123456")
|
|
#expect(!draft.isDirty)
|
|
#expect(!window.showsDiscardTerminalTheme)
|
|
#expect(window.terminalThemeDraft == nil)
|
|
#expect(window.settingsRoute == .terminalCustomThemes)
|
|
#expect(window.selectedDestination == (changesDestination ? .vault : .settings))
|
|
}
|
|
|
|
@Test @MainActor
|
|
func invalidOrDeletedDraftsRemainProtectedAndDiscardCannotResurrectThem() throws {
|
|
let model = TerminalSettingsModel()
|
|
let window = WindowModel()
|
|
let theme = TerminalCustomTheme(name: "Personal", appearance: .both, baseThemeName: "Termind Dark")
|
|
model.saveCustomTheme(theme, isNew: true)
|
|
window.openSettings(route: .terminalCustomThemes)
|
|
window.beginEditingTerminalTheme(theme, isNew: false, in: model)
|
|
let draft = try #require(window.terminalThemeDraft)
|
|
draft.value.name = " "
|
|
window.navigateBackInSettings()
|
|
#expect(window.showsDiscardTerminalTheme)
|
|
#expect(model.themeLibrary.themes.first?.name == "Personal")
|
|
window.cancelTerminalThemeNavigation()
|
|
draft.value.name = "Recovered"
|
|
model.deleteCustomTheme(id: theme.id)
|
|
window.navigateBackInSettings()
|
|
#expect(window.showsDiscardTerminalTheme)
|
|
window.discardTerminalThemeAndNavigate()
|
|
window.saveTerminalThemeChanges()
|
|
#expect(model.themeLibrary.themes.isEmpty)
|
|
#expect(window.terminalThemeDraft == nil)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func failedAutosavePreservesDraftAndProtectsNavigationUntilRetry() throws {
|
|
let store = UnavailableThemeStore()
|
|
let model = TerminalSettingsModel(persistence: store)
|
|
let window = WindowModel()
|
|
let theme = TerminalCustomTheme(name: "", appearance: .both, baseThemeName: "Termind Dark")
|
|
window.openSettings(route: .terminalCustomThemes)
|
|
window.beginEditingTerminalTheme(theme, isNew: true, in: model)
|
|
let draft = try #require(window.terminalThemeDraft)
|
|
draft.value.name = "New"
|
|
window.navigateBackInSettings()
|
|
#expect(window.showsDiscardTerminalTheme)
|
|
#expect(draft.isDirty)
|
|
#expect(model.themeLibrary.themes.isEmpty)
|
|
#expect(model.storageError != nil)
|
|
window.cancelTerminalThemeNavigation()
|
|
store.fails = false
|
|
window.navigateBackInSettings()
|
|
#expect(!window.showsDiscardTerminalTheme)
|
|
#expect(window.terminalThemeDraft == nil)
|
|
#expect(model.themeLibrary.themes.first?.name == "New")
|
|
}
|
|
|
|
@Test @MainActor
|
|
func failedDuplicateSaveIsProtectedEvenBeforeFurtherEdits() {
|
|
let store = UnavailableThemeStore()
|
|
let model = TerminalSettingsModel(persistence: store)
|
|
let window = WindowModel()
|
|
let theme = TerminalCustomTheme(name: "Personal Copy", appearance: .both, baseThemeName: "Termind Dark")
|
|
window.openSettings(route: .terminalCustomThemes)
|
|
window.beginEditingTerminalTheme(theme, isNew: true, in: model)
|
|
#expect(window.terminalThemeDraft?.isDirty == false)
|
|
window.navigateBackInSettings()
|
|
#expect(window.hasUnsavedTerminalThemeChanges)
|
|
#expect(window.showsDiscardTerminalTheme)
|
|
#expect(window.settingsRoute == .terminalCustomTheme(theme.id))
|
|
}
|
|
|
|
@Test @MainActor
|
|
func dirtyThemeProtectsBackNavigationAndChangingDestinations() {
|
|
let window = WindowModel()
|
|
let theme = TerminalCustomTheme(name: "Personal", appearance: .both, baseThemeName: "Termind Dark")
|
|
window.openSettings(route: .terminalCustomThemes)
|
|
window.terminalThemeDraft = .init(theme, isNew: false)
|
|
window.settingsRoute = .terminalCustomTheme(theme.id)
|
|
window.terminalThemeDraft?.value.name = "Unsaved"
|
|
window.navigateBackInSettings()
|
|
#expect(window.settingsRoute == .terminalCustomTheme(theme.id))
|
|
#expect(window.showsDiscardTerminalTheme)
|
|
window.cancelTerminalThemeNavigation()
|
|
#expect(window.terminalThemeDraft?.value.name == "Unsaved")
|
|
window.selectVault()
|
|
#expect(window.selectedDestination == .settings)
|
|
#expect(window.showsDiscardTerminalTheme)
|
|
window.discardTerminalThemeAndNavigate()
|
|
#expect(window.selectedDestination == .vault)
|
|
#expect(window.terminalThemeDraft == nil)
|
|
#expect(!window.showsDiscardTerminalTheme)
|
|
}
|
|
|
|
@Test(
|
|
arguments: [
|
|
EntitlementStatus.free, .purchased(.purchased), .purchased(.familyShared),
|
|
.subscription(
|
|
ProSubscriptionStatus(
|
|
phase: .subscribed, expiresAt: .distantFuture, willAutoRenew: true, ownership: .purchased)),
|
|
]) @MainActor
|
|
func proPresentationLeavesSettingsNavigationAndReturnDestinationIntact(status: EntitlementStatus) {
|
|
let workspace = Workspace(pane: Pane(.empty))
|
|
let window = WindowModel(workspaces: [workspace])
|
|
let pro = ProAccessController.testing(status: status)
|
|
window.openSettings()
|
|
|
|
pro.presentPaywall()
|
|
|
|
#expect(pro.isPaywallPresented)
|
|
#expect(window.settingsRoute == .index)
|
|
#expect(window.selectedDestination == .settings)
|
|
#expect(SettingsCategory(rawValue: "pro") == nil)
|
|
pro.isPaywallPresented = false
|
|
window.settingsRoute = .category(.terminal)
|
|
window.navigateBackInSettings()
|
|
#expect(window.settingsRoute == .index)
|
|
window.navigateBackInSettings()
|
|
#expect(window.selectedDestination == .workspace(workspace.id))
|
|
}
|
|
|
|
@Test @MainActor
|
|
func legacySearchSettingsAndSecretAreRemoved() throws {
|
|
let suite = "LegacyWebSearchSettingsTests.\(UUID().uuidString)"
|
|
let defaults = try #require(UserDefaults(suiteName: suite))
|
|
defer { defaults.removePersistentDomain(forName: suite) }
|
|
defaults.set("brave", forKey: LegacyWebSearchSettings.backendKey)
|
|
defaults.set("https://search.example", forKey: LegacyWebSearchSettings.baseURLKey)
|
|
defaults.set("termind.ai.search.old", forKey: LegacyWebSearchSettings.credentialReferenceKey)
|
|
var deleted: [String] = []
|
|
|
|
LegacyWebSearchSettings.remove(defaults: defaults) { deleted.append($0) }
|
|
|
|
#expect(deleted == ["termind.ai.search.old"])
|
|
#expect(defaults.object(forKey: LegacyWebSearchSettings.backendKey) == nil)
|
|
#expect(defaults.object(forKey: LegacyWebSearchSettings.baseURLKey) == nil)
|
|
#expect(defaults.object(forKey: LegacyWebSearchSettings.credentialReferenceKey) == nil)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func failedLegacySecretDeletionKeepsReferenceForRetry() throws {
|
|
let suite = "LegacyWebSearchSettingsTests.\(UUID().uuidString)"
|
|
let defaults = try #require(UserDefaults(suiteName: suite))
|
|
defer { defaults.removePersistentDomain(forName: suite) }
|
|
defaults.set("termind.ai.search.old", forKey: LegacyWebSearchSettings.credentialReferenceKey)
|
|
|
|
LegacyWebSearchSettings.remove(defaults: defaults) { _ in
|
|
throw CocoaError(.fileWriteNoPermission)
|
|
}
|
|
|
|
#expect(
|
|
defaults.string(forKey: LegacyWebSearchSettings.credentialReferenceKey)
|
|
== "termind.ai.search.old"
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func detailRoutesExposeTheirParentRoute() {
|
|
let providerID = UUID()
|
|
let serverID = UUID()
|
|
|
|
#expect(SettingsRoute.index.parent == nil)
|
|
#expect(SettingsRoute.category(.notifications).parent == .index)
|
|
#expect(SettingsCategory.notifications.symbol == "bell.badge")
|
|
#expect(SettingsRoute.category(.terminal).parent == .index)
|
|
#expect(SettingsRoute.modelProvider(providerID).parent == .category(.ai))
|
|
#expect(
|
|
SettingsRoute.providerModels(providerID).parent
|
|
== .modelProvider(providerID)
|
|
)
|
|
#expect(SettingsRoute.mcpServer(serverID, isNew: false).parent == .category(.ai))
|
|
#expect(SettingsRoute.localMCPServer.parent == .category(.ai))
|
|
#expect(SettingsRoute.terminalCustomTheme(UUID()).parent == .terminalCustomThemes)
|
|
#expect(SettingsRoute.terminalThemes(.dark).parent == .category(.terminal))
|
|
#expect(SettingsRoute.logging.parent == .index)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func settingsRestoresTheDestinationItReplaced() {
|
|
let workspace = Workspace(pane: Pane(.empty))
|
|
let window = WindowModel(workspaces: [workspace])
|
|
|
|
window.openSettings(route: .category(.terminal))
|
|
#expect(window.selectedDestination == .settings)
|
|
#expect(window.settingsRoute == .category(.terminal))
|
|
|
|
window.closeSettings()
|
|
#expect(window.selectedDestination == .workspace(workspace.id))
|
|
#expect(window.settingsRoute == .index)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func settingsFallsBackToVaultWhenItsWorkspaceDisappears() {
|
|
let workspace = Workspace(pane: Pane(.empty))
|
|
let window = WindowModel(workspaces: [workspace])
|
|
|
|
window.openSettings()
|
|
window.workspaces.removeAll()
|
|
window.closeSettings()
|
|
|
|
#expect(window.selectedDestination == .vault)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func settingsToolbarBackWalksTheRouteThenRestoresContent() {
|
|
let window = WindowModel()
|
|
window.openSettings(route: .terminalCustomTheme(UUID()))
|
|
|
|
window.navigateBackInSettings()
|
|
#expect(window.settingsRoute == .terminalCustomThemes)
|
|
|
|
window.navigateBackInSettings()
|
|
#expect(window.settingsRoute == .category(.terminal))
|
|
#expect(window.selectedDestination == .settings)
|
|
|
|
window.navigateBackInSettings()
|
|
#expect(window.settingsRoute == .index)
|
|
#expect(window.selectedDestination == .settings)
|
|
|
|
window.navigateBackInSettings()
|
|
#expect(window.selectedDestination == .vault)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func navigatorAndAssistantRoundTripThroughSettings() {
|
|
let window = WindowModel()
|
|
window.openSettings(route: .category(.app))
|
|
|
|
window.activateSidebarMode(.assistant)
|
|
#expect(window.selectedDestination == .assistant)
|
|
#expect(window.sidebarMode == .assistant)
|
|
|
|
window.activateSidebarMode(.navigation)
|
|
#expect(window.selectedDestination == .settings)
|
|
#expect(window.settingsRoute == .category(.app))
|
|
#expect(window.sidebarMode == .navigation)
|
|
}
|
|
|
|
@Test(arguments: ["", " \t\n "])
|
|
func emptyProviderDraftIsDiscarded(name: String) {
|
|
var provider = ModelProvider(name: name)
|
|
provider.inferenceURL = " \t\n "
|
|
#expect(SettingsDraftPolicy.shouldDiscard(provider))
|
|
}
|
|
|
|
@Test
|
|
func configuredProviderDraftIsKept() {
|
|
var provider = ModelProvider(name: "")
|
|
provider.inferenceURL = "https://example.com/v1/chat/completions"
|
|
#expect(!SettingsDraftPolicy.shouldDiscard(provider))
|
|
|
|
provider = ModelProvider(name: "")
|
|
provider.credentialRef = "test-credential"
|
|
#expect(!SettingsDraftPolicy.shouldDiscard(provider))
|
|
|
|
provider = ModelProvider(name: "")
|
|
provider.models = [AIModel(id: "test-model")]
|
|
#expect(!SettingsDraftPolicy.shouldDiscard(provider))
|
|
}
|
|
|
|
@Test(arguments: ["Work", "New Provider", String(localized: "New Provider")])
|
|
func namedProviderDraftIsKept(name: String) {
|
|
let provider = ModelProvider(name: name)
|
|
#expect(!SettingsDraftPolicy.shouldDiscard(provider))
|
|
}
|
|
|
|
@Test
|
|
func emptyServerDraftIsDiscarded() {
|
|
let server = MCPServer(name: "", namespaceID: "")
|
|
#expect(SettingsDraftPolicy.shouldDiscard(server))
|
|
}
|
|
|
|
@Test
|
|
func configuredServerDraftIsKept() {
|
|
var server = MCPServer(name: "", namespaceID: "")
|
|
server.url = "https://example.com/mcp"
|
|
#expect(SettingsDraftPolicy.shouldDiscard(server))
|
|
|
|
server = MCPServer(name: "Production")
|
|
#expect(!SettingsDraftPolicy.shouldDiscard(server))
|
|
}
|
|
|
|
@Test(arguments: [
|
|
("A B C-D", "a_b_c-d"),
|
|
(" Alpha!!!Beta ", "alpha_beta"),
|
|
("__Alpha--", "alpha"),
|
|
("MIXED_case-42", "mixed_case-42"),
|
|
("中文", ""),
|
|
("A中B", "a_b"),
|
|
("...", ""),
|
|
])
|
|
func mcpNamespaceIDNormalization(input: String, expected: String) {
|
|
#expect(MCPToolNaming.namespaceID(from: input) == expected)
|
|
}
|
|
|
|
@Test
|
|
func mcpNamespaceLocksAndRejectsDuplicates() throws {
|
|
let existing = MCPServer(name: "A B C-D")
|
|
var draft = MCPServer(name: "", namespaceID: "")
|
|
let store = AIConfigurationModel(
|
|
mcpServers: [existing, draft], persistsSecrets: false
|
|
)
|
|
|
|
draft.name = "Other Gateway"
|
|
store.upsert(draft)
|
|
#expect(store.lockMCPNamespace(for: draft.id))
|
|
#expect(store.mcpServer(draft.id)?.namespaceID == "other_gateway")
|
|
|
|
var renamed = try #require(store.mcpServer(draft.id))
|
|
renamed.name = "Renamed"
|
|
store.upsert(renamed)
|
|
#expect(store.mcpServer(draft.id)?.effectiveNamespaceID == "other_gateway")
|
|
|
|
let duplicate = MCPServer(name: "A!!!B C-D", namespaceID: "")
|
|
store.upsert(duplicate)
|
|
#expect(!store.lockMCPNamespace(for: duplicate.id))
|
|
#expect(store.mcpServer(duplicate.id)?.namespaceID == "")
|
|
}
|
|
|
|
@Test
|
|
func mcpToolAccessPolicyIsAtomicAndSurvivesRediscovery() throws {
|
|
var existingTool = MCPTool(id: "search")
|
|
existingTool.accessPolicy = .deny
|
|
let existingAsk = MCPTool(id: "existing-ask", accessPolicy: .ask)
|
|
let existingApprove = MCPTool(id: "existing-approve", accessPolicy: .approve)
|
|
var server = MCPServer(name: "Gateway")
|
|
server.tools = [existingTool, existingAsk, existingApprove]
|
|
let store = AIConfigurationModel(mcpServers: [server], persistsSecrets: false)
|
|
|
|
store.setTools(
|
|
[
|
|
MCPTool(
|
|
id: "search", title: "Updated",
|
|
annotations: MCPToolAnnotations(readOnlyHint: true)
|
|
),
|
|
MCPTool(
|
|
id: "existing-ask",
|
|
annotations: MCPToolAnnotations(readOnlyHint: true)
|
|
),
|
|
MCPTool(id: "existing-approve"),
|
|
MCPTool(
|
|
id: "new-read-only",
|
|
annotations: MCPToolAnnotations(readOnlyHint: true)
|
|
),
|
|
MCPTool(
|
|
id: "new-not-read-only",
|
|
annotations: MCPToolAnnotations(readOnlyHint: false)
|
|
),
|
|
MCPTool(id: "new-without-hint"),
|
|
MCPTool(
|
|
id: "new-conflicting-hints",
|
|
annotations: MCPToolAnnotations(readOnlyHint: true, destructiveHint: true)
|
|
),
|
|
],
|
|
version: "2025-06-18", for: server.id
|
|
)
|
|
|
|
let rediscovered = try #require(store.mcpServer(server.id))
|
|
#expect(rediscovered.tools.first { $0.id == "search" }?.accessPolicy == .deny)
|
|
#expect(rediscovered.tools.first { $0.id == "existing-ask" }?.accessPolicy == .ask)
|
|
#expect(rediscovered.tools.first { $0.id == "existing-approve" }?.accessPolicy == .approve)
|
|
#expect(rediscovered.tools.first { $0.id == "new-read-only" }?.accessPolicy == .approve)
|
|
#expect(rediscovered.tools.first { $0.id == "new-not-read-only" }?.accessPolicy == .ask)
|
|
#expect(rediscovered.tools.first { $0.id == "new-without-hint" }?.accessPolicy == .ask)
|
|
#expect(rediscovered.tools.first { $0.id == "new-conflicting-hints" }?.accessPolicy == .ask)
|
|
|
|
store.setToolAccessPolicy(.approve, tool: "search", on: server.id)
|
|
#expect(
|
|
store.mcpServer(server.id)?.tools.first { $0.id == "search" }?.accessPolicy
|
|
== .approve
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func destructiveMCPToolsCanBeApprovedAndDeniedToolsStayAvailable() throws {
|
|
var destructive = MCPTool(id: "destroy", accessPolicy: .approve)
|
|
destructive.annotations.destructiveHint = true
|
|
let denied = MCPTool(id: "denied", accessPolicy: .deny)
|
|
let server = MCPServer(name: "Gateway", tools: [destructive, denied])
|
|
let store = AIConfigurationModel(mcpServers: [server], persistsSecrets: false)
|
|
|
|
let normalized = try #require(store.mcpServer(server.id))
|
|
#expect(normalized.tools[0].accessPolicy == .approve)
|
|
#expect(store.availableTools.map(\.tool.id).contains("denied"))
|
|
|
|
store.setToolAccessPolicy(.approve, tool: "destroy", on: server.id)
|
|
#expect(store.mcpServer(server.id)?.tools[0].accessPolicy == .approve)
|
|
|
|
var newlyDestructive = MCPTool(id: "denied", accessPolicy: .ask)
|
|
newlyDestructive.annotations.destructiveHint = true
|
|
store.setToolAccessPolicy(.approve, tool: "denied", on: server.id)
|
|
store.setTools([newlyDestructive], version: "2025-06-18", for: server.id)
|
|
#expect(store.mcpServer(server.id)?.tools[0].accessPolicy == .approve)
|
|
}
|
|
}
|
|
|
|
@MainActor private final class UnavailableThemeStore: TerminalPreferencesPersisting {
|
|
var fails = true
|
|
func loadTerminal() throws -> TerminalPreferences { .init() }
|
|
func saveTerminal(_ value: TerminalPreferences) throws {}
|
|
func saveTerminalThemes(_ value: TerminalThemeLibrary) throws {
|
|
if fails { throw CocoaError(.fileWriteUnknown) }
|
|
}
|
|
}
|