diff --git a/mac/Tests/CodeBurnMenubarTests/AppStoreCopilotDisconnectTests.swift b/mac/Tests/CodeBurnMenubarTests/AppStoreCopilotDisconnectTests.swift index 8a99d045..b6b0ce6f 100644 --- a/mac/Tests/CodeBurnMenubarTests/AppStoreCopilotDisconnectTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/AppStoreCopilotDisconnectTests.swift @@ -145,11 +145,10 @@ struct AppStoreCopilotDisconnectTests { @MainActor private func withIsolatedCopilotStore( hasCredential: Bool, + testName: String = #function, _ body: @MainActor (AppStore, CopilotFetchStub, UserDefaults) async throws -> Void ) async throws { - let suiteName = "codeburn.copilot.disconnect.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - TestDefaults.forget(suiteName) + let (defaults, suiteName) = TestDefaults.make("codeburn.copilot.disconnect.\(testName)") defer { TestDefaults.forget(suiteName) } let fetch = CopilotFetchStub() diff --git a/mac/Tests/CodeBurnMenubarTests/CapacityDockGlanceWindowTests.swift b/mac/Tests/CodeBurnMenubarTests/CapacityDockGlanceWindowTests.swift index 0017b0a6..056908ac 100644 --- a/mac/Tests/CodeBurnMenubarTests/CapacityDockGlanceWindowTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/CapacityDockGlanceWindowTests.swift @@ -171,9 +171,8 @@ struct CapacityDockGlanceWindowTests { // MARK: - Persistence - private func defaults() -> (UserDefaults, String) { - let suiteName = "CodeBurnMenubarTests.CapacityDockGlance.\(UUID().uuidString)" - return (UserDefaults(suiteName: suiteName)!, suiteName) + private func defaults(_ testName: String = #function) -> (UserDefaults, String) { + TestDefaults.make("CodeBurnMenubarTests.CapacityDockGlance.\(testName)") } @Test("the glance window defaults to the billing horizon for every provider") diff --git a/mac/Tests/CodeBurnMenubarTests/CapacityDockInteractionStateTests.swift b/mac/Tests/CodeBurnMenubarTests/CapacityDockInteractionStateTests.swift index aede6289..1eb07bc1 100644 --- a/mac/Tests/CodeBurnMenubarTests/CapacityDockInteractionStateTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/CapacityDockInteractionStateTests.swift @@ -104,8 +104,7 @@ struct CapacityDockDragSettlingTests { @MainActor @Test("settling when no drag is in flight changes nothing") func settlingIdleDockIsANoOp() { - let name = "CodeBurnMenubarTests.DragSettle.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: name)! + let (defaults, name) = TestDefaults.make("CodeBurnMenubarTests.DragSettle.\(#function)") defer { TestDefaults.forget(name) } let controller = CapacityDockController(store: AppStore(), defaults: defaults) diff --git a/mac/Tests/CodeBurnMenubarTests/CapacityDockPreferencesTests.swift b/mac/Tests/CodeBurnMenubarTests/CapacityDockPreferencesTests.swift index 64636314..42f828d3 100644 --- a/mac/Tests/CodeBurnMenubarTests/CapacityDockPreferencesTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/CapacityDockPreferencesTests.swift @@ -4,9 +4,8 @@ import Testing @Suite("Capacity Dock preferences") struct CapacityDockPreferencesTests { - private func defaults() -> (UserDefaults, String) { - let suiteName = "CodeBurnMenubarTests.CapacityDock.\(UUID().uuidString)" - return (UserDefaults(suiteName: suiteName)!, suiteName) + private func defaults(_ testName: String = #function) -> (UserDefaults, String) { + TestDefaults.make("CodeBurnMenubarTests.CapacityDock.\(testName)") } @Test("fresh installs keep the dock off with Codex as the only resting provider") diff --git a/mac/Tests/CodeBurnMenubarTests/CapacityDockPresentationTests.swift b/mac/Tests/CodeBurnMenubarTests/CapacityDockPresentationTests.swift index 4cc53de3..53234d76 100644 --- a/mac/Tests/CodeBurnMenubarTests/CapacityDockPresentationTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/CapacityDockPresentationTests.swift @@ -25,8 +25,7 @@ struct CapacityDockPresentationTests { @MainActor @Test("rail body length follows presentation progress instead of snapping to interaction state") func presentationLengthInterpolates() { - let suite = "CodeBurnMenubarTests.CapacityDock.Presentation.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! + let (defaults, suite) = TestDefaults.make("CodeBurnMenubarTests.CapacityDock.Presentation") defer { TestDefaults.forget(suite) } CapacityDockPreferences.setSelectedProviders([.codex, .claude, .gemini], defaults: defaults) let model = CapacityDockViewModel(preferences: CapacityDockPreferences.load(defaults: defaults)) @@ -43,8 +42,7 @@ struct CapacityDockPresentationTests { @MainActor @Test("Resting provider stays at the reveal anchor") func restingProviderFollowsExpansionAnchor() { - let suite = "CodeBurnMenubarTests.CapacityDock.AnchorOrder.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! + let (defaults, suite) = TestDefaults.make("CodeBurnMenubarTests.CapacityDock.AnchorOrder") defer { TestDefaults.forget(suite) } CapacityDockPreferences.setSelectedProviders([.codex, .claude, .gemini], defaults: defaults) let model = CapacityDockViewModel(preferences: CapacityDockPreferences.load(defaults: defaults)) @@ -59,8 +57,7 @@ struct CapacityDockPresentationTests { @MainActor @Test("Attachment morphs inside the body without changing the panel size") func attachmentKeepsPanelSizeStable() { - let suite = "CodeBurnMenubarTests.CapacityDock.EdgeSpread.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! + let (defaults, suite) = TestDefaults.make("CodeBurnMenubarTests.CapacityDock.EdgeSpread") defer { TestDefaults.forget(suite) } CapacityDockPreferences.setScale(1.2, defaults: defaults) let model = CapacityDockViewModel(preferences: CapacityDockPreferences.load(defaults: defaults)) @@ -79,8 +76,7 @@ struct CapacityDockPresentationTests { @MainActor @Test("Supported settled scales and edges compose integral panel dimensions") func supportedSettledGeometryIsIntegral() { - let suite = "CodeBurnMenubarTests.CapacityDock.IntegralGeometry.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! + let (defaults, suite) = TestDefaults.make("CodeBurnMenubarTests.CapacityDock.IntegralGeometry") defer { TestDefaults.forget(suite) } CapacityDockPreferences.setSelectedProviders([.codex, .claude, .gemini], defaults: defaults) @@ -228,8 +224,7 @@ struct CapacityDockPresentationTests { @MainActor @Test("Selected provider identity remains stable throughout reveal and retraction") func selectedProviderIdentityStaysStable() { - let suite = "CodeBurnMenubarTests.CapacityDock.Identity.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! + let (defaults, suite) = TestDefaults.make("CodeBurnMenubarTests.CapacityDock.Identity") defer { TestDefaults.forget(suite) } CapacityDockPreferences.setSelectedProviders([.codex, .claude, .gemini], defaults: defaults) CapacityDockPreferences.setPreferredProvider(.codex, defaults: defaults) @@ -252,8 +247,7 @@ struct CapacityDockPresentationTests { @MainActor @Test("Resting provider stays visible when the rail expands toward its start edge") func restingProviderNeverFlashesToAnotherIcon() { - let suite = "CodeBurnMenubarTests.CapacityDock.NoIconFlash.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! + let (defaults, suite) = TestDefaults.make("CodeBurnMenubarTests.CapacityDock.NoIconFlash") defer { TestDefaults.forget(suite) } CapacityDockPreferences.setSelectedProviders([.codex, .claude, .gemini], defaults: defaults) CapacityDockPreferences.setPreferredProvider(.codex, defaults: defaults) diff --git a/mac/Tests/CodeBurnMenubarTests/CapacityDockProviderQuotaServiceTests.swift b/mac/Tests/CodeBurnMenubarTests/CapacityDockProviderQuotaServiceTests.swift index 7c5e013a..c954e7fb 100644 --- a/mac/Tests/CodeBurnMenubarTests/CapacityDockProviderQuotaServiceTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/CapacityDockProviderQuotaServiceTests.swift @@ -436,8 +436,7 @@ struct CapacityDockProviderQuotaServiceTests { @Test("removeProvider drops only the target and keeps manual selection unlatched") func removeProviderPersistence() throws { - let suiteName = "CodeBurnMenubarTests.CapacityDockRemove.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) + let (defaults, suiteName) = TestDefaults.make("CodeBurnMenubarTests.CapacityDockRemove") defer { TestDefaults.forget(suiteName) } let cursor = try #require(CapacityDockProvider(rawValue: "cursor")) defaults.set(["codex", "cursor"], forKey: CapacityDockPreferences.selectedProvidersKey) diff --git a/mac/Tests/CodeBurnMenubarTests/CodexBankedResetTests.swift b/mac/Tests/CodeBurnMenubarTests/CodexBankedResetTests.swift index f14341f8..53df7490 100644 --- a/mac/Tests/CodeBurnMenubarTests/CodexBankedResetTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/CodexBankedResetTests.swift @@ -351,6 +351,7 @@ struct CodexBankedResetAnnouncerTests { @MainActor private func withAnnouncer( + testName: String = #function, _ body: @MainActor ( CodexBankedResetAnnouncer, RecordingBankedResetNotifier, @@ -358,9 +359,7 @@ private func withAnnouncer( MemoryBankedResetStore ) async throws -> Void ) async throws { - let suiteName = "codeburn.codex.bankedResets.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - TestDefaults.forget(suiteName) + let (defaults, suiteName) = TestDefaults.make("codeburn.codex.bankedResets.\(testName)") defer { TestDefaults.forget(suiteName) } let notifier = RecordingBankedResetNotifier() diff --git a/mac/Tests/CodeBurnMenubarTests/CopilotQuotaPresentationTests.swift b/mac/Tests/CodeBurnMenubarTests/CopilotQuotaPresentationTests.swift index ca8dee48..a25dcc53 100644 --- a/mac/Tests/CodeBurnMenubarTests/CopilotQuotaPresentationTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/CopilotQuotaPresentationTests.swift @@ -50,9 +50,7 @@ struct CopilotQuotaPresentationTests { @Test("explicit disconnect copy differs from first-use; clearing the flag restores first-use") func explicitDisconnectDiffersFromFirstUseAndClears() throws { - let suiteName = "codeburn.copilot.presentation.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - TestDefaults.forget(suiteName) + let (defaults, suiteName) = TestDefaults.make("codeburn.copilot.presentation.\(#function)") defer { TestDefaults.forget(suiteName) } #expect(!CopilotExplicitDisconnect.isSet(defaults: defaults)) diff --git a/mac/Tests/CodeBurnMenubarTests/CredentialKeychainCacheRedTests.swift b/mac/Tests/CodeBurnMenubarTests/CredentialKeychainCacheRedTests.swift index dc8c9a51..1de226fc 100644 --- a/mac/Tests/CodeBurnMenubarTests/CredentialKeychainCacheRedTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/CredentialKeychainCacheRedTests.swift @@ -12,6 +12,7 @@ struct CredentialKeychainCacheRedTests { private let accountSentinel = "cb-red-account-sentinel" private func withIsolatedSeams( + testName: String = #function, _ body: (URL, InMemoryKeychainCredentialCache) throws -> Void ) throws { CredentialStoreTestIsolation.lock.lock() @@ -21,9 +22,7 @@ struct CredentialKeychainCacheRedTests { .appendingPathComponent("codeburn-0b-red-\(UUID().uuidString)", isDirectory: true) let support = root.appendingPathComponent("Application Support", isDirectory: true) try FileManager.default.createDirectory(at: support, withIntermediateDirectories: true) - let suiteName = "codeburn.0b.red.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - TestDefaults.forget(suiteName) + let (defaults, suiteName) = TestDefaults.make("codeburn.0b.red.\(testName)") let fakeKeychain = InMemoryKeychainCredentialCache() ClaudeCredentialStore.resetTestSeams() diff --git a/mac/Tests/CodeBurnMenubarTests/CredentialKeychainContinuityTests.swift b/mac/Tests/CodeBurnMenubarTests/CredentialKeychainContinuityTests.swift index 278f8af3..33574df8 100644 --- a/mac/Tests/CodeBurnMenubarTests/CredentialKeychainContinuityTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/CredentialKeychainContinuityTests.swift @@ -29,6 +29,7 @@ struct CredentialKeychainContinuityTests { } private func withHarness( + testName: String = #function, _ body: (Harness) throws -> Void ) throws { CredentialStoreTestIsolation.lock.lock() @@ -38,9 +39,7 @@ struct CredentialKeychainContinuityTests { .appendingPathComponent("codeburn-0b-cont-\(UUID().uuidString)", isDirectory: true) let support = root.appendingPathComponent("Application Support", isDirectory: true) try FileManager.default.createDirectory(at: support, withIntermediateDirectories: true) - let suiteName = "codeburn.0b.cont.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - TestDefaults.forget(suiteName) + let (defaults, suiteName) = TestDefaults.make("codeburn.0b.cont.\(testName)") let fake = InMemoryKeychainCredentialCache() ClaudeCredentialStore.resetTestSeams() @@ -166,10 +165,8 @@ struct CredentialKeychainContinuityTests { @Test("concurrent provider-presence updates do not lose identifiers") func providerPresenceUpdatesAreAtomic() async { - let suiteName = "codeburn.capacity-dock-presence.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! + let (defaults, suiteName) = TestDefaults.make("codeburn.capacity-dock-presence.\(#function)") let defaultsBox = SendableUserDefaults(defaults) - TestDefaults.forget(suiteName) defer { TestDefaults.forget(suiteName) } let providerIDs = Array(CapacityDockPreferences.supportedProviders.prefix(24).map(\.id)) diff --git a/mac/Tests/CodeBurnMenubarTests/DefaultsKeyObserverTests.swift b/mac/Tests/CodeBurnMenubarTests/DefaultsKeyObserverTests.swift index 1d754249..2d17c6d1 100644 --- a/mac/Tests/CodeBurnMenubarTests/DefaultsKeyObserverTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/DefaultsKeyObserverTests.swift @@ -7,9 +7,8 @@ import Testing /// external write (verified out of process), KVO does, so the observer is what is tested. @Suite("Defaults key observer") struct DefaultsKeyObserverTests { - private func suite() -> (UserDefaults, String) { - let name = "CodeBurnMenubarTests.DefaultsKeyObserver.\(UUID().uuidString)" - return (UserDefaults(suiteName: name)!, name) + private func suite(_ testName: String = #function) -> (UserDefaults, String) { + TestDefaults.make("CodeBurnMenubarTests.DefaultsKeyObserver.\(testName)") } @Test("a change to the watched key calls back") diff --git a/mac/Tests/CodeBurnMenubarTests/EarlyQuotaResetTests.swift b/mac/Tests/CodeBurnMenubarTests/EarlyQuotaResetTests.swift index 6a03dab9..063d8213 100644 --- a/mac/Tests/CodeBurnMenubarTests/EarlyQuotaResetTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/EarlyQuotaResetTests.swift @@ -608,11 +608,10 @@ private func seedBaseline(_ monitor: EarlyQuotaResetMonitor) async { @MainActor private func withIsolatedMonitor( + testName: String = #function, _ body: @MainActor (EarlyQuotaResetMonitor, RecordingEarlyResetNotifier, UserDefaults) async throws -> Void ) async throws { - let suiteName = "codeburn.quota.earlyReset.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - TestDefaults.forget(suiteName) + let (defaults, suiteName) = TestDefaults.make("codeburn.quota.earlyReset.monitor.\(testName)") defer { TestDefaults.forget(suiteName) } let notifier = RecordingEarlyResetNotifier() @@ -952,11 +951,10 @@ struct EarlyQuotaResetCodexWiringTests { } private func withCodexStore( + testName: String = #function, _ body: @MainActor (AppStore, RecordingEarlyResetNotifier) async throws -> Void ) async throws { - let suiteName = "codeburn.quota.earlyReset.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - TestDefaults.forget(suiteName) + let (defaults, suiteName) = TestDefaults.make("codeburn.quota.earlyReset.codexWiring.\(testName)") defer { TestDefaults.forget(suiteName) } let notifier = RecordingEarlyResetNotifier() diff --git a/mac/Tests/CodeBurnMenubarTests/LanguagePreferenceTests.swift b/mac/Tests/CodeBurnMenubarTests/LanguagePreferenceTests.swift index 57346d9b..0e7bf781 100644 --- a/mac/Tests/CodeBurnMenubarTests/LanguagePreferenceTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/LanguagePreferenceTests.swift @@ -62,7 +62,7 @@ struct LanguagePreferenceTests { func applyWritesAppleLanguages() { let domain = domain("apply") defer { TestDefaults.forget(domain) } - let scratch = UserDefaults(suiteName: domain)! + let scratch = TestDefaults.open(domain) LanguagePreference.apply(.chineseSimplified, defaults: scratch) #expect(read(domain) == .chineseSimplified) LanguagePreference.apply(.system, defaults: scratch) diff --git a/mac/Tests/CodeBurnMenubarTests/MenubarPeriodSettingsTests.swift b/mac/Tests/CodeBurnMenubarTests/MenubarPeriodSettingsTests.swift index 451240a5..ab210306 100644 --- a/mac/Tests/CodeBurnMenubarTests/MenubarPeriodSettingsTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/MenubarPeriodSettingsTests.swift @@ -42,8 +42,7 @@ struct MenubarPeriodSettingsTests { @Test("periods persist canonical defaults values") func periodsPersistCanonicalDefaultsValues() { - let suiteName = "CodeBurnMenubarTests.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! + let (defaults, suiteName) = TestDefaults.make("CodeBurnMenubarTests.\(#function)") defer { TestDefaults.forget(suiteName) } Period.sevenDays.persistAsMenubarDefault(defaults: defaults) @@ -61,8 +60,7 @@ struct MenubarPeriodSettingsTests { @Test("menubar scope persistence defaults to local and round-trips") func menubarScopePersistenceDefaultsToLocalAndRoundTrips() { - let suiteName = "CodeBurnMenubarTests.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! + let (defaults, suiteName) = TestDefaults.make("CodeBurnMenubarTests.\(#function)") defer { TestDefaults.forget(suiteName) } #expect(MenubarScope.savedMenubarScope(defaults: defaults) == .local) diff --git a/mac/Tests/CodeBurnMenubarTests/MenubarSecondRowTests.swift b/mac/Tests/CodeBurnMenubarTests/MenubarSecondRowTests.swift index 003db8c6..75e8d800 100644 --- a/mac/Tests/CodeBurnMenubarTests/MenubarSecondRowTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/MenubarSecondRowTests.swift @@ -467,8 +467,7 @@ struct MenubarSecondRowTests { @Test("settings default to off and round-trip through UserDefaults") func preferencesRoundTrip() { - let suiteName = "CodeBurnMenubarTests.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! + let (defaults, suiteName) = TestDefaults.make("CodeBurnMenubarTests.\(#function)") defer { TestDefaults.forget(suiteName) } #expect(MenubarRowPreferences.load(defaults: defaults) == MenubarRowSettings.default) diff --git a/mac/Tests/CodeBurnMenubarTests/ProviderSettingsEditorStateTests.swift b/mac/Tests/CodeBurnMenubarTests/ProviderSettingsEditorStateTests.swift index f56c3123..ccb5e67d 100644 --- a/mac/Tests/CodeBurnMenubarTests/ProviderSettingsEditorStateTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/ProviderSettingsEditorStateTests.swift @@ -115,8 +115,7 @@ struct ProviderSettingsEditorStateTests { @Test("presence writes allow a synchronous UserDefaults callback to read presence") func presenceWritesDoNotDeadlockSwiftUICallbacks() async throws { - let suiteName = "CodeBurnMenubarTests.PresenceReentry.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) + let (defaults, suiteName) = TestDefaults.make("CodeBurnMenubarTests.PresenceReentry") let defaultsBox = SendableUserDefaults(defaults) let callbackFinished = LockedCompletionFlag() let writeFinished = LockedCompletionFlag() diff --git a/mac/Tests/CodeBurnMenubarTests/QuotaCrossingTests.swift b/mac/Tests/CodeBurnMenubarTests/QuotaCrossingTests.swift index 2ef4d8f6..4f960a4a 100644 --- a/mac/Tests/CodeBurnMenubarTests/QuotaCrossingTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/QuotaCrossingTests.swift @@ -150,11 +150,10 @@ struct QuotaCrossingMonitorTests { @MainActor private func withMonitor( + testName: String = #function, _ body: @MainActor (QuotaCrossingMonitor, RecordingCrossingNotifier, UserDefaults) async throws -> Void ) async throws { - let suiteName = "codeburn.quota.crossing.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - TestDefaults.forget(suiteName) + let (defaults, suiteName) = TestDefaults.make("codeburn.quota.crossing.\(testName)") defer { TestDefaults.forget(suiteName) } let notifier = RecordingCrossingNotifier() diff --git a/mac/Tests/CodeBurnMenubarTests/TelemetryTests.swift b/mac/Tests/CodeBurnMenubarTests/TelemetryTests.swift index deb873e9..9ef78bf5 100644 --- a/mac/Tests/CodeBurnMenubarTests/TelemetryTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/TelemetryTests.swift @@ -64,13 +64,16 @@ struct TelemetryTests { /// A defaults suite and a directory that exist only for one test. final class Scratch { - let suiteName = "CodeBurnMenubarTests.Telemetry.\(UUID().uuidString)" + let suiteName: String let directory = FileManager.default.temporaryDirectory .appendingPathComponent("codeburn-telemetry-tests-\(UUID().uuidString)") let defaults: UserDefaults - init() { - defaults = UserDefaults(suiteName: suiteName)! + /// `label` distinguishes several `Scratch`es created by one test + /// (a loop, or a few named cases); it must be unique per call site. + init(_ label: String = "", testName: String = #function) { + let name = label.isEmpty ? testName : "\(testName).\(label)" + (defaults, suiteName) = TestDefaults.make("CodeBurnMenubarTests.Telemetry.\(name)") try? FileManager.default.createDirectory( at: directory, withIntermediateDirectories: true) } @@ -206,17 +209,17 @@ struct TelemetryTests { @Test("standalone defaults on outside the default-off region, off inside it and off when unknown") func standaloneRegionDefaults() { - let us = Scratch() + let us = Scratch("us") #expect(Self.client(us, region: "US").status().enabled) - let de = Scratch() + let de = Scratch("de") #expect(Self.client(de, region: "DE").status().enabled == false) - let unknown = Scratch() + let unknown = Scratch("unknown") #expect(Self.client(unknown, region: nil).status().enabled == false) // A UN M.49 region is nobody's country code, so it reads as unknown. - let m49 = Scratch() + let m49 = Scratch("m49") #expect(Self.client(m49, region: "419").status().enabled == false) } @@ -296,7 +299,7 @@ struct TelemetryTests { func toggleStateForEveryCombination() { for desktopEnabled in [true, false] { for vetoed in [true, false] { - let scratch = Scratch() + let scratch = Scratch("desktop\(desktopEnabled)-veto\(vetoed)") scratch.defaults.set(vetoed, forKey: Telemetry.localOptOutKey) let state = scratch.writeDesktopState( """ @@ -312,7 +315,7 @@ struct TelemetryTests { } } - let scratch = Scratch() + let scratch = Scratch("standalone") let standalone = Self.client(scratch, region: "US") #expect(standalone.status().source == .app) #expect(standalone.status().isLocked == false, "standalone the toggle is never a readout") @@ -376,12 +379,12 @@ struct TelemetryTests { func standaloneExplicitDecisionSwitch() { // Both settings of Telemetry.standaloneRequiresExplicitDecision, so the // product call is a one-line change with tests already standing. - let regionDecides = Scratch() + let regionDecides = Scratch("regionDecides") let deciding = Self.client(regionDecides, region: "US", requiresExplicitDecision: false) deciding.track("app_open") #expect(deciding.queuedEvents.count == 1, "the region default is itself the answer") - let mustBeAsked = Scratch() + let mustBeAsked = Scratch("mustBeAsked") let waiting = Self.client(mustBeAsked, region: "US", requiresExplicitDecision: true) waiting.track("app_open") #expect(waiting.queuedEvents.isEmpty, "nothing before the question has been answered") diff --git a/mac/Tests/CodeBurnMenubarTests/TerminalLauncherTests.swift b/mac/Tests/CodeBurnMenubarTests/TerminalLauncherTests.swift index 5b4c5946..cf458cca 100644 --- a/mac/Tests/CodeBurnMenubarTests/TerminalLauncherTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/TerminalLauncherTests.swift @@ -265,8 +265,7 @@ struct TerminalLauncherTests { @Test("preference defaults to Terminal.app when unset, preserving pre-#877 behaviour") func defaultsToTerminalWhenUnset() { - let suiteName = "CodeBurnMenubarTests.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! + let (defaults, suiteName) = TestDefaults.make("CodeBurnMenubarTests.\(#function)") defer { TestDefaults.forget(suiteName) } #expect(PreferredTerminal.saved(defaults: defaults) == .terminal) @@ -275,8 +274,7 @@ struct TerminalLauncherTests { @Test("preference round-trips and unknown values collapse to the default") func preferenceRoundTripsAndRejectsGarbage() { - let suiteName = "CodeBurnMenubarTests.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! + let (defaults, suiteName) = TestDefaults.make("CodeBurnMenubarTests.\(#function)") defer { TestDefaults.forget(suiteName) } PreferredTerminal.iTerm2.persist(defaults: defaults) diff --git a/mac/Tests/CodeBurnMenubarTests/TestDefaults.swift b/mac/Tests/CodeBurnMenubarTests/TestDefaults.swift index 244690f6..78723c22 100644 --- a/mac/Tests/CodeBurnMenubarTests/TestDefaults.swift +++ b/mac/Tests/CodeBurnMenubarTests/TestDefaults.swift @@ -11,6 +11,26 @@ import Foundation /// right after creating it -- so a full `swift test` run does not grow the /// file count under Preferences. enum TestDefaults { + /// A `UserDefaults` suite named `name`, paired with that name for + /// `forget(_:)`. `name` must be fixed, not a uuid: cfprefsd can still + /// write a cleared domain's plist back to disk after `forget(_:)` + /// deletes it, and a fixed name lands that write on the same file every + /// run instead of a new one. A shared helper, a loop, or a parameterized + /// test must fold something distinguishing -- the caller's `#function`, + /// an index, an argument -- into `name` so no two tests share one. + static func make(_ name: String) -> (UserDefaults, String) { + (open(name), name) + } + + /// Opens a suite/domain name as `UserDefaults`, for call sites that + /// already have their own name instead of calling `make(_:)`. Forgets it + /// first, so a fixed name starts clean even after a run that got killed + /// before its own `forget(_:)` ran. + static func open(_ suiteName: String) -> UserDefaults { + forget(suiteName) + return UserDefaults(suiteName: suiteName)! + } + static func forget(_ suiteName: String) { // A `UserDefaults` instance scoped to the suite -- not `.standard` -- // so `synchronize()` below flushes *this* domain's own dirty state. diff --git a/mac/Tests/CodeBurnMenubarTests/UpdateNotificationTests.swift b/mac/Tests/CodeBurnMenubarTests/UpdateNotificationTests.swift index 476285ab..14b06a92 100644 --- a/mac/Tests/CodeBurnMenubarTests/UpdateNotificationTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/UpdateNotificationTests.swift @@ -98,11 +98,10 @@ struct UpdateNotificationTests { @MainActor private func withIsolatedChecker( + testName: String = #function, _ body: @MainActor (UpdateChecker, RecordingUpdateNotifier, UserDefaults) async throws -> Void ) async throws { - let suiteName = "codeburn.update.notifications.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - TestDefaults.forget(suiteName) + let (defaults, suiteName) = TestDefaults.make("codeburn.update.notifications.\(testName)") defer { TestDefaults.forget(suiteName) } let notifier = RecordingUpdateNotifier() diff --git a/mac/Tests/CodeBurnMenubarTests/UserDefaultsSuiteLeakGuardTests.swift b/mac/Tests/CodeBurnMenubarTests/UserDefaultsSuiteLeakGuardTests.swift new file mode 100644 index 00000000..f5161c93 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/UserDefaultsSuiteLeakGuardTests.swift @@ -0,0 +1,83 @@ +import Foundation +import Testing + +/// Guards the fix for the test-only `UserDefaults(suiteName:` leak: every +/// scratch suite must go through `TestDefaults.make`/`open`, whose only +/// direct call sites live in `TestDefaults.swift`, so its plist actually gets +/// cleaned up via `TestDefaults.forget`. +@Suite("UserDefaults suite leak guard") +struct UserDefaultsSuiteLeakGuardTests { + private static var testsDirectory: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // CodeBurnMenubarTests + .deletingLastPathComponent() // Tests + } + + @Test("the guard can actually see the files it is meant to check") + func testsAreReachable() throws { + let files = try swiftFiles(in: Self.testsDirectory) + #expect( + files.count > 10, + "found \(files.count) Swift files under \(Self.testsDirectory.path); if the tree moved, this guard checks nothing" + ) + } + + @Test("no test file creates a UserDefaults suite outside TestDefaults.swift") + func noDirectSuiteCreation() throws { + let needle = "UserDefaults(" + "suiteName" + let exempt: Set = ["TestDefaults.swift", "UserDefaultsSuiteLeakGuardTests.swift"] + var offenders: [String] = [] + for file in try swiftFiles(in: Self.testsDirectory) where !exempt.contains(file.lastPathComponent) { + let contents = try String(contentsOf: file, encoding: .utf8) + for (index, line) in contents.components(separatedBy: "\n").enumerated() where line.contains(needle) { + offenders.append("\(file.lastPathComponent):\(index + 1)") + } + } + #expect( + offenders.isEmpty, + """ + \(offenders.count) test call site(s) create a UserDefaults suite directly, \ + bypassing TestDefaults.forget and leaking its plist: \(offenders). \ + Use TestDefaults.make(_:) or TestDefaults.open(_:) instead. + """ + ) + } + + @Test("no two TestDefaults.make/open call sites use the same static suite name") + func noDuplicateSuiteNames() throws { + let pattern = try NSRegularExpression(pattern: #"TestDefaults\.(?:make|open)\(\s*"([^"]*)""#) + var locations: [String: [String]] = [:] + for file in try swiftFiles(in: Self.testsDirectory) where file.lastPathComponent != "TestDefaults.swift" { + let contents = try String(contentsOf: file, encoding: .utf8) + for (index, line) in contents.components(separatedBy: "\n").enumerated() { + let range = NSRange(line.startIndex..., in: line) + guard let match = pattern.firstMatch(in: line, range: range), + let nameRange = Range(match.range(at: 1), in: line) else { continue } + let name = String(line[nameRange]) + // A name built from the caller's own `#function` is unique per call + // site by construction (a different function, a different name); + // only a name with no such per-caller piece can silently collide. + guard !name.contains("#function") else { continue } + locations[name, default: []].append("\(file.lastPathComponent):\(index + 1)") + } + } + let duplicates = locations.filter { $0.value.count > 1 } + #expect( + duplicates.isEmpty, + """ + \(duplicates.count) suite name(s) are reused across call sites, so two tests \ + can write and forget the same plist concurrently: \ + \(duplicates.map { "\"\($0.key)\" at \($0.value)" }.joined(separator: "; ")). \ + Fold something distinguishing into the name -- the caller's #function, a loop \ + index, a parameterized test's argument. + """ + ) + } + + private func swiftFiles(in directory: URL) throws -> [URL] { + guard let enumerator = FileManager.default.enumerator( + at: directory, includingPropertiesForKeys: nil + ) else { return [] } + return enumerator.compactMap { $0 as? URL }.filter { $0.pathExtension == "swift" } + } +}