Skip to content

Commit

Permalink
Merge branch 'main' into ablyChatDemo-wip
Browse files Browse the repository at this point in the history
  • Loading branch information
umair-ably committed Sep 30, 2024
2 parents 325bb75 + b35b114 commit 84af6a1
Show file tree
Hide file tree
Showing 12 changed files with 134 additions and 128 deletions.
16 changes: 4 additions & 12 deletions Sources/AblyChat/AblyCocoaExtensions/Ably+Dependencies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,9 @@ import Ably

// TODO: remove "@unchecked Sendable" once https://github.com/ably/ably-cocoa/issues/1962 done

#if swift(>=6)
// This @retroactive is needed to silence the Swift 6 compiler error "extension declares a conformance of imported type 'ARTRealtimeChannels' to imported protocol 'Sendable'; this will not behave correctly if the owners of 'Ably' introduce this conformance in the future (…) add '@retroactive' to silence this warning". I don’t fully understand the implications of this but don’t really mind since both libraries are in our control.
extension ARTRealtime: RealtimeClientProtocol, @retroactive @unchecked Sendable {}
// This @retroactive is needed to silence the Swift 6 compiler error "extension declares a conformance of imported type 'ARTRealtimeChannels' to imported protocol 'Sendable'; this will not behave correctly if the owners of 'Ably' introduce this conformance in the future (…) add '@retroactive' to silence this warning". I don’t fully understand the implications of this but don’t really mind since both libraries are in our control.
extension ARTRealtime: RealtimeClientProtocol, @retroactive @unchecked Sendable {}

extension ARTRealtimeChannels: RealtimeChannelsProtocol, @retroactive @unchecked Sendable {}
extension ARTRealtimeChannels: RealtimeChannelsProtocol, @retroactive @unchecked Sendable {}

extension ARTRealtimeChannel: RealtimeChannelProtocol, @retroactive @unchecked Sendable {}
#else
extension ARTRealtime: RealtimeClientProtocol, @unchecked Sendable {}

extension ARTRealtimeChannels: RealtimeChannelsProtocol, @unchecked Sendable {}

extension ARTRealtimeChannel: RealtimeChannelProtocol, @unchecked Sendable {}
#endif
extension ARTRealtimeChannel: RealtimeChannelProtocol, @retroactive @unchecked Sendable {}
23 changes: 22 additions & 1 deletion Sources/AblyChat/Presence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ public typealias PresenceData = any Sendable

public protocol Presence: AnyObject, Sendable, EmitsDiscontinuities {
func get() async throws -> [PresenceMember]
func get(params: ARTRealtimePresenceQuery?) async throws -> [PresenceMember]
func get(params: PresenceQuery?) async throws -> [PresenceMember]
func isUserPresent(clientID: String) async throws -> Bool
func enter() async throws
func enter(data: PresenceData) async throws
Expand Down Expand Up @@ -61,3 +61,24 @@ public struct PresenceEvent: Sendable {
self.data = data
}
}

