Skip to content

Commit bfefbc7

Browse files
authored
Stop store tests leaking mojito.tests.* plists into ~/Library/Preferences (#243)
* Stop store tests leaking mojito.tests.* plists into ~/Library/Preferences TriggerConfigStore, AliasStore and QuickAccessStore tests each opened a uniquely-named UserDefaults suite per test and never removed it, so every run added plists to ~/Library/Preferences (~5,800 had piled up locally). Deleting a named suite isn't reliable: cfprefsd rewrites an empty plist even after removePersistentDomain + removing the file. ScratchDefaults instead opens suites by absolute path in a private temp directory and tears them down on deinit; each suite holds one as a stored property so cleanup runs per test. Refs: W-1030 * Add ScratchDefaults.swift to the Xcode project Refs: W-1030
1 parent 293a5d3 commit bfefbc7

6 files changed

Lines changed: 71 additions & 9 deletions

File tree

‎CLAUDE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ never logs. Reusable for other Arc regressions via the same `e2e` log stream.
9999
- **SourceKit cross-file diagnostics lie.** Editing a Swift file in this repo regularly produces `Cannot find type 'PickerViewModel'` / `Cannot find 'PrefsKey'` diagnostics for symbols defined in sibling files. They resolve at build time. Trust `xcodebuild`, not SourceKit, when verifying correctness.
100100
- **Do not switch to ad-hoc signing (`-`).** The "Mojito Dev" self-signed identity is what keeps Accessibility / Input Monitoring TCC grants stable across rebuilds. Every ad-hoc rebuild gets a fresh cdhash and TCC treats it as a different app.
101101
- **Debug builds have their own bundle ID (`ee.wells.Mojito.dev`).** This is intentional — it keeps the dev build's TCC grants, login-item registration, and ⌘-Tab presence fully separate from any released `Mojito.app` on the same Mac. You'll need to grant Accessibility + Input Monitoring once to `Mojito Dev.app` (separately from `Mojito.app`); they'll persist across rebuilds because the "Mojito Dev" code-signing identity is stable. Sparkle is disabled in Debug so the dev build doesn't try to update itself to the release.
102+
- **Tests that need a `UserDefaults` suite must get it from `ScratchDefaults`** (`Tests/MojitoTests/ScratchDefaults.swift`), held as a stored property of the suite. Never `UserDefaults(suiteName: "mojito.tests.…")`: every run leaves a plist in `~/Library/Preferences` that cfprefsd keeps rewriting even after `removePersistentDomain` + deleting the file (W-1030 cleaned up ~5,800 of them).
102103
- **Don't bump the emoji DB by re-running `build_emoji_db.py` alone.** The script pins SHA256 digests of upstream emojibase files in `EXPECTED_SHA256`. Bumping requires manual review + paste of new digests (the script explains the procedure in its header).
103104
- **`SUPublicEDKey` in `project.yml` must be set before any release.** It's substituted into `Resources/Info.plist` at build time via the `info.properties` block, and `scripts/release.sh` aborts if it's empty. Generate via `./bin/generate_keys` (Sparkle stashes the private key in the login keychain, prints the public key; paste public into `project.yml`).
104105

‎Mojito.xcodeproj/project.pbxproj‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@
156156
D57C0BA3F93C45531A439D89 /* v07.bin in Resources */ = {isa = PBXBuildFile; fileRef = 7CCBA41F4C17CC47E155A9BE /* v07.bin */; };
157157
D69A33630F4FD6D6EB950C67 /* UpdaterCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 702AC2870CD686AB17CC518E /* UpdaterCoordinator.swift */; };
158158
D7FD72169491380D742F69BC /* ChooChooSound.swift in Sources */ = {isa = PBXBuildFile; fileRef = C060B27E6970531BEBA3673E /* ChooChooSound.swift */; };
159+
D8CD1BAA49BB0D0B96A7C2CC /* ScratchDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A8D795EF2026F5349717D49 /* ScratchDefaults.swift */; };
159160
DADDDB9ED8378048DF84B51A /* EggSound.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1732A13138B365E5C880F62 /* EggSound.swift */; };
160161
DCE52DEE8C9CEC354443E7DB /* SettingsRoot.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC142D2205F1C089F7524D2 /* SettingsRoot.swift */; };
161162
DD501615C4A6623E5BA03369 /* EmojiDatabaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B842CBBD6C7466F9BA2786 /* EmojiDatabaseTests.swift */; };
@@ -262,6 +263,7 @@
262263
561D5F53503A9DDE7BE03430 /* KeyMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyMonitor.swift; sourceTree = "<group>"; };
263264
5A80F3946EA93F8F8A25774E /* TriggerStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerStateMachine.swift; sourceTree = "<group>"; };
264265
5A8851906565F70B7815640F /* FuzzyMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuzzyMatcher.swift; sourceTree = "<group>"; };
266+
5A8D795EF2026F5349717D49 /* ScratchDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScratchDefaults.swift; sourceTree = "<group>"; };
265267
5B9E4C2183154C36F1883004 /* PickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickerViewModel.swift; sourceTree = "<group>"; };
266268
5C9E01A82260E8A38DF8D7EA /* DebugReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugReport.swift; sourceTree = "<group>"; };
267269
5FB7AB3DB0E63AF30880FE66 /* BrowserURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserURL.swift; sourceTree = "<group>"; };
@@ -426,6 +428,7 @@
426428
AEB65C36C1B1D1A3ECADC6FE /* KlipyAPITests.swift */,
427429
12F71260B63547A6DF1B01AD /* PickerViewModelTests.swift */,
428430
6401923F7190AFC65A7B6959 /* QueryStemmerTests.swift */,
431+
5A8D795EF2026F5349717D49 /* ScratchDefaults.swift */,
429432
9E0C646543028E968FB6287E /* SeasonalGatesTests.swift */,
430433
D74BDC060E2EAACF7A71A3A6 /* SkinToneTests.swift */,
431434
E25EC50F4D58E03E218947DF /* SymbolsDatabaseTests.swift */,
@@ -1102,6 +1105,7 @@
11021105
98422299C980042CFA642B78 /* KlipyAPITests.swift in Sources */,
11031106
2C2FD54F6E35CCD0374213BB /* PickerViewModelTests.swift in Sources */,
11041107
A11509496D8522DC13BF31F3 /* QueryStemmerTests.swift in Sources */,
1108+
D8CD1BAA49BB0D0B96A7C2CC /* ScratchDefaults.swift in Sources */,
11051109
C025CD4AE30320C3FE69028A /* SeasonalGatesTests.swift in Sources */,
11061110
917B8512B30F3B6E8C9D2B8C /* SkinToneTests.swift in Sources */,
11071111
91A5F2A800EA1FC0700394EA /* SymbolsDatabaseTests.swift in Sources */,

