From 783865c3f5c54a65474ab520f0aa673957ffbfee Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 20 Aug 2024 16:42:44 +0100 Subject: [PATCH 1/2] Add AsyncAlgorithms package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It offers some nice convenience features. I don’t think that this package is a controversial inclusion; it’s written by Apple and they had a WWDC session about it. This is the first time I’ve tried adding a new package, and for some reason Xcode didn’t update its xcshareddata Package.resolved automatically, but running `xcodebuild -resolvePackageDependencies` did the trick. Adding this package exposed a couple of issues in our CI setup: 1. the way in which we specify which Swift language version to use — I’ve fixed this 2. Xcode can fail when trying to compile a Package.swift package using “treat warnings as errors” — I’ve had to turn that off --- .github/workflows/check.yaml | 10 ++-- .../xcshareddata/swiftpm/Package.resolved | 20 +++++++- CONTRIBUTING.md | 5 +- .../AblyChatExample.xcodeproj/project.pbxproj | 6 ++- Example/AblyChatExample/Config.xcconfig | 3 ++ Package.resolved | 20 +++++++- Package.swift | 8 +++ Package@swift-6.swift | 8 +++ Sources/BuildTool/BuildTool.swift | 7 ++- Sources/BuildTool/XcodeRunner.swift | 51 +++++++++++++++++-- 10 files changed, 119 insertions(+), 19 deletions(-) create mode 100644 Example/AblyChatExample/Config.xcconfig diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 51e93c73..932625e6 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -43,7 +43,7 @@ jobs: run: swift run BuildTool generate-matrices >> $GITHUB_OUTPUT check-spm: - name: SPM (Xcode ${{ matrix.tooling.xcodeVersion }}, Swift ${{ matrix.tooling.swiftVersion }}) + name: SPM (Xcode ${{ matrix.tooling.xcodeVersion }}) runs-on: macos-latest needs: generate-matrices strategy: @@ -57,11 +57,11 @@ jobs: xcode-version: ${{ matrix.tooling.xcodeVersion }} # https://forums.swift.org/t/warnings-as-errors-for-libraries-frameworks/58393/2 - - run: swift build -Xswiftc -warnings-as-errors -Xswiftc -swift-version -Xswiftc ${{ matrix.tooling.swiftVersion }} - - run: swift test -Xswiftc -warnings-as-errors -Xswiftc -swift-version -Xswiftc ${{ matrix.tooling.swiftVersion }} + - run: swift build -Xswiftc -warnings-as-errors + - run: swift test -Xswiftc -warnings-as-errors check-xcode: - name: Xcode, ${{matrix.platform}} (Xcode ${{ matrix.tooling.xcodeVersion }}, Swift ${{ matrix.tooling.swiftVersion }}) + name: Xcode, ${{matrix.platform}} (Xcode ${{ matrix.tooling.xcodeVersion }}) runs-on: macos-latest needs: generate-matrices @@ -76,7 +76,7 @@ jobs: xcode-version: ${{ matrix.tooling.xcodeVersion }} - name: Build and run tests - run: swift run BuildTool build-and-test-library --platform ${{ matrix.platform }} --swift-version ${{ matrix.tooling.swiftVersion }} + run: swift run BuildTool build-and-test-library --platform ${{ matrix.platform }} check-example-app: name: Example app, ${{matrix.platform}} (Xcode ${{ matrix.tooling.xcodeVersion }}, Swift ${{ matrix.tooling.swiftVersion }}) diff --git a/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved index 18c2c7de..9cffb1ec 100644 --- a/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f6b591fa76437494c2609ca1208b8583cbc3debe5e659be06bdcb7bc4bb5e457", + "originHash" : "fcc346d6fe86e610ac200cdbbf91c56204df67286546d5079bd9c610ee65953b", "pins" : [ { "identity" : "ably-cocoa", @@ -36,6 +36,24 @@ "revision" : "41982a3656a71c768319979febd796c6fd111d5c", "version" : "1.5.0" } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms", + "state" : { + "revision" : "6ae9a051f76b81cc668305ceed5b0e0a7fd93d20", + "version" : "1.0.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", + "version" : "1.1.2" + } } ], "version" : 3 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 93817e34..1501f9c6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,6 +39,9 @@ And, actually more importantly, we want to be sure that the SDK can be integrate ### Multiple `Package.swift` files -We have a separate manifest file, `Package@swift-6.swift`, which a Swift compiler supporting Swift 6 will use instead of `Package.swift` (see [documentation of this SPM feature](https://github.com/swiftlang/swift-package-manager/blob/74f06f8a7fd6b4c729e474dee34db66319d90759/Documentation/Usage.md#version-specific-manifest-selection)). This file only exists because if you try to use `.enableUpcomingFeature` for a feature that is enabled by default in Swift 6, you’ll get an error `error: upcoming feature 'BareSlashRegexLiterals' is already enabled as of Swift version 6`. (I don’t know if there’s a better way of handling this.) +We have a separate manifest file, `Package@swift-6.swift`, which a Swift compiler supporting Swift 6 will use instead of `Package.swift` (see [documentation of this SPM feature](https://github.com/swiftlang/swift-package-manager/blob/74f06f8a7fd6b4c729e474dee34db66319d90759/Documentation/Usage.md#version-specific-manifest-selection)). This file exists for two reasons: + +1. To tell the compiler “use the Swift 6 language mode to compile this package if the compiler supports Swift 6, else use the Swift 5 language mode” (I previously tried passing `-Xswiftc -swift-version -Xswiftc 6` to `swift build` but this seems to then use Swift 6 language mode for compiling not just our own package, but all of our dependencies, which is likely to fail.) +2. If you try to use `.enableUpcomingFeature` for a feature that is enabled by default in Swift 6, you’ll get an error `error: upcoming feature 'BareSlashRegexLiterals' is already enabled as of Swift version 6`. (I don’t know if there’s a better way of handling this.) So, we need to make sure we keep `Package.swift` and `Package@swift-6.swift` in sync manually. diff --git a/Example/AblyChatExample.xcodeproj/project.pbxproj b/Example/AblyChatExample.xcodeproj/project.pbxproj index a704d8b1..a82aa446 100644 --- a/Example/AblyChatExample.xcodeproj/project.pbxproj +++ b/Example/AblyChatExample.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ /* Begin PBXFileReference section */ 212F95A62C6CAD9300420287 /* MockRealtime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRealtime.swift; sourceTree = ""; }; + 214AA9262C778FB70068FD0A /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; 21F09A9C2C60CAF00025AF73 /* AblyChatExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AblyChatExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 21F09A9F2C60CAF00025AF73 /* AblyChatExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AblyChatExampleApp.swift; sourceTree = ""; }; 21F09AA12C60CAF00025AF73 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -75,6 +76,7 @@ 212F95A52C6CAD7E00420287 /* Mocks */, 21F09A9F2C60CAF00025AF73 /* AblyChatExampleApp.swift */, 21F09AA12C60CAF00025AF73 /* ContentView.swift */, + 214AA9262C778FB70068FD0A /* Config.xcconfig */, 21F09AA32C60CAF20025AF73 /* Assets.xcassets */, 21F09AA52C60CAF20025AF73 /* AblyChatExample.entitlements */, 21F09AA62C60CAF20025AF73 /* Preview Content */, @@ -174,6 +176,7 @@ /* Begin XCBuildConfiguration section */ 21F09AA92C60CAF20025AF73 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 214AA9262C778FB70068FD0A /* Config.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -238,6 +241,7 @@ }; 21F09AAA2C60CAF20025AF73 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 214AA9262C778FB70068FD0A /* Config.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -326,7 +330,6 @@ SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3"; TVOS_DEPLOYMENT_TARGET = 17.0; }; @@ -366,7 +369,6 @@ SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3"; TVOS_DEPLOYMENT_TARGET = 17.0; }; diff --git a/Example/AblyChatExample/Config.xcconfig b/Example/AblyChatExample/Config.xcconfig new file mode 100644 index 00000000..d90edaaf --- /dev/null +++ b/Example/AblyChatExample/Config.xcconfig @@ -0,0 +1,3 @@ +// This allows us to specify the Swift version on the command line without affecting the Swift version used for our dependencies (which it appears that specifying SWIFT_VERSION on the command line does). +EXAMPLE_APP_SWIFT_VERSION = 5.0 +SWIFT_VERSION = $(EXAMPLE_APP_SWIFT_VERSION) diff --git a/Package.resolved b/Package.resolved index 18c2c7de..9cffb1ec 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f6b591fa76437494c2609ca1208b8583cbc3debe5e659be06bdcb7bc4bb5e457", + "originHash" : "fcc346d6fe86e610ac200cdbbf91c56204df67286546d5079bd9c610ee65953b", "pins" : [ { "identity" : "ably-cocoa", @@ -36,6 +36,24 @@ "revision" : "41982a3656a71c768319979febd796c6fd111d5c", "version" : "1.5.0" } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms", + "state" : { + "revision" : "6ae9a051f76b81cc668305ceed5b0e0a7fd93d20", + "version" : "1.0.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", + "version" : "1.1.2" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index e00adcc9..bf4b0de9 100644 --- a/Package.swift +++ b/Package.swift @@ -26,6 +26,10 @@ let package = Package( url: "https://github.com/apple/swift-argument-parser", from: "1.5.0" ), + .package( + url: "https://github.com/apple/swift-async-algorithms", + from: "1.0.1" + ), ], targets: [ .target( @@ -64,6 +68,10 @@ let package = Package( name: "ArgumentParser", package: "swift-argument-parser" ), + .product( + name: "AsyncAlgorithms", + package: "swift-async-algorithms" + ), ], swiftSettings: [ // See justification above. diff --git a/Package@swift-6.swift b/Package@swift-6.swift index adbd46f1..538f334a 100644 --- a/Package@swift-6.swift +++ b/Package@swift-6.swift @@ -26,6 +26,10 @@ let package = Package( url: "https://github.com/apple/swift-argument-parser", from: "1.5.0" ), + .package( + url: "https://github.com/apple/swift-async-algorithms", + from: "1.0.1" + ), ], targets: [ .target( @@ -50,6 +54,10 @@ let package = Package( name: "ArgumentParser", package: "swift-argument-parser" ), + .product( + name: "AsyncAlgorithms", + package: "swift-async-algorithms" + ), ] ), ] diff --git a/Sources/BuildTool/BuildTool.swift b/Sources/BuildTool/BuildTool.swift index 159792a9..e66a8455 100644 --- a/Sources/BuildTool/BuildTool.swift +++ b/Sources/BuildTool/BuildTool.swift @@ -19,14 +19,13 @@ struct BuildAndTestLibrary: AsyncParsableCommand { static let configuration = CommandConfiguration(abstract: "Build and test the AblyChat library") @Option var platform: Platform - @Option var swiftVersion: Int mutating func run() async throws { let destinationSpecifier = try await platform.resolve() let scheme = "AblyChat" - try await XcodeRunner.runXcodebuild(action: nil, scheme: scheme, destination: destinationSpecifier, swiftVersion: swiftVersion) - try await XcodeRunner.runXcodebuild(action: "test", scheme: scheme, destination: destinationSpecifier, swiftVersion: swiftVersion) + try await XcodeRunner.runXcodebuild(action: nil, scheme: scheme, destination: destinationSpecifier) + try await XcodeRunner.runXcodebuild(action: "test", scheme: scheme, destination: destinationSpecifier) } } @@ -40,7 +39,7 @@ struct BuildExampleApp: AsyncParsableCommand { mutating func run() async throws { let destinationSpecifier = try await platform.resolve() - try await XcodeRunner.runXcodebuild(action: nil, scheme: "AblyChatExample", destination: destinationSpecifier, swiftVersion: swiftVersion) + try await XcodeRunner.runXcodebuild(action: nil, scheme: "AblyChatExample", destination: destinationSpecifier, exampleAppSwiftVersion: swiftVersion) } } diff --git a/Sources/BuildTool/XcodeRunner.swift b/Sources/BuildTool/XcodeRunner.swift index 9171bd70..d5e70ab3 100644 --- a/Sources/BuildTool/XcodeRunner.swift +++ b/Sources/BuildTool/XcodeRunner.swift @@ -2,7 +2,7 @@ import Foundation @available(macOS 14, *) enum XcodeRunner { - static func runXcodebuild(action: String?, scheme: String, destination: DestinationSpecifier, swiftVersion: Int) async throws { + static func runXcodebuild(action: String?, scheme: String, destination: DestinationSpecifier, exampleAppSwiftVersion: Int? = nil) async throws { var arguments: [String] = [] if let action { @@ -12,10 +12,51 @@ enum XcodeRunner { arguments.append(contentsOf: ["-scheme", scheme]) arguments.append(contentsOf: ["-destination", destination.xcodebuildArgument]) - arguments.append(contentsOf: [ - "SWIFT_TREAT_WARNINGS_AS_ERRORS=YES", - "SWIFT_VERSION=\(swiftVersion)", - ]) + /* + Note: I was previously passing SWIFT_TREAT_WARNINGS_AS_ERRORS=YES here, but am no longer able to do so, for the following reasons: + + 1. After adding a new package dependency, Xcode started trying to pass + the Swift compiler the -suppress-warnings flag when compiling one of + the newly-added transitive dependencies. This clashes with the + -warnings-as-errors flag that Xcode adds when you set + SWIFT_TREAT_WARNINGS_AS_ERRORS=YES, leading to a compiler error like + + > error: Conflicting options '-warnings-as-errors' and '-suppress-warnings' (in target 'InternalCollectionsUtilities' from project 'swift-collections') + + It’s not clear _why_ Xcode is adding this flag (see + https://forums.swift.org/t/warnings-as-errors-in-sub-packages/70810), + but perhaps it’s because of what I mention in point 2 below. + + It seems that there is no way to tell Xcode, when building your own + Swift package, “treat warnings as errors, but only for my package, and + not for its dependencies”. + + 2. So, I thought that I’d try making Xcode remove the + -suppress-warnings flag by additionally passing + SWIFT_SUPPRESS_WARNINGS=NO, but this also doesn’t work because it turns + out that one of our dependencies (swift-async-algorithms) actually does + have some warnings, causing the build to fail. + + tl;dr: There doesn’t seem to be a way to treat warnings as errors when + compiling the package from Package.swift using Xcode. + + It’s probably OK, though, because we also compile the package with SPM, + and hopefully that will flag any warnings in CI (unless there’s some + class of warnings I’m not aware of that only appear when compiling + against the tvOS or iOS SDK). + + (I imagine that using .unsafeFlags(["-warnings-as-errors"]) in the + manifest might work, but then that’d stop other people from being able + to use us as a dependency. I suppose we could, in CI at least, do + something like modifying the manifest as part of the build process, but + that seems like a nuisance.) + */ + + if let exampleAppSwiftVersion { + arguments.append( + "ABLY_EXAMPLE_APP_SWIFT_VERSION=\(exampleAppSwiftVersion)" + ) + } try await ProcessRunner.run(executableName: "xcodebuild", arguments: arguments) } From 721f10c2f41a307dfebaa0d4fe5d2c8db4edb367 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 22 Aug 2024 15:15:00 +0100 Subject: [PATCH 2/2] Implement the Subscription type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Note that XCTAssertEqual doesn’t allow you to write `await` inside its arguments, hence the indirection to get the result of a couple of `async let`s. Hopefully we’ll be able to migrate to Swift Testing at some point, which will resolve this; see #21. I’ve also implemented MessageSubscription by wrapping Subscription. --- Package.swift | 4 + Package@swift-6.swift | 4 + Sources/AblyChat/BufferingPolicy.swift | 11 ++ Sources/AblyChat/Messages.swift | 34 +++++- Sources/AblyChat/Subscription.swift | 105 ++++++++++++++++-- .../MessageSubscriptionTests.swift | 56 ++++++++++ Tests/AblyChatTests/SubscriptionTests.swift | 26 +++++ 7 files changed, 226 insertions(+), 14 deletions(-) create mode 100644 Tests/AblyChatTests/MessageSubscriptionTests.swift create mode 100644 Tests/AblyChatTests/SubscriptionTests.swift diff --git a/Package.swift b/Package.swift index bf4b0de9..b4d59551 100644 --- a/Package.swift +++ b/Package.swift @@ -59,6 +59,10 @@ let package = Package( name: "AblyChatTests", dependencies: [ "AblyChat", + .product( + name: "AsyncAlgorithms", + package: "swift-async-algorithms" + ), ] ), .executableTarget( diff --git a/Package@swift-6.swift b/Package@swift-6.swift index 538f334a..fc233cd2 100644 --- a/Package@swift-6.swift +++ b/Package@swift-6.swift @@ -45,6 +45,10 @@ let package = Package( name: "AblyChatTests", dependencies: [ "AblyChat", + .product( + name: "AsyncAlgorithms", + package: "swift-async-algorithms" + ), ] ), .executableTarget( diff --git a/Sources/AblyChat/BufferingPolicy.swift b/Sources/AblyChat/BufferingPolicy.swift index 110dac3f..b9440903 100644 --- a/Sources/AblyChat/BufferingPolicy.swift +++ b/Sources/AblyChat/BufferingPolicy.swift @@ -4,4 +4,15 @@ public enum BufferingPolicy: Sendable { case unbounded case bufferingOldest(Int) case bufferingNewest(Int) + + internal func asAsyncStreamBufferingPolicy() -> AsyncStream.Continuation.BufferingPolicy { + switch self { + case let .bufferingNewest(count): + .bufferingNewest(count) + case let .bufferingOldest(count): + .bufferingOldest(count) + case .unbounded: + .unbounded + } + } } diff --git a/Sources/AblyChat/Messages.swift b/Sources/AblyChat/Messages.swift index 9f7a6207..ab4b48a9 100644 --- a/Sources/AblyChat/Messages.swift +++ b/Sources/AblyChat/Messages.swift @@ -54,21 +54,43 @@ public struct QueryOptionsWithoutDirection: Sendable { public struct MessageSubscription: Sendable, AsyncSequence { public typealias Element = Message - public init(mockAsyncSequence _: T) where T.Element == Element { - fatalError("Not yet implemented") + private var subscription: Subscription + + private var mockGetPreviousMessages: (@Sendable (QueryOptionsWithoutDirection) async throws -> any PaginatedResult)? + + internal init(bufferingPolicy: BufferingPolicy) { + subscription = .init(bufferingPolicy: bufferingPolicy) + } + + public init(mockAsyncSequence: T, mockGetPreviousMessages: @escaping @Sendable (QueryOptionsWithoutDirection) async throws -> any PaginatedResult) where T.Element == Element { + subscription = .init(mockAsyncSequence: mockAsyncSequence) + self.mockGetPreviousMessages = mockGetPreviousMessages + } + + internal func emit(_ element: Element) { + subscription.emit(element) } - public func getPreviousMessages(params _: QueryOptionsWithoutDirection) async throws -> any PaginatedResult { - fatalError("Not yet implemented") + public func getPreviousMessages(params: QueryOptionsWithoutDirection) async throws -> any PaginatedResult { + guard let mockImplementation = mockGetPreviousMessages else { + fatalError("Not yet implemented") + } + return try await mockImplementation(params) } public struct AsyncIterator: AsyncIteratorProtocol { + private var subscriptionIterator: Subscription.AsyncIterator + + fileprivate init(subscriptionIterator: Subscription.AsyncIterator) { + self.subscriptionIterator = subscriptionIterator + } + public mutating func next() async -> Element? { - fatalError("Not implemented") + await subscriptionIterator.next() } } public func makeAsyncIterator() -> AsyncIterator { - fatalError("Not implemented") + .init(subscriptionIterator: subscription.makeAsyncIterator()) } } diff --git a/Sources/AblyChat/Subscription.swift b/Sources/AblyChat/Subscription.swift index e79b09bc..b07f6a39 100644 --- a/Sources/AblyChat/Subscription.swift +++ b/Sources/AblyChat/Subscription.swift @@ -4,22 +4,111 @@ // // 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. -public struct Subscription: Sendable, AsyncSequence { - // This is a workaround for the fact that, as mentioned above, `Subscription` is a struct when I would have liked it to be a protocol. It allows people mocking our SDK to create a `Subscription` so that they can return it from their mocks. The intention of this initializer is that if you use it, then the created `Subscription` will just replay the sequence that you pass it. - public init(mockAsyncSequence _: T) where T.Element == Element { - fatalError("Not implemented") +// 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. +public struct Subscription: Sendable, AsyncSequence { + private enum Mode: Sendable { + case `default`(stream: AsyncStream, continuation: AsyncStream.Continuation) + case mockAsyncSequence(AnyNonThrowingAsyncSequence) } - // (The below is just necessary boilerplate to get this to compile; the key point is that `next()` does not have a `throws` annotation.) + /// A type-erased AsyncSequence that doesn’t throw any errors. + fileprivate struct AnyNonThrowingAsyncSequence: AsyncSequence, Sendable { + private var makeAsyncIteratorImpl: @Sendable () -> AsyncIterator + + init(asyncSequence: T) where T.Element == Element { + makeAsyncIteratorImpl = { + AsyncIterator(asyncIterator: asyncSequence.makeAsyncIterator()) + } + } + + fileprivate struct AsyncIterator: AsyncIteratorProtocol { + private var nextImpl: () async -> Element? + + init(asyncIterator: T) where T.Element == Element { + var iterator = asyncIterator + nextImpl = { () async -> Element? in + do { + return try await iterator.next() + } catch { + fatalError("The AsyncSequence passed to Subscription.init(mockAsyncSequence:) threw an error: \(error). This is not supported.") + } + } + } + + mutating func next() async -> Element? { + await nextImpl() + } + } + + func makeAsyncIterator() -> AsyncIterator { + makeAsyncIteratorImpl() + } + } + + private let mode: Mode + + internal init(bufferingPolicy: BufferingPolicy) { + let (stream, continuation) = AsyncStream.makeStream(of: Element.self, bufferingPolicy: bufferingPolicy.asAsyncStreamBufferingPolicy()) + mode = .default(stream: stream, continuation: continuation) + } + + // This is a workaround for the fact that, as mentioned above, `Subscription` is a struct when I would have liked it to be a protocol. It allows people mocking our SDK to create a `Subscription` so that they can return it from their mocks. The intention of this initializer is that if you use it, then the created `Subscription` will just replay the sequence that you pass it. It is a programmer error to pass a throwing AsyncSequence. + public init(mockAsyncSequence: T) where T.Element == Element { + mode = .mockAsyncSequence(.init(asyncSequence: mockAsyncSequence)) + } + + /** + Causes the subscription to make a new element available on its `AsyncSequence` interface. + + It is a programmer error to call this when the receiver was created using ``init(mockAsyncSequence:)``. + */ + internal func emit(_ element: Element) { + switch mode { + case let .default(_, continuation): + continuation.yield(element) + case .mockAsyncSequence: + fatalError("`emit` cannot be called on a Subscription that was created using init(mockAsyncSequence:)") + } + } public struct AsyncIterator: AsyncIteratorProtocol { + fileprivate enum Mode { + case `default`(iterator: AsyncStream.AsyncIterator) + case mockAsyncSequence(iterator: AnyNonThrowingAsyncSequence.AsyncIterator) + + mutating func next() async -> Element? { + switch self { + case var .default(iterator: iterator): + let next = await iterator.next() + self = .default(iterator: iterator) + return next + case var .mockAsyncSequence(iterator: iterator): + let next = await iterator.next() + self = .mockAsyncSequence(iterator: iterator) + return next + } + } + } + + private var mode: Mode + + fileprivate init(mode: Mode) { + self.mode = mode + } + public mutating func next() async -> Element? { - fatalError("Not implemented") + await mode.next() } } public func makeAsyncIterator() -> AsyncIterator { - fatalError("Not implemented") + let iteratorMode: AsyncIterator.Mode = switch mode { + case let .default(stream: stream, continuation: _): + .default(iterator: stream.makeAsyncIterator()) + case let .mockAsyncSequence(asyncSequence): + .mockAsyncSequence(iterator: asyncSequence.makeAsyncIterator()) + } + + return .init(mode: iteratorMode) } } diff --git a/Tests/AblyChatTests/MessageSubscriptionTests.swift b/Tests/AblyChatTests/MessageSubscriptionTests.swift new file mode 100644 index 00000000..b002afb6 --- /dev/null +++ b/Tests/AblyChatTests/MessageSubscriptionTests.swift @@ -0,0 +1,56 @@ +@testable import AblyChat +import AsyncAlgorithms +import XCTest + +private final class MockPaginatedResult: PaginatedResult { + var items: [T] { fatalError("Not implemented") } + + var hasNext: Bool { fatalError("Not implemented") } + + var isLast: Bool { fatalError("Not implemented") } + + var next: (any AblyChat.PaginatedResult)? { fatalError("Not implemented") } + + var first: any AblyChat.PaginatedResult { fatalError("Not implemented") } + + var current: any AblyChat.PaginatedResult { fatalError("Not implemented") } + + init() {} +} + +class MessageSubscriptionTests: XCTestCase { + let messages = ["First", "Second"].map { text in + Message(timeserial: "", clientID: "", roomID: "", text: text, createdAt: .init(), metadata: [:], headers: [:]) + } + + func testWithMockAsyncSequence() async { + let subscription = MessageSubscription(mockAsyncSequence: messages.async) { _ in fatalError("Not implemented") } + + async let emittedElements = Array(subscription.prefix(2)) + + let awaitedEmittedElements = await emittedElements + XCTAssertEqual(awaitedEmittedElements.map(\.text), ["First", "Second"]) + } + + func testEmit() async { + let subscription = MessageSubscription(bufferingPolicy: .unbounded) + + async let emittedElements = Array(subscription.prefix(2)) + + subscription.emit(messages[0]) + subscription.emit(messages[1]) + + let awaitedEmittedElements = await emittedElements + XCTAssertEqual(awaitedEmittedElements.map(\.text), ["First", "Second"]) + } + + func testMockGetPreviousMessages() async throws { + let mockPaginatedResult = MockPaginatedResult() + let subscription = MessageSubscription(mockAsyncSequence: [].async) { _ in mockPaginatedResult } + + let result = try await subscription.getPreviousMessages(params: .init()) + // This dance is to avoid the compiler error "Runtime support for parameterized protocol types is only available in iOS 16.0.0 or newer" — casting back to a concrete type seems to avoid this + let resultAsConcreteType = try XCTUnwrap(result as? MockPaginatedResult) + XCTAssertIdentical(resultAsConcreteType, mockPaginatedResult) + } +} diff --git a/Tests/AblyChatTests/SubscriptionTests.swift b/Tests/AblyChatTests/SubscriptionTests.swift new file mode 100644 index 00000000..a3a80d59 --- /dev/null +++ b/Tests/AblyChatTests/SubscriptionTests.swift @@ -0,0 +1,26 @@ +@testable import AblyChat +import AsyncAlgorithms +import XCTest + +class SubscriptionTests: XCTestCase { + func testWithMockAsyncSequence() async { + let subscription = Subscription(mockAsyncSequence: ["First", "Second"].async) + + async let emittedElements = Array(subscription.prefix(2)) + + let awaitedEmittedElements = await emittedElements + XCTAssertEqual(awaitedEmittedElements, ["First", "Second"]) + } + + func testEmit() async { + let subscription = Subscription(bufferingPolicy: .unbounded) + + async let emittedElements = Array(subscription.prefix(2)) + + subscription.emit("First") + subscription.emit("Second") + + let awaitedEmittedElements = await emittedElements + XCTAssertEqual(awaitedEmittedElements, ["First", "Second"]) + } +}