native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503import Dependenciesimport DependenciesTestSupportimport Foundationimport Sharingimport Testing
@testable import Prowl
struct SettingsFilePersistenceTests { @Test func legacyGlobalSettingsDefaultsAgentIslandFields() throws { let encoded = try JSONEncoder().encode(GlobalSettings.default) var dictionary = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any]) dictionary.removeValue(forKey: "agentIslandOnlyShowWithAgents") dictionary.removeValue(forKey: "agentIslandEnabled") dictionary.removeValue(forKey: "agentIslandDisplayPreference") dictionary.removeValue(forKey: "agentIslandFloatingPositions") dictionary.removeValue(forKey: "agentIslandSilentOpacity")
let legacyData = try JSONSerialization.data(withJSONObject: dictionary) let decoded = try JSONDecoder().decode(GlobalSettings.self, from: legacyData)
#expect(!decoded.agentIslandOnlyShowWithAgents) #expect(!decoded.agentIslandEnabled) #expect(decoded.agentIslandDisplayPreference == .automatic) #expect(decoded.agentIslandFloatingPositions.isEmpty) #expect(decoded.agentIslandSilentOpacity == AgentIslandOpacityPolicy.defaultSilentOpacity) }
@Test func agentIslandDisplayPreferenceRoundTripsStableDisplayID() throws { var settings = GlobalSettings.default settings.agentIslandOnlyShowWithAgents = true settings.agentIslandEnabled = true settings.agentIslandDisplayPreference = .display(id: "display-uuid", name: "Studio Display") settings.agentIslandFloatingPositions.setNormalizedPosition(0.25, for: "display-uuid") settings.agentIslandSilentOpacity = 0.6
let data = try JSONEncoder().encode(settings) let decoded = try JSONDecoder().decode(GlobalSettings.self, from: data)
#expect(decoded.agentIslandOnlyShowWithAgents) #expect(decoded.agentIslandEnabled) #expect( decoded.agentIslandDisplayPreference == .display(id: "display-uuid", name: "Studio Display")) #expect(decoded.agentIslandFloatingPositions.normalizedPosition(for: "display-uuid") == 0.25) #expect(decoded.agentIslandSilentOpacity == 0.6) }
@Test(.dependencies) func loadWritesDefaultsWhenMissing() throws { let storage = SettingsTestStorage()
let settings: SettingsFile = withDependencies { $0.settingsFileStorage = storage.storage } operation: { @Shared(.settingsFile) var settings: SettingsFile return settings }
#expect(settings == .default)
let reloaded: SettingsFile = withDependencies { $0.settingsFileStorage = storage.storage } operation: { @Shared(.settingsFile) var settings: SettingsFile return settings }
#expect(reloaded == .default) }
@Test(.dependencies) func saveAndReload() throws { let storage = SettingsTestStorage()
withDependencies { $0.settingsFileStorage = storage.storage } operation: { @Shared(.settingsFile) var settings: SettingsFile $settings.withLock { $0.global.appearanceMode = .dark $0.repositoryRoots = ["/tmp/repo-a", "/tmp/repo-b"] $0.pinnedWorktreeIDs = ["/tmp/repo-a/wt-1"] } }
let reloaded: SettingsFile = withDependencies { $0.settingsFileStorage = storage.storage } operation: { @Shared(.settingsFile) var settings: SettingsFile return settings }
#expect(reloaded.global.appearanceMode == .dark) #expect(reloaded.repositoryRoots == ["/tmp/repo-a", "/tmp/repo-b"]) #expect(reloaded.pinnedWorktreeIDs == ["/tmp/repo-a/wt-1"]) }
@Test(.dependencies) func saveAndReloadToolbarAndDockSettings() throws { let storage = SettingsTestStorage()
withDependencies { $0.settingsFileStorage = storage.storage } operation: { @Shared(.settingsFile) var settings: SettingsFile $settings.withLock { $0.global.showRunButtonInToolbar = false $0.global.showDefaultEditorInToolbar = false $0.global.dockBounceMode = .continuous $0.global.showNotificationDotOnDock = true } }
let reloaded: SettingsFile = withDependencies { $0.settingsFileStorage = storage.storage } operation: { @Shared(.settingsFile) var settings: SettingsFile return settings }
#expect(reloaded.global.showRunButtonInToolbar == false) #expect(reloaded.global.showDefaultEditorInToolbar == false) #expect(reloaded.global.dockBounceMode == .continuous) #expect(reloaded.global.showNotificationDotOnDock == true) }
@Test(.dependencies) func saveAndReloadShelfSpineTintPreferences() throws { let storage = SettingsTestStorage()
withDependencies { $0.settingsFileStorage = storage.storage } operation: { @Shared(.settingsFile) var settings: SettingsFile $settings.withLock { $0.global.shelfSpineTintFallback = .systemTint $0.global.shelfSpineTintFollowsRepositoryColor = false $0.global.showActiveAgentStatusInShelf = false } }
let reloaded: SettingsFile = withDependencies { $0.settingsFileStorage = storage.storage } operation: { @Shared(.settingsFile) var settings: SettingsFile return settings }
#expect(reloaded.global.shelfSpineTintFallback == .systemTint) #expect(reloaded.global.shelfSpineTintFollowsRepositoryColor == false) #expect(reloaded.global.showActiveAgentStatusInShelf == false) }
@Test(.dependencies) func invalidJSONResetsToDefaults() throws { let storage = MutableTestStorage(initialData: Data("{".utf8))
let settings: SettingsFile = withDependencies { $0.settingsFileStorage = storage.storage } operation: { @Shared(.settingsFile) var settings: SettingsFile return settings }
#expect(settings == .default)
let reloaded: SettingsFile = withDependencies { $0.settingsFileStorage = storage.storage } operation: { @Shared(.settingsFile) var settings: SettingsFile return settings }
#expect(reloaded == .default) }
@Test(.dependencies) func decodesMissingInAppNotificationsEnabled() throws { let legacy = LegacySettingsFile( global: LegacyGlobalSettings( appearanceMode: .dark, updatesAutomaticallyCheckForUpdates: false, updatesAutomaticallyDownloadUpdates: true ), repositories: [:] ) let data = try JSONEncoder().encode(legacy) let storage = MutableTestStorage(initialData: data)
let settings: SettingsFile = withDependencies { $0.settingsFileStorage = storage.storage } operation: { @Shared(.settingsFile) var settings: SettingsFile return settings }
#expect(settings.global.appearanceMode == .dark) #expect(settings.global.confirmBeforeQuit == true) #expect(settings.global.updatesAutomaticallyCheckForUpdates == false) #expect(settings.global.updatesAutomaticallyDownloadUpdates == true) #expect(settings.global.inAppNotificationsEnabled == true) // Missing key (pre-feature file) decodes to the default sound. #expect(settings.global.notificationSound == .prowlClassic) #expect(settings.global.systemNotificationsEnabled == false) #expect(settings.global.moveNotifiedWorktreeToTop == true) #expect(settings.global.analyticsEnabled == true) #expect(settings.global.crashReportsEnabled == true) #expect(settings.global.githubIntegrationEnabled == true) #expect(settings.global.deleteBranchOnAutomaticCleanup == false) #expect(settings.global.mergedWorktreeAction == nil) #expect(settings.global.promptForWorktreeCreation == true) #expect(settings.global.defaultWorktreeBaseDirectoryPath == nil) #expect(settings.global.restoreTerminalLayoutOnLaunch == false) #expect(settings.global.defaultEditorID == OpenWorktreeAction.automaticSettingsID) #expect(settings.global.showRunButtonInToolbar == true) #expect(settings.global.showDefaultEditorInToolbar == true) #expect(settings.global.dockBounceMode == .off) #expect(settings.global.showNotificationDotOnDock == false) #expect(settings.global.showActiveAgentStatusInShelf == true) #expect(settings.global.shelfSpineTintFallback == .neutral) #expect(settings.global.shelfSpineTintFollowsRepositoryColor == true) // Absent from legacy payloads → falls back to the new Tile default. #expect(settings.global.canvasDefaultLayout == .tile) #expect(settings.repositoryRoots.isEmpty) #expect(settings.pinnedWorktreeIDs.isEmpty) }
@Test(.dependencies) func legacyAutomaticallyArchiveMergedWorktreesMigratesToMergedWorktreeAction() throws { let legacy = LegacyAutoArchiveSettingsFile( global: LegacyAutoArchiveGlobalSettings( appearanceMode: .dark, updatesAutomaticallyCheckForUpdates: false, updatesAutomaticallyDownloadUpdates: true, automaticallyArchiveMergedWorktrees: true ), repositories: [:] ) let data = try JSONEncoder().encode(legacy) let storage = MutableTestStorage(initialData: data)
let settings: SettingsFile = withDependencies { $0.settingsFileStorage = storage.storage } operation: { @Shared(.settingsFile) var settings: SettingsFile return settings }
#expect(settings.global.mergedWorktreeAction == .archive) }
@Test(.dependencies) func legacyAutomaticallyArchiveFalseMigratesToNil() throws { let legacy = LegacyAutoArchiveSettingsFile( global: LegacyAutoArchiveGlobalSettings( appearanceMode: .dark, updatesAutomaticallyCheckForUpdates: false, updatesAutomaticallyDownloadUpdates: true, automaticallyArchiveMergedWorktrees: false ), repositories: [:] ) let data = try JSONEncoder().encode(legacy) let storage = MutableTestStorage(initialData: data)
let settings: SettingsFile = withDependencies { $0.settingsFileStorage = storage.storage } operation: { @Shared(.settingsFile) var settings: SettingsFile return settings }
#expect(settings.global.mergedWorktreeAction == nil) }
@Test(.dependencies) func legacyDeleteBranchOnDeleteWorktreeMigratesToAutomaticCleanup() throws { // Files written before the setting was split carry only the legacy key; // an opted-in user keeps branch deletion during automatic cleanup. let encoded = try JSONEncoder().encode(GlobalSettings.default) var globalDict = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any]) globalDict.removeValue(forKey: "deleteBranchOnAutomaticCleanup") globalDict["deleteBranchOnDeleteWorktree"] = true let data = try JSONSerialization.data(withJSONObject: ["global": globalDict, "repositories": [:]]) let storage = MutableTestStorage(initialData: data)
let settings: SettingsFile = withDependencies { $0.settingsFileStorage = storage.storage } operation: { @Shared(.settingsFile) var settings: SettingsFile return settings }
#expect(settings.global.deleteBranchOnAutomaticCleanup == true) }
@Test(.dependencies) func explicitDeleteBranchOnAutomaticCleanupWinsOverLegacyKey() throws { let encoded = try JSONEncoder().encode(GlobalSettings.default) var globalDict = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any]) globalDict["deleteBranchOnAutomaticCleanup"] = false globalDict["deleteBranchOnDeleteWorktree"] = true let data = try JSONSerialization.data(withJSONObject: ["global": globalDict, "repositories": [:]]) let storage = MutableTestStorage(initialData: data)
let settings: SettingsFile = withDependencies { $0.settingsFileStorage = storage.storage } operation: { @Shared(.settingsFile) var settings: SettingsFile return settings }
#expect(settings.global.deleteBranchOnAutomaticCleanup == false) }
@Test func encodesAutomaticCleanupSettingWithoutLegacyKey() throws { var settings = GlobalSettings.default settings.deleteBranchOnAutomaticCleanup = true
let encoded = try JSONEncoder().encode(settings) let globalDict = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any])
#expect(globalDict["deleteBranchOnAutomaticCleanup"] as? Bool == true) #expect(globalDict["deleteBranchOnDeleteWorktree"] == nil) }
@Test(.dependencies) func roundTripsExplicitNotificationSound() throws { let storage = SettingsTestStorage()
withDependencies { $0.settingsFileStorage = storage.storage } operation: { @Shared(.settingsFile) var settings: SettingsFile $settings.withLock { $0.global.notificationSound = .submarine } }
let reloaded: SettingsFile = withDependencies { $0.settingsFileStorage = storage.storage } operation: { @Shared(.settingsFile) var reloaded: SettingsFile return reloaded }
// An explicitly chosen sound must survive a save / reload round-trip. #expect(reloaded.global.notificationSound == .submarine) }
@Test(.dependencies) func migratesLegacyNotificationSoundEnabledFalseToNever() throws { // A pre-picker file with the sound explicitly muted must stay muted, not // resurface as the default sound on upgrade. let legacy = LegacySettingsFileWithSoundToggle( global: LegacyGlobalSettingsWithSoundToggle( appearanceMode: .dark, updatesAutomaticallyCheckForUpdates: true, updatesAutomaticallyDownloadUpdates: false, notificationSoundEnabled: false ), repositories: [:] ) let data = try JSONEncoder().encode(legacy) let storage = MutableTestStorage(initialData: data)
let settings: SettingsFile = withDependencies { $0.settingsFileStorage = storage.storage } operation: { @Shared(.settingsFile) var settings: SettingsFile return settings }
#expect(settings.global.notificationSound == .never) }
@Test(.dependencies) func migratesLegacyNotificationSoundEnabledTrueToDefault() throws { // Symmetric to the `false` case: the sound was on, so it folds to the // classic chime — what those users were already hearing. let legacy = LegacySettingsFileWithSoundToggle( global: LegacyGlobalSettingsWithSoundToggle( appearanceMode: .dark, updatesAutomaticallyCheckForUpdates: true, updatesAutomaticallyDownloadUpdates: false, notificationSoundEnabled: true ), repositories: [:] ) let data = try JSONEncoder().encode(legacy) let storage = MutableTestStorage(initialData: data)
let settings: SettingsFile = withDependencies { $0.settingsFileStorage = storage.storage } operation: { @Shared(.settingsFile) var settings: SettingsFile return settings }
#expect(settings.global.notificationSound == .prowlClassic) }
@Test(.dependencies) func decodesUnrecognizedNotificationSoundAsDefaultWithoutResettingSiblings() throws { // A hand-edited or downgraded file carrying a sound case this build doesn't // know yet. The `try?` must isolate the fallback to this one field. var global = GlobalSettings.default global.appearanceMode = .dark global.systemNotificationsEnabled = true global.updatesAutomaticallyDownloadUpdates = true
let encoded = try JSONEncoder().encode(global) var globalDict = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any]) globalDict["notificationSound"] = "futureSoundFromNewerBuild" let data = try JSONSerialization.data(withJSONObject: ["global": globalDict, "repositories": [:]]) let storage = MutableTestStorage(initialData: data)
let settings: SettingsFile = withDependencies { $0.settingsFileStorage = storage.storage } operation: { @Shared(.settingsFile) var settings: SettingsFile return settings }
// The unknown sound falls back to the default... #expect(settings.global.notificationSound == .prowlClassic) // ...but the rest of the file survives. This is what `try?` buys over `try`. #expect(settings.global.appearanceMode == .dark) #expect(settings.global.systemNotificationsEnabled == true) #expect(settings.global.updatesAutomaticallyDownloadUpdates == true) }
// The language lives in the per-app `AppleLanguages` default, not in settings.json. @Test func settingsFileDoesNotCarryTheAppLanguage() throws { let encoded = try JSONEncoder().encode(GlobalSettings.default) let globalDict = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any])
#expect(globalDict["appLanguage"] == nil) }
@Test func fileFromABuildThatStoredTheLanguageStillDecodes() throws { let encoded = try JSONEncoder().encode(GlobalSettings.default) var globalDict = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any]) globalDict["appLanguage"] = "zh-Hans"
let data = try JSONSerialization.data(withJSONObject: globalDict) let decoded = try JSONDecoder().decode(GlobalSettings.self, from: data)
#expect(decoded == .default) }}
nonisolated private final class MutableTestStorage: @unchecked Sendable { private let lock = NSLock() private var data: Data? private let initialData: Data
init(initialData: Data) { self.initialData = initialData }
var storage: SettingsFileStorage { SettingsFileStorage( load: { try self.load($0) }, save: { try self.save($0, $1) } ) }
private func load(_ url: URL) throws -> Data { lock.lock() defer { lock.unlock() } if let data { return data } return initialData }
private func save(_ data: Data, _ url: URL) throws { lock.lock() defer { lock.unlock() } self.data = data }}
private struct LegacyAutoArchiveSettingsFile: Codable { var global: LegacyAutoArchiveGlobalSettings var repositories: [String: RepositorySettings]}
private struct LegacyAutoArchiveGlobalSettings: Codable { var appearanceMode: AppearanceMode var updatesAutomaticallyCheckForUpdates: Bool var updatesAutomaticallyDownloadUpdates: Bool var automaticallyArchiveMergedWorktrees: Bool}
private struct LegacySettingsFileWithSoundToggle: Codable { var global: LegacyGlobalSettingsWithSoundToggle var repositories: [String: RepositorySettings]}
private struct LegacyGlobalSettingsWithSoundToggle: Codable { var appearanceMode: AppearanceMode var updatesAutomaticallyCheckForUpdates: Bool var updatesAutomaticallyDownloadUpdates: Bool var notificationSoundEnabled: Bool}
private struct LegacySettingsFile: Codable { var global: LegacyGlobalSettings var repositories: [String: RepositorySettings]}
private struct LegacyGlobalSettings: Codable { var appearanceMode: AppearanceMode var updatesAutomaticallyCheckForUpdates: Bool var updatesAutomaticallyDownloadUpdates: Bool}