Skip to content

Commit 0667b9f

Browse files
authored
test(realtime): fix MockWebSocketClient (#461)
* test(realtime): use mainSerialExecutor * fix MockWebSocketClient
1 parent 3c09d6e commit 0667b9f

File tree

5 files changed

+271
-200
lines changed

5 files changed

+271
-200
lines changed

Sources/Helpers/EventEmitter.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import ConcurrencyExtras
99
import Foundation
1010

11-
public final class ObservationToken: Sendable {
11+
public final class ObservationToken: Sendable, Hashable {
1212
let _onCancel = LockIsolated((@Sendable () -> Void)?.none)
1313

1414
package init(_ onCancel: (@Sendable () -> Void)? = nil) {
@@ -34,6 +34,20 @@ public final class ObservationToken: Sendable {
3434
deinit {
3535
cancel()
3636
}
37+
38+
public static func == (lhs: ObservationToken, rhs: ObservationToken) -> Bool {
39+
ObjectIdentifier(lhs) == ObjectIdentifier(rhs)
40+
}
41+
42+
public func hash(into hasher: inout Hasher) {
43+
hasher.combine(ObjectIdentifier(self))
44+
}
45+
}
46+
47+
extension ObservationToken {
48+
public func store(in set: inout Set<ObservationToken>) {
49+
set.insert(self)
50+
}
3751
}
3852

3953
package final class EventEmitter<Event: Sendable>: Sendable {

Sources/Realtime/V2/RealtimeChannelV2.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,17 @@ public final class RealtimeChannelV2: Sendable {
7777
statusEventEmitter.stream()
7878
}
7979

80+
/// Listen for connection status changes.
81+
/// - Parameter listener: Closure that will be called when connection status changes.
82+
/// - Returns: An observation handle that can be used to stop listening.
83+
///
84+
/// - Note: Use ``statusChange`` if you prefer to use Async/Await.
85+
public func onStatusChange(
86+
_ listener: @escaping @Sendable (Status) -> Void
87+
) -> ObservationToken {
88+
statusEventEmitter.attach(listener)
89+
}
90+
8091
init(
8192
topic: String,
8293
config: RealtimeChannelConfig,

Tests/IntegrationTests/RealtimeIntegrationTests.swift

Lines changed: 94 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -37,126 +37,130 @@ final class RealtimeIntegrationTests: XCTestCase {
3737
)
3838

3939
func testBroadcast() async throws {
40-
let expectation = expectation(description: "receivedBroadcastMessages")
41-
expectation.expectedFulfillmentCount = 3
40+
try await withMainSerialExecutor {
41+
let expectation = expectation(description: "receivedBroadcastMessages")
42+
expectation.expectedFulfillmentCount = 3
4243

43-
let channel = realtime.channel("integration") {
44-
$0.broadcast.receiveOwnBroadcasts = true
45-
}
44+
let channel = realtime.channel("integration") {
45+
$0.broadcast.receiveOwnBroadcasts = true
46+
}
4647

47-
let receivedMessages = LockIsolated<[JSONObject]>([])
48+
let receivedMessages = LockIsolated<[JSONObject]>([])
4849

49-
Task {
50-
for await message in channel.broadcastStream(event: "test") {
51-
receivedMessages.withValue {
52-
$0.append(message)
50+
Task {
51+
for await message in channel.broadcastStream(event: "test") {
52+
receivedMessages.withValue {
53+
$0.append(message)
54+
}
55+
expectation.fulfill()
5356
}
54-
expectation.fulfill()
5557
}
56-
}
5758

58-
await Task.megaYield()
59+
await Task.yield()
5960

60-
await channel.subscribe()
61+
await channel.subscribe()
6162

62-
struct Message: Codable {
63-
var value: Int
64-
}
63+
struct Message: Codable {
64+
var value: Int
65+
}
6566

66-
try await channel.broadcast(event: "test", message: Message(value: 1))
67-
try await channel.broadcast(event: "test", message: Message(value: 2))
68-
try await channel.broadcast(event: "test", message: ["value": 3, "another_value": 42])
67+
try await channel.broadcast(event: "test", message: Message(value: 1))
68+
try await channel.broadcast(event: "test", message: Message(value: 2))
69+
try await channel.broadcast(event: "test", message: ["value": 3, "another_value": 42])
6970

70-
await fulfillment(of: [expectation], timeout: 0.5)
71+
await fulfillment(of: [expectation], timeout: 0.5)
7172

72-
XCTAssertNoDifference(
73-
receivedMessages.value,
74-
[
73+
XCTAssertNoDifference(
74+
receivedMessages.value,
7575
[
76-
"event": "test",
77-
"payload": [
78-
"value": 1,
76+
[
77+
"event": "test",
78+
"payload": [
79+
"value": 1,
80+
],
81+
"type": "broadcast",
7982
],
80-
"type": "broadcast",
81-
],
82-
[
83-
"event": "test",
84-
"payload": [
85-
"value": 2,
83+
[
84+
"event": "test",
85+
"payload": [
86+
"value": 2,
87+
],
88+
"type": "broadcast",
8689
],
87-
"type": "broadcast",
88-
],
89-
[
90-
"event": "test",
91-
"payload": [
92-
"value": 3,
93-
"another_value": 42,
90+
[
91+
"event": "test",
92+
"payload": [
93+
"value": 3,
94+
"another_value": 42,
95+
],
96+
"type": "broadcast",
9497
],
95-
"type": "broadcast",
96-
],
97-
]
98-
)
98+
]
99+
)
99100

100-
await channel.unsubscribe()
101+
await channel.unsubscribe()
102+
}
101103
}
102104

103105
func testPresence() async throws {
104-
let channel = realtime.channel("integration") {
105-
$0.broadcast.receiveOwnBroadcasts = true
106-
}
106+
try await withMainSerialExecutor {
107+
let channel = realtime.channel("integration") {
108+
$0.broadcast.receiveOwnBroadcasts = true
109+
}
107110

108-
let expectation = expectation(description: "presenceChange")
109-
expectation.expectedFulfillmentCount = 4
111+
let expectation = expectation(description: "presenceChange")
112+
expectation.expectedFulfillmentCount = 4
110113

111-
let receivedPresenceChanges = LockIsolated<[any PresenceAction]>([])
114+
let receivedPresenceChanges = LockIsolated<[any PresenceAction]>([])
112115

113-
Task {
114-
for await presence in channel.presenceChange() {
115-
receivedPresenceChanges.withValue {
116-
$0.append(presence)
116+
Task {
117+
for await presence in channel.presenceChange() {
118+
receivedPresenceChanges.withValue {
119+
$0.append(presence)
120+
}
121+
expectation.fulfill()
117122
}
118-
expectation.fulfill()
119123
}
120-
}
121-
122-
await Task.megaYield()
123124

124-
await channel.subscribe()
125+
await Task.yield()
125126

126-
struct UserState: Codable, Equatable {
127-
let email: String
128-
}
127+
await channel.subscribe()
129128

130-
try await channel.track(UserState(email: "[email protected]"))
131-
try await channel.track(["email": "[email protected]"])
132-
133-
await channel.untrack()
129+
struct UserState: Codable, Equatable {
130+
let email: String
131+
}
134132

135-
await fulfillment(of: [expectation], timeout: 0.5)
133+
try await channel.track(UserState(email: "[email protected]"))
134+
try await channel.track(["email": "[email protected]"])
136135

137-
let joins = try receivedPresenceChanges.value.map { try $0.decodeJoins(as: UserState.self) }
138-
let leaves = try receivedPresenceChanges.value.map { try $0.decodeLeaves(as: UserState.self) }
139-
XCTAssertNoDifference(
140-
joins,
141-
[
142-
[], // This is the first PRESENCE_STATE event.
143-
[UserState(email: "[email protected]")],
144-
[UserState(email: "[email protected]")],
145-
[],
146-
]
147-
)
136+
await channel.untrack()
148137

149-
XCTAssertNoDifference(
150-
leaves,
151-
[
152-
[], // This is the first PRESENCE_STATE event.
153-
[],
154-
[UserState(email: "[email protected]")],
155-
[UserState(email: "[email protected]")],
156-
]
157-
)
138+
await fulfillment(of: [expectation], timeout: 0.5)
158139

159-
await channel.unsubscribe()
140+
let joins = try receivedPresenceChanges.value.map { try $0.decodeJoins(as: UserState.self) }
141+
let leaves = try receivedPresenceChanges.value.map { try $0.decodeLeaves(as: UserState.self) }
142+
XCTAssertNoDifference(
143+
joins,
144+
[
145+
[], // This is the first PRESENCE_STATE event.
146+
[UserState(email: "[email protected]")],
147+
[UserState(email: "[email protected]")],
148+
[],
149+
]
150+
)
151+
152+
XCTAssertNoDifference(
153+
leaves,
154+
[
155+
[], // This is the first PRESENCE_STATE event.
156+
[],
157+
[UserState(email: "[email protected]")],
158+
[UserState(email: "[email protected]")],
159+
]
160+
)
161+
162+
await channel.unsubscribe()
163+
}
160164
}
161165

162166
// FIXME: Test getting stuck
@@ -179,7 +183,7 @@ final class RealtimeIntegrationTests: XCTestCase {
179183
// await channel.postgresChange(AnyAction.self, schema: "public").prefix(3).collect()
180184
// }
181185
//
182-
// await Task.megaYield()
186+
// await Task.yield()
183187
// await channel.subscribe()
184188
//
185189
// struct Entry: Codable, Equatable {

Tests/RealtimeTests/MockWebSocketClient.swift

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,42 +15,81 @@ import XCTestDynamicOverlay
1515
#endif
1616

1717
final class MockWebSocketClient: WebSocketClient {
18-
let sentMessages = LockIsolated<[RealtimeMessageV2]>([])
18+
struct MutableState {
19+
var receiveContinuation: AsyncThrowingStream<RealtimeMessageV2, any Error>.Continuation?
20+
var sentMessages: [RealtimeMessageV2] = []
21+
var onCallback: ((RealtimeMessageV2) -> RealtimeMessageV2?)?
22+
var connectContinuation: AsyncStream<ConnectionStatus>.Continuation?
23+
24+
var sendMessageBuffer: [RealtimeMessageV2] = []
25+
var connectionStatusBuffer: [ConnectionStatus] = []
26+
}
27+
28+
private let mutableState = LockIsolated(MutableState())
29+
30+
var sentMessages: [RealtimeMessageV2] {
31+
mutableState.sentMessages
32+
}
33+
1934
func send(_ message: RealtimeMessageV2) async throws {
20-
sentMessages.withValue {
21-
$0.append(message)
22-
}
35+
mutableState.withValue {
36+
$0.sentMessages.append(message)
2337

24-
if let callback = onCallback.value, let response = callback(message) {
25-
mockReceive(response)
38+
if let callback = $0.onCallback, let response = callback(message) {
39+
mockReceive(response)
40+
}
2641
}
2742
}
2843

29-
private let receiveContinuation =
30-
LockIsolated<AsyncThrowingStream<RealtimeMessageV2, any Error>.Continuation?>(nil)
3144
func mockReceive(_ message: RealtimeMessageV2) {
32-
receiveContinuation.value?.yield(message)
45+
mutableState.withValue {
46+
if let continuation = $0.receiveContinuation {
47+
continuation.yield(message)
48+
} else {
49+
$0.sendMessageBuffer.append(message)
50+
}
51+
}
3352
}
3453

35-
private let onCallback = LockIsolated<((RealtimeMessageV2) -> RealtimeMessageV2?)?>(nil)
3654
func on(_ callback: @escaping (RealtimeMessageV2) -> RealtimeMessageV2?) {
37-
onCallback.setValue(callback)
55+
mutableState.withValue {
56+
$0.onCallback = callback
57+
}
3858
}
3959

4060
func receive() -> AsyncThrowingStream<RealtimeMessageV2, any Error> {
4161
let (stream, continuation) = AsyncThrowingStream<RealtimeMessageV2, any Error>.makeStream()
42-
receiveContinuation.setValue(continuation)
62+
mutableState.withValue {
63+
$0.receiveContinuation = continuation
64+
65+
while !$0.sendMessageBuffer.isEmpty {
66+
let message = $0.sendMessageBuffer.removeFirst()
67+
$0.receiveContinuation?.yield(message)
68+
}
69+
}
4370
return stream
4471
}
4572

46-
private let connectContinuation = LockIsolated<AsyncStream<ConnectionStatus>.Continuation?>(nil)
4773
func mockConnect(_ status: ConnectionStatus) {
48-
connectContinuation.value?.yield(status)
74+
mutableState.withValue {
75+
if let continuation = $0.connectContinuation {
76+
continuation.yield(status)
77+
} else {
78+
$0.connectionStatusBuffer.append(status)
79+
}
80+
}
4981
}
5082

5183
func connect() -> AsyncStream<ConnectionStatus> {
5284
let (stream, continuation) = AsyncStream<ConnectionStatus>.makeStream()
53-
connectContinuation.setValue(continuation)
85+
mutableState.withValue {
86+
$0.connectContinuation = continuation
87+
88+
while !$0.connectionStatusBuffer.isEmpty {
89+
let status = $0.connectionStatusBuffer.removeFirst()
90+
$0.connectContinuation?.yield(status)
91+
}
92+
}
5493
return stream
5594
}
5695

0 commit comments

Comments
 (0)