// This is a Sendable equivalent of ably-cocoa’s ARTRealtimePresenceQuery type.
//
// Originally, ``Presence.get(params:)`` accepted an ARTRealtimePresenceQuery object, but I’ve changed it to accept this type, because else when you try and write an actor that implements ``Presence``, you get a compiler error like "Non-sendable type 'ARTRealtimePresenceQuery' in parameter of the protocol requirement satisfied by actor-isolated instance method 'get(params:)' cannot cross actor boundary; this is an error in the Swift 6 language mode".
//
// Now, based on my limited understanding, you _should_ be able to send non-Sendable values from one isolation domain to another (the purpose of the "region-based isolation" and "`sending` parameters" features added in Swift 6), but to get this to work I had to mark ``Presence`` as requiring conformance to the `Actor` protocol, and since I didn’t understand _why_ I had to do that, I didn’t want to put it in the public API.
//
// So, for now, let’s just accept this copy (which I don’t think is a big problem anyway); we can always revisit it with more Swift concurrency knowledge in the future. Created https://github.com/ably-labs/ably-chat-swift/issues/64 to revisit.
public struct PresenceQuery: Sendable {
public var limit = 100
public var clientID: String?
public var connectionID: String?
public var waitForSync = true

internal init(limit: Int = 100, clientID: String? = nil, connectionID: String? = nil, waitForSync: Bool = true) {
self.limit = limit
self.clientID = clientID
self.connectionID = connectionID
self.waitForSync = waitForSync
}
}
2 changes: 1 addition & 1 deletion Sources/AblyChat/Subscription.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//
// At some point we should define how this thing behaves when you iterate over it from multiple loops, or when you pass it around. I’m not yet sufficiently experienced with `AsyncSequence` to know what’s idiomatic. I tried the same thing out with `AsyncStream` (two tasks iterating over a single stream) and it appears that each element is delivered to precisely one consumer. But we can leave that for later. On a similar note consider whether it makes a difference whether this is a struct or a class.
//
// TODO: I wanted to implement this as a protocol (from which `MessageSubscription` would then inherit) but struggled to do so, hence the struct. Try again sometime. We can also revisit our implementation of `AsyncSequence` if we migrate to Swift 6, which adds primary types and typed errors to `AsyncSequence` and should make things easier; see https://github.com/ably-labs/ably-chat-swift/issues/21.
// I wanted to implement this as a protocol (from which `MessageSubscription` would then inherit) but struggled to do so (see https://forums.swift.org/t/struggling-to-create-a-protocol-that-inherits-from-asyncsequence-with-primary-associated-type/73950 where someone suggested it’s a compiler bug), hence the struct. I was also hoping that upon switching to Swift 6 we could use AsyncSequence’s `Failure` associated type to simplify the way in which we show that the subscription is non-throwing, but it turns out this can only be done in macOS 15 etc. So I think that for now we’re stuck with things the way they are.
public struct Subscription<Element: Sendable>: Sendable, AsyncSequence {
private enum Mode: Sendable {
case `default`(stream: AsyncStream<Element>, continuation: AsyncStream<Element>.Continuation)
Expand Down
18 changes: 10 additions & 8 deletions Tests/AblyChatTests/DefaultChatClientTests.swift
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
@testable import AblyChat
import XCTest
import Testing

class DefaultChatClientTests: XCTestCase {
func test_init_withoutClientOptions() {
struct DefaultChatClientTests {
@Test
func init_withoutClientOptions() {
// Given: An instance of DefaultChatClient is created with nil clientOptions
let client = DefaultChatClient(realtime: MockRealtime.create(), clientOptions: nil)

// Then: It uses the default client options
let defaultOptions = ClientOptions()
XCTAssertTrue(client.clientOptions.isEqualForTestPurposes(defaultOptions))
#expect(client.clientOptions.isEqualForTestPurposes(defaultOptions))
}

func test_rooms() throws {
@Test
func rooms() throws {
// Given: An instance of DefaultChatClient
let realtime = MockRealtime.create()
let options = ClientOptions()
Expand All @@ -20,8 +22,8 @@ class DefaultChatClientTests: XCTestCase {
// Then: Its `rooms` property returns an instance of DefaultRooms with the same realtime client and client options
let rooms = client.rooms

let defaultRooms = try XCTUnwrap(rooms as? DefaultRooms)
XCTAssertIdentical(defaultRooms.realtime, realtime)
XCTAssertTrue(defaultRooms.clientOptions.isEqualForTestPurposes(options))
let defaultRooms = try #require(rooms as? DefaultRooms)
#expect(defaultRooms.realtime === realtime)
#expect(defaultRooms.clientOptions.isEqualForTestPurposes(options))
}
}
27 changes: 15 additions & 12 deletions Tests/AblyChatTests/DefaultInternalLoggerTests.swift
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
@testable import AblyChat
import XCTest
import Testing

class DefaultInternalLoggerTests: XCTestCase {
func test_defaults() {
struct DefaultInternalLoggerTests {
@Test
func defaults() {
let logger = DefaultInternalLogger(logHandler: nil, logLevel: nil)

XCTAssertTrue(logger.logHandler is DefaultLogHandler)
XCTAssertEqual(logger.logLevel, .error)
#expect(logger.logHandler is DefaultLogHandler)
#expect(logger.logLevel == .error)
}

func test_log() throws {
@Test
func log() throws {
// Given: A DefaultInternalLogger instance
let logHandler = MockLogHandler()
let logger = DefaultInternalLogger(logHandler: logHandler, logLevel: nil)
Expand All @@ -22,13 +24,14 @@ class DefaultInternalLoggerTests: XCTestCase {
)

// Then: It calls log(…) on the underlying logger, interpolating the code location into the message and passing through the level
let logArguments = try XCTUnwrap(logHandler.logArguments)
XCTAssertEqual(logArguments.message, "(Ably/Room.swift:123) Hello")
XCTAssertEqual(logArguments.level, .error)
XCTAssertNil(logArguments.context)
let logArguments = try #require(logHandler.logArguments)
#expect(logArguments.message == "(Ably/Room.swift:123) Hello")
#expect(logArguments.level == .error)
#expect(logArguments.context == nil)
}

func test_log_whenLogLevelArgumentIsLessSevereThanLogLevelProperty_itDoesNotLog() {
@Test
func log_whenLogLevelArgumentIsLessSevereThanLogLevelProperty_itDoesNotLog() {
// Given: A DefaultInternalLogger instance
let logHandler = MockLogHandler()
let logger = DefaultInternalLogger(
Expand All @@ -44,6 +47,6 @@ class DefaultInternalLoggerTests: XCTestCase {
)

// Then: It does not call `log(…)` on the underlying logger
XCTAssertNil(logHandler.logArguments)
#expect(logHandler.logArguments == nil)
}
}
33 changes: 14 additions & 19 deletions Tests/AblyChatTests/DefaultRoomStatusTests.swift
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
@testable import AblyChat
import XCTest
import Testing

class DefaultRoomStatusTests: XCTestCase {
func test_current_startsAsInitialized() async {
struct DefaultRoomStatusTests {
@Test
func current_startsAsInitialized() async {
let status = DefaultRoomStatus(logger: TestLogger())
let current = await status.current
XCTAssertEqual(current, .initialized)
#expect(await status.current == .initialized)
}

func test_error_startsAsNil() async {
@Test()
func error_startsAsNil() async {
let status = DefaultRoomStatus(logger: TestLogger())
let error = await status.error
XCTAssertNil(error)
#expect(await status.error == nil)
}

func test_transition() async {
@Test
func transition() async throws {
// Given: A RoomStatus
let status = DefaultRoomStatus(logger: TestLogger())
let originalState = await status.current
Expand All @@ -30,17 +31,11 @@ class DefaultRoomStatusTests: XCTestCase {
await status.transition(to: newState)

// Then: It emits a status change to all subscribers added via onChange(bufferingPolicy:), and updates its `current` property to the new state
guard let statusChange1 = await statusChange1, let statusChange2 = await statusChange2 else {
XCTFail("Expected status changes to be emitted")
return
for statusChange in try await [#require(statusChange1), #require(statusChange2)] {
#expect(statusChange.previous == originalState)
#expect(statusChange.current == newState)
}

for statusChange in [statusChange1, statusChange2] {
XCTAssertEqual(statusChange.previous, originalState)
XCTAssertEqual(statusChange.current, newState)
}

let current = await status.current
XCTAssertEqual(current, .attached)
#expect(await status.current == .attached)
}
}
44 changes: 18 additions & 26 deletions Tests/AblyChatTests/DefaultRoomTests.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import Ably
@testable import AblyChat
import XCTest
import Testing

class DefaultRoomTests: XCTestCase {
func test_attach_attachesAllChannels_andSucceedsIfAllSucceed() async throws {
struct DefaultRoomTests {
@Test
func attach_attachesAllChannels_andSucceedsIfAllSucceed() async throws {
// Given: a DefaultRoom instance with ID "basketball", with a Realtime client for which `attach(_:)` completes successfully if called on the following channels:
//
// - basketball::$chat::$chatMessages
Expand All @@ -26,19 +27,15 @@ class DefaultRoomTests: XCTestCase {

// Then: `attach(_:)` is called on each of the channels, the room `attach` call succeeds, and the room transitions to ATTACHED
for channel in channelsList {
XCTAssertTrue(channel.attachCallCounter.isNonZero)
#expect(channel.attachCallCounter.isNonZero)
}

guard let attachedStatusChange = await attachedStatusChange else {
XCTFail("Expected status change to ATTACHED but didn't get one")
return
}
let currentStatus = await room.status.current
XCTAssertEqual(currentStatus, .attached)
XCTAssertEqual(attachedStatusChange.current, .attached)
#expect(await room.status.current == .attached)
#expect(try #require(await attachedStatusChange).current == .attached)
}

func test_attach_attachesAllChannels_andFailsIfOneFails() async throws {
@Test
func attach_attachesAllChannels_andFailsIfOneFails() async throws {
// Given: a DefaultRoom instance, with a Realtime client for which `attach(_:)` completes successfully if called on the following channels:
//
// - basketball::$chat::$chatMessages
Expand All @@ -65,11 +62,11 @@ class DefaultRoomTests: XCTestCase {
}

// Then: the room `attach` call fails with the same error as the channel `attach(_:)` call
let roomAttachErrorInfo = try XCTUnwrap(roomAttachError as? ARTErrorInfo)
XCTAssertIdentical(roomAttachErrorInfo, channelAttachError)
#expect(try #require(roomAttachError as? ARTErrorInfo) === channelAttachError)
}

func test_detach_detachesAllChannels_andSucceedsIfAllSucceed() async throws {
@Test
func detach_detachesAllChannels_andSucceedsIfAllSucceed() async throws {
// Given: a DefaultRoom instance with ID "basketball", with a Realtime client for which `detach(_:)` completes successfully if called on the following channels:
//
// - basketball::$chat::$chatMessages
Expand All @@ -92,19 +89,15 @@ class DefaultRoomTests: XCTestCase {

// Then: `detach(_:)` is called on each of the channels, the room `detach` call succeeds, and the room transitions to DETACHED
for channel in channelsList {
XCTAssertTrue(channel.detachCallCounter.isNonZero)
#expect(channel.detachCallCounter.isNonZero)
}

guard let detachedStatusChange = await detachedStatusChange else {
XCTFail("Expected status change to DETACHED but didn't get one")
return
}
let currentStatus = await room.status.current
XCTAssertEqual(currentStatus, .detached)
XCTAssertEqual(detachedStatusChange.current, .detached)
#expect(await room.status.current == .detached)
#expect(try #require(await detachedStatusChange).current == .detached)
}

func test_detach_detachesAllChannels_andFailsIfOneFails() async throws {
@Test
func detach_detachesAllChannels_andFailsIfOneFails() async throws {
// Given: a DefaultRoom instance, with a Realtime client for which `detach(_:)` completes successfully if called on the following channels:
//
// - basketball::$chat::$chatMessages
Expand All @@ -131,7 +124,6 @@ class DefaultRoomTests: XCTestCase {
}

// Then: the room `detach` call fails with the same error as the channel `detach(_:)` call
let roomDetachErrorInfo = try XCTUnwrap(roomDetachError as? ARTErrorInfo)
XCTAssertIdentical(roomDetachErrorInfo, channelDetachError)
#expect(try #require(roomDetachError as? ARTErrorInfo) === channelDetachError)
}
}
25 changes: 14 additions & 11 deletions Tests/AblyChatTests/DefaultRoomsTests.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
@testable import AblyChat
import XCTest
import Testing

class DefaultRoomsTests: XCTestCase {
struct DefaultRoomsTests {
// @spec CHA-RC1a
func test_get_returnsRoomWithGivenID() async throws {
@Test
func get_returnsRoomWithGivenID() async throws {
// Given: an instance of DefaultRooms
let realtime = MockRealtime.create()
let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger())
Expand All @@ -14,14 +15,15 @@ class DefaultRoomsTests: XCTestCase {
let room = try await rooms.get(roomID: roomID, options: options)

// Then: It returns a DefaultRoom instance that uses the same Realtime instance, with the given ID and options
let defaultRoom = try XCTUnwrap(room as? DefaultRoom)
XCTAssertIdentical(defaultRoom.realtime, realtime)
XCTAssertEqual(defaultRoom.roomID, roomID)
XCTAssertEqual(defaultRoom.options, options)
let defaultRoom = try #require(room as? DefaultRoom)
#expect(defaultRoom.realtime === realtime)
#expect(defaultRoom.roomID == roomID)
#expect(defaultRoom.options == options)
}

// @spec CHA-RC1b
func test_get_returnsExistingRoomWithGivenID() async throws {
@Test
func get_returnsExistingRoomWithGivenID() async throws {
// Given: an instance of DefaultRooms, on which get(roomID:options:) has already been called with a given ID
let realtime = MockRealtime.create()
let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger())
Expand All @@ -34,11 +36,12 @@ class DefaultRoomsTests: XCTestCase {
let secondRoom = try await rooms.get(roomID: roomID, options: options)

// Then: It returns the same room object
XCTAssertIdentical(secondRoom, firstRoom)
#expect(secondRoom === firstRoom)
}

// @spec CHA-RC1c
func test_get_throwsErrorWhenOptionsDoNotMatch() async throws {
@Test
func get_throwsErrorWhenOptionsDoNotMatch() async throws {
// Given: an instance of DefaultRooms, on which get(roomID:options:) has already been called with a given ID and options
let realtime = MockRealtime.create()
let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger())
Expand All @@ -59,6 +62,6 @@ class DefaultRoomsTests: XCTestCase {
}

// Then: It throws an inconsistentRoomOptions error
try assertIsChatError(caughtError, withCode: .inconsistentRoomOptions)
#expect(isChatError(caughtError, withCode: .inconsistentRoomOptions))
}
}
16 changes: 8 additions & 8 deletions Tests/AblyChatTests/Helpers/Helpers.swift
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import Ably
@testable import AblyChat
import XCTest

/**
Asserts that a given optional `Error` is an `ARTErrorInfo` in the chat error domain with a given code.
Tests whether a given optional `Error` is an `ARTErrorInfo` in the chat error domain with a given code.
*/
func assertIsChatError(_ maybeError: (any Error)?, withCode code: AblyChat.ErrorCode, file: StaticString = #filePath, line: UInt = #line) throws {
let error = try XCTUnwrap(maybeError, "Expected an error", file: file, line: line)
let ablyError = try XCTUnwrap(error as? ARTErrorInfo, "Expected an ARTErrorInfo", file: file, line: line)
func isChatError(_ maybeError: (any Error)?, withCode code: AblyChat.ErrorCode) -> Bool {
guard let ablyError = maybeError as? ARTErrorInfo else {
return false
}

XCTAssertEqual(ablyError.domain, AblyChat.errorDomain as String, file: file, line: line)
XCTAssertEqual(ablyError.code, code.rawValue, file: file, line: line)
XCTAssertEqual(ablyError.statusCode, code.statusCode, file: file, line: line)
return ablyError.domain == AblyChat.errorDomain as String
&& ablyError.code == code.rawValue
&& ablyError.statusCode == code.statusCode
}
Loading

0 comments on commit 84af6a1

Please sign in to comment.