Skip to content

Commit 6eb9a5a

Browse files
authored
test(macos): isolate config change notifications (#103481)
1 parent 7d87ced commit 6eb9a5a

2 files changed

Lines changed: 71 additions & 22 deletions

File tree

apps/macos/Sources/OpenClaw/ConfigStore.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ enum ConfigStore {
99
var loadRemote: (@MainActor @Sendable () async -> [String: Any])?
1010
var saveRemote: (@MainActor @Sendable ([String: Any]) async throws -> Void)?
1111
var saveGateway: (@MainActor @Sendable ([String: Any]) async throws -> Void)?
12+
#if DEBUG
13+
/// Isolates focused notification assertions without changing the production sender contract.
14+
var notificationCenter: NotificationCenter?
15+
#endif
1216
}
1317

1418
private actor OverrideStore {
@@ -83,7 +87,12 @@ enum ConfigStore {
8387
}
8488
}
8589
}
86-
NotificationCenter.default.post(name: .openclawConfigDidChange, object: nil)
90+
#if DEBUG
91+
let notificationCenter = overrides.notificationCenter ?? .default
92+
#else
93+
let notificationCenter = NotificationCenter.default
94+
#endif
95+
notificationCenter.post(name: .openclawConfigDidChange, object: nil)
8796
}
8897

8998
@MainActor

apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -40,24 +40,34 @@ struct ConfigStoreTests {
4040
@Test func `save routes to remote in remote mode`() async throws {
4141
var localHit = false
4242
var remoteHit = false
43+
let notificationCenter = NotificationCenter()
4344
let changeCount = NotificationCount()
44-
let observer = NotificationCenter.default.addObserver(
45+
let observer = notificationCenter.addObserver(
4546
forName: .openclawConfigDidChange,
4647
object: nil,
4748
queue: nil)
48-
{ _ in changeCount.increment() }
49-
defer { NotificationCenter.default.removeObserver(observer) }
50-
await ConfigStore._testSetOverrides(.init(
49+
{ note in changeCount.record(note) }
50+
defer { notificationCenter.removeObserver(observer) }
51+
52+
try await self.withOverrides(.init(
5153
isRemoteMode: { true },
5254
saveLocal: { _ in localHit = true },
53-
saveRemote: { _ in remoteHit = true }))
54-
55-
try await ConfigStore.save(["remote": true])
55+
saveRemote: { _ in
56+
remoteHit = true
57+
// Reproduce a concurrent AppState-style publisher overlapping this save.
58+
await Task.detached {
59+
NotificationCenter.default.post(name: .openclawConfigDidChange, object: nil)
60+
}.value
61+
},
62+
notificationCenter: notificationCenter))
63+
{
64+
try await ConfigStore.save(["remote": true])
65+
}
5666

57-
await ConfigStore._testClearOverrides()
5867
#expect(remoteHit)
5968
#expect(!localHit)
6069
#expect(changeCount.value == 1)
70+
#expect(changeCount.allSendersWereNil)
6171
}
6272

6373
@Test func `save routes to local in local mode`() async throws {
@@ -76,25 +86,32 @@ struct ConfigStoreTests {
7686
}
7787

7888
@Test func `failed save does not announce config change`() async {
89+
let notificationCenter = NotificationCenter()
7990
let changeCount = NotificationCount()
80-
let observer = NotificationCenter.default.addObserver(
91+
let observer = notificationCenter.addObserver(
8192
forName: .openclawConfigDidChange,
8293
object: nil,
8394
queue: nil)
84-
{ _ in changeCount.increment() }
85-
defer { NotificationCenter.default.removeObserver(observer) }
86-
await ConfigStore._testSetOverrides(.init(
95+
{ note in changeCount.record(note) }
96+
defer { notificationCenter.removeObserver(observer) }
97+
98+
await self.withOverrides(.init(
8799
isRemoteMode: { true },
88100
saveRemote: { _ in
101+
// Concurrent same-name traffic must not look like a ConfigStore announcement.
102+
await Task.detached {
103+
NotificationCenter.default.post(name: .openclawConfigDidChange, object: nil)
104+
}.value
89105
throw NSError(domain: "ConfigStoreTests", code: 1)
90-
}))
91-
92-
do {
93-
try await ConfigStore.save(["remote": true])
94-
Issue.record("Expected save to fail")
95-
} catch {}
106+
},
107+
notificationCenter: notificationCenter))
108+
{
109+
do {
110+
try await ConfigStore.save(["remote": true])
111+
Issue.record("Expected save to fail")
112+
} catch {}
113+
}
96114

97-
await ConfigStore._testClearOverrides()
98115
#expect(changeCount.value == 0)
99116
}
100117

@@ -169,17 +186,40 @@ struct ConfigStoreTests {
169186
#expect((root?["meta"] as? [String: Any]) != nil)
170187
}
171188
}
189+
190+
private func withOverrides<T>(
191+
_ overrides: ConfigStore.Overrides,
192+
_ body: () async throws -> T) async rethrows -> T
193+
{
194+
await ConfigStore._testSetOverrides(overrides)
195+
do {
196+
let result = try await body()
197+
await ConfigStore._testClearOverrides()
198+
return result
199+
} catch {
200+
await ConfigStore._testClearOverrides()
201+
throw error
202+
}
203+
}
172204
}
173205

174206
private final class NotificationCount: @unchecked Sendable {
175207
private let lock = NSLock()
176208
private var count = 0
209+
private var sawNonNilSender = false
177210

178211
var value: Int {
179212
self.lock.withLock { self.count }
180213
}
181214

182-
func increment() {
183-
self.lock.withLock { self.count += 1 }
215+
var allSendersWereNil: Bool {
216+
self.lock.withLock { !self.sawNonNilSender }
217+
}
218+
219+
func record(_ notification: Notification) {
220+
self.lock.withLock {
221+
self.count += 1
222+
self.sawNonNilSender = self.sawNonNilSender || notification.object != nil
223+
}
184224
}
185225
}

0 commit comments

Comments
 (0)