‎Tests/MojitoTests/AliasTests.swift‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import Foundation
55
/// Custom shortcut (alias) storage + validation.
66
@MainActor
77
struct AliasStoreTests {
8+
private let scratch = ScratchDefaults()
9+
810
private func makeStore() -> AliasStore {
9-
let suite = UserDefaults(suiteName: "mojito.tests.alias.\(UUID().uuidString)")!
10-
return AliasStore(defaults: suite)
11+
AliasStore(defaults: scratch.make())
1112
}
1213

1314
@Test func startsEmpty() {
@@ -61,8 +62,7 @@ struct AliasStoreTests {
6162
}
6263

6364
@Test func persistsAcrossInstances() {
64-
let suiteName = "mojito.tests.alias.persist.\(UUID().uuidString)"
65-
let suite = UserDefaults(suiteName: suiteName)!
65+
let suite = scratch.make()
6666
let first = AliasStore(defaults: suite)
6767
first.add(alias: "check", hexcode: "2705")
6868
let second = AliasStore(defaults: suite)

‎Tests/MojitoTests/FavoritesBrowseTests.swift‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -219,9 +219,10 @@ struct TriggerStateMachineBrowseTests {
219219

220220
@MainActor
221221
struct QuickAccessStoreTests {
222+
private let scratch = ScratchDefaults()
223+
222224
private func makeStore() -> QuickAccessStore {
223-
let suite = UserDefaults(suiteName: "mojito.tests.quickaccess.\(UUID().uuidString)")!
224-
return QuickAccessStore(defaults: suite)
225+
QuickAccessStore(defaults: scratch.make())
225226
}
226227

227228
@Test func startsAllAuto() {
@@ -254,8 +255,7 @@ struct QuickAccessStoreTests {
254255
}
255256

256257
@Test func persistsAcrossInstances() {
257-
let suiteName = "mojito.tests.quickaccess.persist.\(UUID().uuidString)"
258-
let suite = UserDefaults(suiteName: suiteName)!
258+
let suite = scratch.make()
259259
let first = QuickAccessStore(defaults: suite)
260260
first.pin("2764", at: 2)
261261
let second = QuickAccessStore(defaults: suite)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import Foundation
2+
import Testing
3+
4+
/// Hands out throwaway `UserDefaults` suites for store tests and deletes them
5+
/// on deinit.
6+
///
7+
/// Suites are opened by absolute path inside a private temp directory, so they
8+
/// never land in `~/Library/Preferences`. A named suite
9+
/// (`UserDefaults(suiteName: "mojito.tests.…")`) does, and cfprefsd rewrites
10+
/// an empty plist there even after `removePersistentDomain` + deleting the
11+
/// file — that's how thousands of `mojito.tests.*.plist` files piled up (W-1030).
12+
///
13+
/// Keep one as a stored property of the test suite. Swift Testing builds a
14+
/// fresh suite instance per test, so every domain it made is torn down when
15+
/// that test finishes.
16+
final class ScratchDefaults {
17+
let directory = FileManager.default.temporaryDirectory
18+
.appendingPathComponent("mojito-tests-\(UUID().uuidString)", isDirectory: true)
19+
private(set) var suiteNames: [String] = []
20+
private var suites: [UserDefaults] = []
21+
22+
func make() -> UserDefaults {
23+
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
24+
let name = directory.appendingPathComponent(UUID().uuidString).path
25+
let suite = UserDefaults(suiteName: name)!
26+
suiteNames.append(name)
27+
suites.append(suite)
28+
return suite
29+
}
30+
31+
deinit {
32+
// Clear cfprefsd's copy first so it has nothing to flush back to disk.
33+
for (name, suite) in zip(suiteNames, suites) {
34+
suite.removePersistentDomain(forName: name)
35+
}
36+
try? FileManager.default.removeItem(at: directory)
37+
}
38+
}
39+
40+
struct ScratchDefaultsTests {
41+
@Test func suitesLiveInTempAndAreRemovedOnDeinit() throws {
42+
var scratch: ScratchDefaults? = ScratchDefaults()
43+
let suite = try #require(scratch).make()
44+
suite.set("x", forKey: "k")
45+
let name = try #require(scratch?.suiteNames.first)
46+
let directory = try #require(scratch).directory
47+
#expect(name.hasPrefix(FileManager.default.temporaryDirectory.path))
48+
#expect(FileManager.default.fileExists(atPath: directory.path))
49+
50+
scratch = nil
51+
52+
#expect(!FileManager.default.fileExists(atPath: directory.path))
53+
#expect(suite.string(forKey: "k") == nil)
54+
}
55+
}

‎Tests/MojitoTests/TriggerConfigStoreTests.swift‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import Testing
55
/// Round-trip persistence and one-time legacy migration of `TriggerConfig`.
66
struct TriggerConfigStoreTests {
77

8+
private let scratch = ScratchDefaults()
9+
810
private func freshSuite() -> UserDefaults {
9-
UserDefaults(suiteName: "mojito.tests.triggers.\(UUID().uuidString)")!
11+
scratch.make()
1012
}
1113

1214
// MARK: Codable round-trip

0 commit comments

Comments
 (0)