test(mac): fixed names for scratch settings suites (#1576)

* test(mac): clean up scratch UserDefaults suites, guard against new leaks

* test(mac): fixed suite names so late cfprefsd write-backs reuse one file per test

* test(mac): start every scratch suite clean
This commit is contained in:
Resham Joshi 2026-10-02 16:18:38 +02:00 • committed by GitHub
parent c17e7b05aa
commit a7ca8bdb94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 156 additions and 79 deletions

View file

@ -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()

View file

@ -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")

View file

@ -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)

View file

@ -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")

View file

@ -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)

View file

@ -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)

View file

@ -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()

View file

@ -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))

View file

@ -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()

View file

@ -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))

View file

@ -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")

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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()

View file

@ -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()

View file

@ -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")

View file

@ -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)

View file

@ -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.

View file

@ -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()

View file

@ -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<String> = ["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" }
}
}