From 4f7dd49a2d57b4106655fc559d9de15fab5301af Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Sun, 28 Sep 2025 10:46:54 +0200 Subject: [PATCH 01/10] Add support for AsyncStream / AsyncSequence for Firestore --- Firestore/Swift/CHANGELOG.md | 5 + .../DocumentReference+AsyncSequence.swift | 51 +++++ .../AsyncAwait/Query+AsyncSequence.swift | 51 +++++ .../Unit/AsyncAwait/AsyncSequenceTests.swift | 183 ++++++++++++++++++ 4 files changed, 290 insertions(+) create mode 100644 Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift create mode 100644 Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift create mode 100644 Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift diff --git a/Firestore/Swift/CHANGELOG.md b/Firestore/Swift/CHANGELOG.md index 742402f80eb..2f38e396ba4 100644 --- a/Firestore/Swift/CHANGELOG.md +++ b/Firestore/Swift/CHANGELOG.md @@ -1,3 +1,8 @@ +# Unreleased +- [added] Added `AsyncSequence` support for `Query.snapshots` and + `DocumentReference.snapshots`, providing a modern, structured-concurrency + alternative to `addSnapshotListener`. + # 10.17.0 - [deprecated] All of the public API from `FirebaseFirestoreSwift` can now be accessed through the `FirebaseFirestore` module. Therefore, diff --git a/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift b/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift new file mode 100644 index 00000000000..4bde70b8c93 --- /dev/null +++ b/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift @@ -0,0 +1,51 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#if SWIFT_PACKAGE + @_exported import FirebaseFirestoreInternalWrapper +#else + @_exported import FirebaseFirestoreInternal +#endif // SWIFT_PACKAGE +import Foundation + +@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) +public extension DocumentReference { + /// An asynchronous sequence of document snapshots. + /// + /// This stream emits a new `DocumentSnapshot` every time the underlying data changes. + var snapshots: AsyncThrowingStream { + return snapshots(includeMetadataChanges: false) + } + + /// An asynchronous sequence of document snapshots. + /// + /// - Parameter includeMetadataChanges: Whether to receive events for metadata-only changes. + /// - Returns: An `AsyncThrowingStream` of `DocumentSnapshot` events. + func snapshots(includeMetadataChanges: Bool) -> AsyncThrowingStream { + return AsyncThrowingStream { continuation in + let listener = self.addSnapshotListener(includeMetadataChanges: includeMetadataChanges) { snapshot, error in + if let error = error { + continuation.finish(throwing: error) + } else if let snapshot = snapshot { + continuation.yield(snapshot) + } + } + continuation.onTermination = { @Sendable _ in + listener.remove() + } + } + } +} diff --git a/Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift b/Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift new file mode 100644 index 00000000000..0d2901ed0d1 --- /dev/null +++ b/Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift @@ -0,0 +1,51 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#if SWIFT_PACKAGE + @_exported import FirebaseFirestoreInternalWrapper +#else + @_exported import FirebaseFirestoreInternal +#endif // SWIFT_PACKAGE +import Foundation + +@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) +public extension Query { + /// An asynchronous sequence of query snapshots. + /// + /// This stream emits a new `QuerySnapshot` every time the underlying data changes. + var snapshots: AsyncThrowingStream { + return snapshots(includeMetadataChanges: false) + } + + /// An asynchronous sequence of query snapshots. + /// + /// - Parameter includeMetadataChanges: Whether to receive events for metadata-only changes. + /// - Returns: An `AsyncThrowingStream` of `QuerySnapshot` events. + func snapshots(includeMetadataChanges: Bool) -> AsyncThrowingStream { + return AsyncThrowingStream { continuation in + let listener = self.addSnapshotListener(includeMetadataChanges: includeMetadataChanges) { snapshot, error in + if let error = error { + continuation.finish(throwing: error) + } else if let snapshot = snapshot { + continuation.yield(snapshot) + } + } + continuation.onTermination = { @Sendable _ in + listener.remove() + } + } + } +} diff --git a/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift b/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift new file mode 100644 index 00000000000..ae1a9d9b159 --- /dev/null +++ b/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift @@ -0,0 +1,183 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import XCTest +@testable import FirebaseFirestore + +// MARK: - Mock Objects for Testing + +private class MockListenerRegistration: ListenerRegistration { + var isRemoved = false + func remove() { + isRemoved = true + } +} + +private typealias SnapshotListener = (QuerySnapshot?, Error?) -> Void +private typealias DocumentSnapshotListener = (DocumentSnapshot?, Error?) -> Void + +private class MockQuery: Query { + var capturedListener: SnapshotListener? + let mockListenerRegistration = MockListenerRegistration() + + override func addSnapshotListener( + _ listener: @escaping SnapshotListener + ) -> ListenerRegistration { + capturedListener = listener + return mockListenerRegistration + } + + override func addSnapshotListener( + includeMetadataChanges: Bool, + listener: @escaping SnapshotListener + ) -> ListenerRegistration { + capturedListener = listener + return mockListenerRegistration + } +} + +private class MockDocumentReference: DocumentReference { + var capturedListener: DocumentSnapshotListener? + let mockListenerRegistration = MockListenerRegistration() + + override func addSnapshotListener( + _ listener: @escaping DocumentSnapshotListener + ) -> ListenerRegistration { + capturedListener = listener + return mockListenerRegistration + } + + override func addSnapshotListener( + includeMetadataChanges: Bool, + listener: @escaping DocumentSnapshotListener + ) -> ListenerRegistration { + capturedListener = listener + return mockListenerRegistration + } +} + +// MARK: - AsyncSequenceTests + +@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) +class AsyncSequenceTests: XCTestCase { + func testQuerySnapshotsYieldsValues() async throws { + let mockQuery = MockQuery() + let expectation = XCTestExpectation(description: "Received snapshot") + + let task = Task { + for try await _ in mockQuery.snapshots { + expectation.fulfill() + break // Exit after first result + } + } + + // Ensure the listener has been set up + XCTAssertNotNil(mockQuery.capturedListener) + + // Simulate a snapshot event + mockQuery.capturedListener?(QuerySnapshot(), nil) + + await fulfillment(of: [expectation], timeout: 1.0) + task.cancel() + } + + func testQuerySnapshotsThrowsErrors() async throws { + let mockQuery = MockQuery() + let expectedError = NSError(domain: "TestError", code: 123, userInfo: nil) + var receivedError: Error? + + let task = Task { + do { + for try await _ in mockQuery.snapshots { + XCTFail("Should not have received a value.") + } + } catch { + receivedError = error + } + } + + // Ensure the listener has been set up + XCTAssertNotNil(mockQuery.capturedListener) + + // Simulate an error event + mockQuery.capturedListener?(nil, expectedError) + + // Allow the task to process the error + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertNotNil(receivedError) + XCTAssertEqual((receivedError as NSError?)?.domain, expectedError.domain) + XCTAssertEqual((receivedError as NSError?)?.code, expectedError.code) + task.cancel() + } + + func testQuerySnapshotsCancellationRemovesListener() async throws { + let mockQuery = MockQuery() + + let task = Task { + for try await _ in mockQuery.snapshots { + XCTFail("Should not receive any values as the task is cancelled immediately.") + } + } + + // Ensure the listener was attached before we cancel + XCTAssertNotNil(mockQuery.capturedListener) + XCTAssertFalse(mockQuery.mockListenerRegistration.isRemoved) + + task.cancel() + + // Allow time for the cancellation handler to execute + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertTrue(mockQuery.mockListenerRegistration.isRemoved) + } + + func testDocumentReferenceSnapshotsYieldsValues() async throws { + let mockDocRef = MockDocumentReference() + let expectation = XCTestExpectation(description: "Received document snapshot") + + let task = Task { + for try await _ in mockDocRef.snapshots { + expectation.fulfill() + break + } + } + + XCTAssertNotNil(mockDocRef.capturedListener) + mockDocRef.capturedListener?(DocumentSnapshot(), nil) + + await fulfillment(of: [expectation], timeout: 1.0) + task.cancel() + } + + func testDocumentReferenceSnapshotsCancellationRemovesListener() async throws { + let mockDocRef = MockDocumentReference() + + let task = Task { + for try await _ in mockDocRef.snapshots { + XCTFail("Should not receive values.") + } + } + + XCTAssertNotNil(mockDocRef.capturedListener) + XCTAssertFalse(mockDocRef.mockListenerRegistration.isRemoved) + + task.cancel() + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertTrue(mockDocRef.mockListenerRegistration.isRemoved) + } +} \ No newline at end of file From 3c84e74542f5aa646d5a75b9f963ad5954e27374 Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Sun, 28 Sep 2025 16:33:57 +0200 Subject: [PATCH 02/10] Add AsyncSequence support for Firestore This change introduces support for `AsyncSequence` to `DocumentReference` and `Query`, allowing developers to use `for await in` to receive real-time updates from Firestore. The implementation wraps the existing snapshot listener APIs in an `AsyncThrowingStream`, providing a modern, Swift-native way to work with real-time data. This change also includes unit tests for the new functionality. --- .../DocumentReference+AsyncSequence.swift | 13 ++++----- .../AsyncAwait/Query+AsyncSequence.swift | 13 ++++----- .../Unit/AsyncAwait/AsyncSequenceTests.swift | 27 ++++++++----------- 3 files changed, 25 insertions(+), 28 deletions(-) diff --git a/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift b/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift index 4bde70b8c93..3ecf5d5203c 100644 --- a/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift +++ b/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift @@ -36,13 +36,14 @@ public extension DocumentReference { /// - Returns: An `AsyncThrowingStream` of `DocumentSnapshot` events. func snapshots(includeMetadataChanges: Bool) -> AsyncThrowingStream { return AsyncThrowingStream { continuation in - let listener = self.addSnapshotListener(includeMetadataChanges: includeMetadataChanges) { snapshot, error in - if let error = error { - continuation.finish(throwing: error) - } else if let snapshot = snapshot { - continuation.yield(snapshot) + let listener = self + .addSnapshotListener(includeMetadataChanges: includeMetadataChanges) { snapshot, error in + if let error = error { + continuation.finish(throwing: error) + } else if let snapshot = snapshot { + continuation.yield(snapshot) + } } - } continuation.onTermination = { @Sendable _ in listener.remove() } diff --git a/Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift b/Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift index 0d2901ed0d1..c63181af998 100644 --- a/Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift +++ b/Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift @@ -36,13 +36,14 @@ public extension Query { /// - Returns: An `AsyncThrowingStream` of `QuerySnapshot` events. func snapshots(includeMetadataChanges: Bool) -> AsyncThrowingStream { return AsyncThrowingStream { continuation in - let listener = self.addSnapshotListener(includeMetadataChanges: includeMetadataChanges) { snapshot, error in - if let error = error { - continuation.finish(throwing: error) - } else if let snapshot = snapshot { - continuation.yield(snapshot) + let listener = self + .addSnapshotListener(includeMetadataChanges: includeMetadataChanges) { snapshot, error in + if let error = error { + continuation.finish(throwing: error) + } else if let snapshot = snapshot { + continuation.yield(snapshot) + } } - } continuation.onTermination = { @Sendable _ in listener.remove() } diff --git a/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift b/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift index ae1a9d9b159..10e32d2efde 100644 --- a/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift +++ b/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift @@ -14,8 +14,8 @@ * limitations under the License. */ -import XCTest @testable import FirebaseFirestore +import XCTest // MARK: - Mock Objects for Testing @@ -33,17 +33,14 @@ private class MockQuery: Query { var capturedListener: SnapshotListener? let mockListenerRegistration = MockListenerRegistration() - override func addSnapshotListener( - _ listener: @escaping SnapshotListener - ) -> ListenerRegistration { + override func addSnapshotListener(_ listener: @escaping SnapshotListener) + -> ListenerRegistration { capturedListener = listener return mockListenerRegistration } - override func addSnapshotListener( - includeMetadataChanges: Bool, - listener: @escaping SnapshotListener - ) -> ListenerRegistration { + override func addSnapshotListener(includeMetadataChanges: Bool, + listener: @escaping SnapshotListener) -> ListenerRegistration { capturedListener = listener return mockListenerRegistration } @@ -53,17 +50,15 @@ private class MockDocumentReference: DocumentReference { var capturedListener: DocumentSnapshotListener? let mockListenerRegistration = MockListenerRegistration() - override func addSnapshotListener( - _ listener: @escaping DocumentSnapshotListener - ) -> ListenerRegistration { + override func addSnapshotListener(_ listener: @escaping DocumentSnapshotListener) + -> ListenerRegistration { capturedListener = listener return mockListenerRegistration } - override func addSnapshotListener( - includeMetadataChanges: Bool, - listener: @escaping DocumentSnapshotListener - ) -> ListenerRegistration { + override func addSnapshotListener(includeMetadataChanges: Bool, + listener: @escaping DocumentSnapshotListener) + -> ListenerRegistration { capturedListener = listener return mockListenerRegistration } @@ -180,4 +175,4 @@ class AsyncSequenceTests: XCTestCase { XCTAssertTrue(mockDocRef.mockListenerRegistration.isRemoved) } -} \ No newline at end of file +} From 86aea724ff64c469cb03922fc24154349b80b378 Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Tue, 30 Sep 2025 12:03:22 +0200 Subject: [PATCH 03/10] Update copyright years --- .../Source/AsyncAwait/DocumentReference+AsyncSequence.swift | 2 +- Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift | 2 +- Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift b/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift index 3ecf5d5203c..08f6c65e9f9 100644 --- a/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift +++ b/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift @@ -1,5 +1,5 @@ /* - * Copyright 2024 Google LLC + * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift b/Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift index c63181af998..5f759ae656f 100644 --- a/Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift +++ b/Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift @@ -1,5 +1,5 @@ /* - * Copyright 2024 Google LLC + * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift b/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift index 10e32d2efde..8f7080485e8 100644 --- a/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift +++ b/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift @@ -1,5 +1,5 @@ /* - * Copyright 2024 Google LLC + * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. From cd48b85c13e48d16b5e158cf8b2b5159a41a80eb Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Wed, 1 Oct 2025 06:13:10 +0200 Subject: [PATCH 04/10] Return stream as an AsyncSequence --- .../Source/AsyncAwait/DocumentReference+AsyncSequence.swift | 6 ++++-- Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift b/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift index 08f6c65e9f9..1cb4ad2ca2c 100644 --- a/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift +++ b/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift @@ -26,7 +26,8 @@ public extension DocumentReference { /// An asynchronous sequence of document snapshots. /// /// This stream emits a new `DocumentSnapshot` every time the underlying data changes. - var snapshots: AsyncThrowingStream { + @available(iOS 18.0, *) + var snapshots: some AsyncSequence { return snapshots(includeMetadataChanges: false) } @@ -34,7 +35,8 @@ public extension DocumentReference { /// /// - Parameter includeMetadataChanges: Whether to receive events for metadata-only changes. /// - Returns: An `AsyncThrowingStream` of `DocumentSnapshot` events. - func snapshots(includeMetadataChanges: Bool) -> AsyncThrowingStream { + @available(iOS 18.0, *) + func snapshots(includeMetadataChanges: Bool) -> some AsyncSequence { return AsyncThrowingStream { continuation in let listener = self .addSnapshotListener(includeMetadataChanges: includeMetadataChanges) { snapshot, error in diff --git a/Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift b/Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift index 5f759ae656f..0cbc3487085 100644 --- a/Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift +++ b/Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift @@ -26,7 +26,8 @@ public extension Query { /// An asynchronous sequence of query snapshots. /// /// This stream emits a new `QuerySnapshot` every time the underlying data changes. - var snapshots: AsyncThrowingStream { + @available(iOS 18.0, *) + var snapshots: some AsyncSequence { return snapshots(includeMetadataChanges: false) } @@ -34,7 +35,8 @@ public extension Query { /// /// - Parameter includeMetadataChanges: Whether to receive events for metadata-only changes. /// - Returns: An `AsyncThrowingStream` of `QuerySnapshot` events. - func snapshots(includeMetadataChanges: Bool) -> AsyncThrowingStream { + @available(iOS 18.0, *) + func snapshots(includeMetadataChanges: Bool) -> some AsyncSequence { return AsyncThrowingStream { continuation in let listener = self .addSnapshotListener(includeMetadataChanges: includeMetadataChanges) { snapshot, error in From d1b23276466b73666930203bbfc48273dec6f96d Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Thu, 2 Oct 2025 23:13:13 +0100 Subject: [PATCH 05/10] Update availability specification --- .../Source/AsyncAwait/DocumentReference+AsyncSequence.swift | 4 ++-- .../Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift b/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift index 1cb4ad2ca2c..4edfa2e1d42 100644 --- a/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift +++ b/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift @@ -26,7 +26,7 @@ public extension DocumentReference { /// An asynchronous sequence of document snapshots. /// /// This stream emits a new `DocumentSnapshot` every time the underlying data changes. - @available(iOS 18.0, *) + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) var snapshots: some AsyncSequence { return snapshots(includeMetadataChanges: false) } @@ -35,7 +35,7 @@ public extension DocumentReference { /// /// - Parameter includeMetadataChanges: Whether to receive events for metadata-only changes. /// - Returns: An `AsyncThrowingStream` of `DocumentSnapshot` events. - @available(iOS 18.0, *) + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) func snapshots(includeMetadataChanges: Bool) -> some AsyncSequence { return AsyncThrowingStream { continuation in let listener = self diff --git a/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift b/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift index 8f7080485e8..c5bf0c44128 100644 --- a/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift +++ b/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift @@ -66,7 +66,7 @@ private class MockDocumentReference: DocumentReference { // MARK: - AsyncSequenceTests -@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) class AsyncSequenceTests: XCTestCase { func testQuerySnapshotsYieldsValues() async throws { let mockQuery = MockQuery() From 5cb5b4310f9b3902a6ad52c0a0e6c8fdf7edeec9 Mon Sep 17 00:00:00 2001 From: peterfriese Date: Wed, 12 Nov 2025 12:10:24 +0100 Subject: [PATCH 06/10] Use concrete AsyncSequence types for snapshots --- .../DocumentReference+AsyncSequence.swift | 94 +++++++++++++--- .../AsyncAwait/Query+AsyncSequence.swift | 100 +++++++++++++++--- 2 files changed, 168 insertions(+), 26 deletions(-) diff --git a/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift b/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift index 4edfa2e1d42..24e035dc14e 100644 --- a/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift +++ b/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift @@ -27,28 +27,98 @@ public extension DocumentReference { /// /// This stream emits a new `DocumentSnapshot` every time the underlying data changes. @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) - var snapshots: some AsyncSequence { + var snapshots: DocumentSnapshotsSequence { return snapshots(includeMetadataChanges: false) } /// An asynchronous sequence of document snapshots. /// /// - Parameter includeMetadataChanges: Whether to receive events for metadata-only changes. - /// - Returns: An `AsyncThrowingStream` of `DocumentSnapshot` events. + /// - Returns: A `DocumentSnapshotsSequence` of `DocumentSnapshot` events. @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) - func snapshots(includeMetadataChanges: Bool) -> some AsyncSequence { - return AsyncThrowingStream { continuation in - let listener = self - .addSnapshotListener(includeMetadataChanges: includeMetadataChanges) { snapshot, error in - if let error = error { - continuation.finish(throwing: error) - } else if let snapshot = snapshot { - continuation.yield(snapshot) + func snapshots(includeMetadataChanges: Bool) -> DocumentSnapshotsSequence { + return DocumentSnapshotsSequence(self, includeMetadataChanges: includeMetadataChanges) + } + + /// An `AsyncSequence` that emits `DocumentSnapshot` values whenever the document data changes. + /// + /// This struct is the concrete type returned by the `DocumentReference.snapshots` property. + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + @frozen + struct DocumentSnapshotsSequence: AsyncSequence, Sendable { + public typealias Element = DocumentSnapshot + public typealias Failure = Error + public typealias AsyncIterator = Iterator + + @usableFromInline + internal let documentReference: DocumentReference + @usableFromInline + internal let includeMetadataChanges: Bool + + /// Creates a new sequence for monitoring document snapshots. + /// - Parameters: + /// - documentReference: The `DocumentReference` instance to monitor. + /// - includeMetadataChanges: Whether to receive events for metadata-only changes. + @inlinable + public init(_ documentReference: DocumentReference, includeMetadataChanges: Bool) { + self.documentReference = documentReference + self.includeMetadataChanges = includeMetadataChanges + } + + /// Creates and returns an iterator for this asynchronous sequence. + /// - Returns: An `Iterator` for `DocumentSnapshotsSequence`. + @inlinable + public func makeAsyncIterator() -> Iterator { + Iterator(documentReference: documentReference, includeMetadataChanges: includeMetadataChanges) + } + + /// The asynchronous iterator for `DocumentSnapshotsSequence`. + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + @frozen + public struct Iterator: AsyncIteratorProtocol { + public typealias Element = DocumentSnapshot + @usableFromInline + internal let stream: AsyncThrowingStream + @usableFromInline + internal var streamIterator: AsyncThrowingStream.Iterator + + /// Initializes the iterator with the provided `DocumentReference` instance. + /// This sets up the `AsyncThrowingStream` and registers the necessary listener. + /// - Parameters: + /// - documentReference: The `DocumentReference` instance to monitor. + /// - includeMetadataChanges: Whether to receive events for metadata-only changes. + @inlinable + init(documentReference: DocumentReference, includeMetadataChanges: Bool) { + stream = AsyncThrowingStream { continuation in + let listener = documentReference + .addSnapshotListener(includeMetadataChanges: includeMetadataChanges) { snapshot, error in + if let error = error { + continuation.finish(throwing: error) + } else if let snapshot = snapshot { + continuation.yield(snapshot) + } + } + + continuation.onTermination = { @Sendable _ in + listener.remove() } } - continuation.onTermination = { @Sendable _ in - listener.remove() + streamIterator = stream.makeAsyncIterator() + } + + /// Produces the next element in the asynchronous sequence. + /// + /// Returns a `DocumentSnapshot` value or `nil` if the sequence has terminated. + /// Throws an error if the underlying listener encounters an issue. + /// - Returns: An optional `DocumentSnapshot` object. + @inlinable + public mutating func next() async throws -> Element? { + try await streamIterator.next() } } } } + +// Explicitly mark the Iterator as unavailable for Sendable conformance +@available(*, unavailable) +extension DocumentReference.DocumentSnapshotsSequence.Iterator: Sendable {} diff --git a/Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift b/Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift index 0cbc3487085..ae1b58dd04f 100644 --- a/Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift +++ b/Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift @@ -26,29 +26,101 @@ public extension Query { /// An asynchronous sequence of query snapshots. /// /// This stream emits a new `QuerySnapshot` every time the underlying data changes. - @available(iOS 18.0, *) - var snapshots: some AsyncSequence { + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + var snapshots: QuerySnapshotsSequence { return snapshots(includeMetadataChanges: false) } /// An asynchronous sequence of query snapshots. /// /// - Parameter includeMetadataChanges: Whether to receive events for metadata-only changes. - /// - Returns: An `AsyncThrowingStream` of `QuerySnapshot` events. - @available(iOS 18.0, *) - func snapshots(includeMetadataChanges: Bool) -> some AsyncSequence { - return AsyncThrowingStream { continuation in - let listener = self - .addSnapshotListener(includeMetadataChanges: includeMetadataChanges) { snapshot, error in - if let error = error { - continuation.finish(throwing: error) - } else if let snapshot = snapshot { - continuation.yield(snapshot) + /// - Returns: A `QuerySnapshotsSequence` of `QuerySnapshot` events. + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func snapshots(includeMetadataChanges: Bool) -> QuerySnapshotsSequence { + return QuerySnapshotsSequence(self, includeMetadataChanges: includeMetadataChanges) + } + + /// An `AsyncSequence` that emits `QuerySnapshot` values whenever the query data changes. + /// + /// This struct is the concrete type returned by the `Query.snapshots` property. + /// + /// - Important: This type is marked `Sendable` because `Query` itself is `Sendable`. + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + @frozen + struct QuerySnapshotsSequence: AsyncSequence, Sendable { + public typealias Element = QuerySnapshot + public typealias Failure = Error + public typealias AsyncIterator = Iterator + + @usableFromInline + internal let query: Query + @usableFromInline + internal let includeMetadataChanges: Bool + + /// Creates a new sequence for monitoring query snapshots. + /// - Parameters: + /// - query: The `Query` instance to monitor. + /// - includeMetadataChanges: Whether to receive events for metadata-only changes. + @inlinable + public init(_ query: Query, includeMetadataChanges: Bool) { + self.query = query + self.includeMetadataChanges = includeMetadataChanges + } + + /// Creates and returns an iterator for this asynchronous sequence. + /// - Returns: An `Iterator` for `QuerySnapshotsSequence`. + @inlinable + public func makeAsyncIterator() -> Iterator { + Iterator(query: query, includeMetadataChanges: includeMetadataChanges) + } + + /// The asynchronous iterator for `QuerySnapshotsSequence`. + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + @frozen + public struct Iterator: AsyncIteratorProtocol { + public typealias Element = QuerySnapshot + @usableFromInline + internal let stream: AsyncThrowingStream + @usableFromInline + internal var streamIterator: AsyncThrowingStream.Iterator + + /// Initializes the iterator with the provided `Query` instance. + /// This sets up the `AsyncThrowingStream` and registers the necessary listener. + /// - Parameters: + /// - query: The `Query` instance to monitor. + /// - includeMetadataChanges: Whether to receive events for metadata-only changes. + @inlinable + init(query: Query, includeMetadataChanges: Bool) { + stream = AsyncThrowingStream { continuation in + let listener = query + .addSnapshotListener(includeMetadataChanges: includeMetadataChanges) { snapshot, error in + if let error = error { + continuation.finish(throwing: error) + } else if let snapshot = snapshot { + continuation.yield(snapshot) + } + } + + continuation.onTermination = { @Sendable _ in + listener.remove() } } - continuation.onTermination = { @Sendable _ in - listener.remove() + streamIterator = stream.makeAsyncIterator() + } + + /// Produces the next element in the asynchronous sequence. + /// + /// Returns a `QuerySnapshot` value or `nil` if the sequence has terminated. + /// Throws an error if the underlying listener encounters an issue. + /// - Returns: An optional `QuerySnapshot` object. + @inlinable + public mutating func next() async throws -> Element? { + try await streamIterator.next() } } } } + +// Explicitly mark the Iterator as unavailable for Sendable conformance +@available(*, unavailable) +extension Query.QuerySnapshotsSequence.Iterator: Sendable {} From 68e32295f6079d7f2cd177e9baa8fd4d27427b39 Mon Sep 17 00:00:00 2001 From: peterfriese Date: Wed, 12 Nov 2025 12:10:43 +0100 Subject: [PATCH 07/10] Tests WIP More tests WIP Tests WIP. This one seems to work. Migrate to Swift Testing Fix handing tests Fix tests --- .../Unit/AsyncAwait/AsyncSequenceTests.swift | 323 +++++++++++------- Package.swift | 12 + 2 files changed, 208 insertions(+), 127 deletions(-) diff --git a/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift b/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift index c5bf0c44128..908ab2457c3 100644 --- a/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift +++ b/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift @@ -1,178 +1,247 @@ -/* - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Testing +import FirebaseCore @testable import FirebaseFirestore -import XCTest - -// MARK: - Mock Objects for Testing - -private class MockListenerRegistration: ListenerRegistration { - var isRemoved = false - func remove() { - isRemoved = true - } -} -private typealias SnapshotListener = (QuerySnapshot?, Error?) -> Void -private typealias DocumentSnapshotListener = (DocumentSnapshot?, Error?) -> Void +private final class AssociationKey: Sendable {} -private class MockQuery: Query { - var capturedListener: SnapshotListener? - let mockListenerRegistration = MockListenerRegistration() +/// A wrapper to safely pass a non-Sendable closure to a Sendable context. +/// +/// This is safe in this specific test because the listener closure originates from +/// an `AsyncStream` continuation, which is guaranteed to be thread-safe. +private final class SendableListenerWrapper: @unchecked Sendable { + let listener: (QuerySnapshot?, Error?) -> Void - override func addSnapshotListener(_ listener: @escaping SnapshotListener) - -> ListenerRegistration { - capturedListener = listener - return mockListenerRegistration - } - - override func addSnapshotListener(includeMetadataChanges: Bool, - listener: @escaping SnapshotListener) -> ListenerRegistration { - capturedListener = listener - return mockListenerRegistration + init(_ listener: @escaping (QuerySnapshot?, Error?) -> Void) { + self.listener = listener } } -private class MockDocumentReference: DocumentReference { - var capturedListener: DocumentSnapshotListener? - let mockListenerRegistration = MockListenerRegistration() +@Suite("Query AsyncSequence Tests") +struct AsyncSequenceTests { + fileprivate static let associationKey = AssociationKey() + + // This static property handles the one-time setup for FirebaseApp. + @MainActor + private static let firebaseApp: FirebaseApp = { + let options = FirebaseOptions(googleAppID: "1:1234567890:ios:abcdef", + gcmSenderID: "1234s567890") + options.projectID = "Firestore-Testing-Project" + FirebaseApp.configure(options: options) + return FirebaseApp.app()! + }() + + // Swizzling is managed by this helper struct using a simple RAII pattern. + // The swizzling is active for the lifetime of the struct instance. + private struct Swizzler: ~Copyable { + init() { + Self.swizzle( + Query.self, + originalSelector: #selector(Query.addSnapshotListener(includeMetadataChanges:listener:)), + swizzledSelector: #selector(Query.swizzled_addSnapshotListener(includeMetadataChanges:listener:)) + ) + } - override func addSnapshotListener(_ listener: @escaping DocumentSnapshotListener) - -> ListenerRegistration { - capturedListener = listener - return mockListenerRegistration - } + deinit { + Self.swizzle( + Query.self, + originalSelector: #selector(Query.swizzled_addSnapshotListener(includeMetadataChanges:listener:)), + swizzledSelector: #selector(Query.addSnapshotListener(includeMetadataChanges:listener:)) + ) + } - override func addSnapshotListener(includeMetadataChanges: Bool, - listener: @escaping DocumentSnapshotListener) - -> ListenerRegistration { - capturedListener = listener - return mockListenerRegistration + private static func swizzle(_ cls: AnyClass, originalSelector: Selector, swizzledSelector: Selector) { + guard let originalMethod = class_getInstanceMethod(cls, originalSelector), + let swizzledMethod = class_getInstanceMethod(cls, swizzledSelector) else { + #expect(false, "Failed to get methods for swizzling") + return + } + method_exchangeImplementations(originalMethod, swizzledMethod) + } } -} - -// MARK: - AsyncSequenceTests -@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) -class AsyncSequenceTests: XCTestCase { - func testQuerySnapshotsYieldsValues() async throws { - let mockQuery = MockQuery() - let expectation = XCTestExpectation(description: "Received snapshot") + @available(iOS 18.0, *) + @MainActor + @Test("Stream handles cancellation correctly") + func test_snapshotStream_handlesCancellationCorrectly() async throws { + // Ensure Firebase is configured before swizzling, as interacting with the + // Query class can trigger SDK initialization that requires a configured app. + let app = Self.firebaseApp + let swizzler = Swizzler() + defer { withExtendedLifetime(swizzler) {} } + + let actor = TestStateActor() + let query = Firestore.firestore(app: app) + .collection("test-\(UUID().uuidString)") + let key = Unmanaged.passUnretained(Self.associationKey).toOpaque() + objc_setAssociatedObject(query, key, actor, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) let task = Task { - for try await _ in mockQuery.snapshots { - expectation.fulfill() - break // Exit after first result + for try await _ in query.snapshots { + // Do nothing } } - // Ensure the listener has been set up - XCTAssertNotNil(mockQuery.capturedListener) - - // Simulate a snapshot event - mockQuery.capturedListener?(QuerySnapshot(), nil) - - await fulfillment(of: [expectation], timeout: 1.0) + await actor.waitForListenerSetup() task.cancel() + await actor.waitForListenerRemoval() } - func testQuerySnapshotsThrowsErrors() async throws { - let mockQuery = MockQuery() - let expectedError = NSError(domain: "TestError", code: 123, userInfo: nil) - var receivedError: Error? + @available(iOS 18.0, *) + @MainActor + @Test("Stream propagates errors") + func test_snapshotStream_propagatesErrors() async throws { + // Ensure Firebase is configured before swizzling, as interacting with the + // Query class can trigger SDK initialization that requires a configured app. + let app = Self.firebaseApp + let swizzler = Swizzler() + defer { withExtendedLifetime(swizzler) {} } + + let actor = TestStateActor() + let query = Firestore.firestore(app: app) + .collection("test-\(UUID().uuidString)") + let key = Unmanaged.passUnretained(Self.associationKey).toOpaque() + objc_setAssociatedObject(query, key, actor, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) let task = Task { do { - for try await _ in mockQuery.snapshots { - XCTFail("Should not have received a value.") - } + for try await _ in query.snapshots {} + throw "Stream did not throw" } catch { - receivedError = error + return error } } - // Ensure the listener has been set up - XCTAssertNotNil(mockQuery.capturedListener) - - // Simulate an error event - mockQuery.capturedListener?(nil, expectedError) + await actor.waitForListenerSetup() + await actor.invokeListener(withSnapshot: nil, error: TestError.mockError) - // Allow the task to process the error - try await Task.sleep(nanoseconds: 100_000_000) - - XCTAssertNotNil(receivedError) - XCTAssertEqual((receivedError as NSError?)?.domain, expectedError.domain) - XCTAssertEqual((receivedError as NSError?)?.code, expectedError.code) + let caughtError = await task.value + #expect(caughtError as? TestError == .mockError) task.cancel() } - func testQuerySnapshotsCancellationRemovesListener() async throws { - let mockQuery = MockQuery() + @available(iOS 18.0, *) + @MainActor + @Test("Stream handles (nil, nil) events gracefully") + func test_snapshotStream_handlesNilSnapshotAndNilErrorGracefully() async throws { + // Ensure Firebase is configured before swizzling, as interacting with the + // Query class can trigger SDK initialization that requires a configured app. + let app = Self.firebaseApp + let swizzler = Swizzler() + defer { withExtendedLifetime(swizzler) {} } + + let actor = TestStateActor() + let query = Firestore.firestore(app: app) + .collection("test-\(UUID().uuidString)") + let key = Unmanaged.passUnretained(Self.associationKey).toOpaque() + objc_setAssociatedObject(query, key, actor, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) let task = Task { - for try await _ in mockQuery.snapshots { - XCTFail("Should not receive any values as the task is cancelled immediately.") + for try await _ in query.snapshots { + #expect(false, "The stream should not have produced any values.") } } - // Ensure the listener was attached before we cancel - XCTAssertNotNil(mockQuery.capturedListener) - XCTAssertFalse(mockQuery.mockListenerRegistration.isRemoved) - + await actor.waitForListenerSetup() + await actor.invokeListener(withSnapshot: nil, error: nil) task.cancel() + await actor.waitForListenerRemoval() - // Allow time for the cancellation handler to execute - try await Task.sleep(nanoseconds: 100_000_000) - - XCTAssertTrue(mockQuery.mockListenerRegistration.isRemoved) + // Awaiting the task will rethrow a CancellationError, which is expected + // and handled by the `throws` on the test function. + try await task.value } +} + +private enum TestError: Error, Equatable { + case mockError +} - func testDocumentReferenceSnapshotsYieldsValues() async throws { - let mockDocRef = MockDocumentReference() - let expectation = XCTestExpectation(description: "Received document snapshot") +// We can finally use a real actor, which is much safer and cleaner. +private actor TestStateActor { + private var capturedListenerWrapper: SendableListenerWrapper? + private var listenerSetupContinuation: CheckedContinuation? + private var listenerRemovedContinuation: CheckedContinuation? - let task = Task { - for try await _ in mockDocRef.snapshots { - expectation.fulfill() - break - } + private var hasSetUpListener = false + private var hasRemovedListener = false + + func waitForListenerSetup() async { + if hasSetUpListener { return } + await withCheckedContinuation { continuation in + self.listenerSetupContinuation = continuation } + } - XCTAssertNotNil(mockDocRef.capturedListener) - mockDocRef.capturedListener?(DocumentSnapshot(), nil) + func waitForListenerRemoval() async { + if hasRemovedListener { return } + await withCheckedContinuation { continuation in + self.listenerRemovedContinuation = continuation + } + } - await fulfillment(of: [expectation], timeout: 1.0) - task.cancel() + func listenerDidSetUp(wrapper: SendableListenerWrapper) { + capturedListenerWrapper = wrapper + hasSetUpListener = true + listenerSetupContinuation?.resume() + listenerSetupContinuation = nil } - func testDocumentReferenceSnapshotsCancellationRemovesListener() async throws { - let mockDocRef = MockDocumentReference() + func listenerDidRemove() { + hasRemovedListener = true + listenerRemovedContinuation?.resume() + listenerRemovedContinuation = nil + } - let task = Task { - for try await _ in mockDocRef.snapshots { - XCTFail("Should not receive values.") - } - } + func invokeListener(withSnapshot snapshot: QuerySnapshot?, error: Error?) { + capturedListenerWrapper?.listener(snapshot, error) + } +} - XCTAssertNotNil(mockDocRef.capturedListener) - XCTAssertFalse(mockDocRef.mockListenerRegistration.isRemoved) +private final class MockListenerRegistration: NSObject, ListenerRegistration { + private var actor: TestStateActor - task.cancel() - try await Task.sleep(nanoseconds: 100_000_000) + init(actor: TestStateActor) { + self.actor = actor + } - XCTAssertTrue(mockDocRef.mockListenerRegistration.isRemoved) + func remove() { + let actor = self.actor + Task { + await actor.listenerDidRemove() + } } } + +extension Query { + @objc func swizzled_addSnapshotListener( + includeMetadataChanges: Bool, + listener: @escaping (QuerySnapshot?, Error?) -> Void + ) -> ListenerRegistration { + let key = Unmanaged.passUnretained(AsyncSequenceTests.associationKey).toOpaque() + let actor = objc_getAssociatedObject(self, key) as! TestStateActor + let registration = MockListenerRegistration(actor: actor) + let wrapper = SendableListenerWrapper(listener) + Task { + await actor.listenerDidSetUp(wrapper: wrapper) + } + return registration + } +} + +extension String: Error {} diff --git a/Package.swift b/Package.swift index 3bcbca83686..b649d08d375 100644 --- a/Package.swift +++ b/Package.swift @@ -1552,6 +1552,18 @@ func firestoreTargets() -> [Target] { .swiftLanguageMode(SwiftLanguageMode.v5), ] ), + .testTarget( + name: "FirebaseFirestoreTests", + dependencies: [ + "Firebase", + "FirebaseCore", + "FirebaseFirestoreTarget" + ], + path: "Firestore/Swift/Tests/Unit", + cSettings: [ + .headerSearchPath("../../../"), + ] + ), ] } From 8b6ba6abe2dc80e33fadb1aabd7090e1f92cc988 Mon Sep 17 00:00:00 2001 From: peterfriese Date: Thu, 13 Nov 2025 15:27:47 +0100 Subject: [PATCH 08/10] Add test for DocumentReference AsyncSequence --- .../Unit/AsyncAwait/AsyncSequenceTests.swift | 331 +++++++++++++----- 1 file changed, 241 insertions(+), 90 deletions(-) diff --git a/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift b/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift index 908ab2457c3..adc4c94e8a4 100644 --- a/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift +++ b/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift @@ -18,22 +18,121 @@ import Testing import FirebaseCore @testable import FirebaseFirestore +// MARK: - Shared Test Helpers + private final class AssociationKey: Sendable {} +private enum TestError: Error, Equatable { + case mockError +} + +/// A protocol to allow the `MockListenerRegistration` to call back to the actor +/// without needing to know its generic type. +private protocol ListenerRemovable: Sendable { + func listenerDidRemove() async +} + /// A wrapper to safely pass a non-Sendable closure to a Sendable context. /// /// This is safe in this specific test because the listener closure originates from /// an `AsyncStream` continuation, which is guaranteed to be thread-safe. -private final class SendableListenerWrapper: @unchecked Sendable { - let listener: (QuerySnapshot?, Error?) -> Void +private final class SendableListenerWrapper: @unchecked Sendable { + let listener: (SnapshotType?, Error?) -> Void - init(_ listener: @escaping (QuerySnapshot?, Error?) -> Void) { + init(_ listener: @escaping (SnapshotType?, Error?) -> Void) { self.listener = listener } } +/// An actor to manage test state, ensuring thread-safe access to continuations +/// and the captured listener closure. +private actor TestStateActor: ListenerRemovable { + private var capturedListenerWrapper: SendableListenerWrapper? + private var listenerSetupContinuation: CheckedContinuation? + private var listenerRemovedContinuation: CheckedContinuation? + + private var hasSetUpListener = false + private var hasRemovedListener = false + + func waitForListenerSetup() async { + if hasSetUpListener { return } + await withCheckedContinuation { continuation in + self.listenerSetupContinuation = continuation + } + } + + func waitForListenerRemoval() async { + if hasRemovedListener { return } + await withCheckedContinuation { continuation in + self.listenerRemovedContinuation = continuation + } + } + + func listenerDidSetUp(wrapper: SendableListenerWrapper) { + capturedListenerWrapper = wrapper + hasSetUpListener = true + listenerSetupContinuation?.resume() + listenerSetupContinuation = nil + } + + func listenerDidRemove() { + hasRemovedListener = true + listenerRemovedContinuation?.resume() + listenerRemovedContinuation = nil + } + + func invokeListener(withSnapshot snapshot: SnapshotType?, error: Error?) { + capturedListenerWrapper?.listener(snapshot, error) + } +} + +private final class MockListenerRegistration: NSObject, ListenerRegistration { + private var actor: any ListenerRemovable + + init(actor: any ListenerRemovable) { + self.actor = actor + } + + func remove() { + let actor = self.actor + Task { + await actor.listenerDidRemove() + } + } +} + +// Swizzling is managed by this helper struct using a simple RAII pattern. +// The swizzling is active for the lifetime of the struct instance. +private struct Swizzler: ~Copyable { + private let cls: AnyClass + private let original: Selector + private let swizzled: Selector + + init(_ cls: AnyClass, original: Selector, swizzled: Selector) { + self.cls = cls + self.original = original + self.swizzled = swizzled + Self.swizzle(cls, originalSelector: original, swizzledSelector: swizzled) + } + + deinit { + Self.swizzle(cls, originalSelector: swizzled, swizzledSelector: original) + } + + private static func swizzle(_ cls: AnyClass, originalSelector: Selector, swizzledSelector: Selector) { + guard let originalMethod = class_getInstanceMethod(cls, originalSelector), + let swizzledMethod = class_getInstanceMethod(cls, swizzledSelector) else { + #expect(false, "Failed to get methods for swizzling") + return + } + method_exchangeImplementations(originalMethod, swizzledMethod) + } +} + +// MARK: - Query Tests + @Suite("Query AsyncSequence Tests") -struct AsyncSequenceTests { +struct QueryAsyncSequenceTests { fileprivate static let associationKey = AssociationKey() // This static property handles the one-time setup for FirebaseApp. @@ -46,46 +145,19 @@ struct AsyncSequenceTests { return FirebaseApp.app()! }() - // Swizzling is managed by this helper struct using a simple RAII pattern. - // The swizzling is active for the lifetime of the struct instance. - private struct Swizzler: ~Copyable { - init() { - Self.swizzle( - Query.self, - originalSelector: #selector(Query.addSnapshotListener(includeMetadataChanges:listener:)), - swizzledSelector: #selector(Query.swizzled_addSnapshotListener(includeMetadataChanges:listener:)) - ) - } - - deinit { - Self.swizzle( - Query.self, - originalSelector: #selector(Query.swizzled_addSnapshotListener(includeMetadataChanges:listener:)), - swizzledSelector: #selector(Query.addSnapshotListener(includeMetadataChanges:listener:)) - ) - } - - private static func swizzle(_ cls: AnyClass, originalSelector: Selector, swizzledSelector: Selector) { - guard let originalMethod = class_getInstanceMethod(cls, originalSelector), - let swizzledMethod = class_getInstanceMethod(cls, swizzledSelector) else { - #expect(false, "Failed to get methods for swizzling") - return - } - method_exchangeImplementations(originalMethod, swizzledMethod) - } - } - @available(iOS 18.0, *) @MainActor @Test("Stream handles cancellation correctly") func test_snapshotStream_handlesCancellationCorrectly() async throws { - // Ensure Firebase is configured before swizzling, as interacting with the - // Query class can trigger SDK initialization that requires a configured app. let app = Self.firebaseApp - let swizzler = Swizzler() + let swizzler = Swizzler( + Query.self, + original: #selector(Query.addSnapshotListener(includeMetadataChanges:listener:)), + swizzled: #selector(Query.swizzled_addSnapshotListener(includeMetadataChanges:listener:)) + ) defer { withExtendedLifetime(swizzler) {} } - let actor = TestStateActor() + let actor = TestStateActor() let query = Firestore.firestore(app: app) .collection("test-\(UUID().uuidString)") let key = Unmanaged.passUnretained(Self.associationKey).toOpaque() @@ -106,13 +178,15 @@ struct AsyncSequenceTests { @MainActor @Test("Stream propagates errors") func test_snapshotStream_propagatesErrors() async throws { - // Ensure Firebase is configured before swizzling, as interacting with the - // Query class can trigger SDK initialization that requires a configured app. let app = Self.firebaseApp - let swizzler = Swizzler() + let swizzler = Swizzler( + Query.self, + original: #selector(Query.addSnapshotListener(includeMetadataChanges:listener:)), + swizzled: #selector(Query.swizzled_addSnapshotListener(includeMetadataChanges:listener:)) + ) defer { withExtendedLifetime(swizzler) {} } - let actor = TestStateActor() + let actor = TestStateActor() let query = Firestore.firestore(app: app) .collection("test-\(UUID().uuidString)") let key = Unmanaged.passUnretained(Self.associationKey).toOpaque() @@ -139,13 +213,15 @@ struct AsyncSequenceTests { @MainActor @Test("Stream handles (nil, nil) events gracefully") func test_snapshotStream_handlesNilSnapshotAndNilErrorGracefully() async throws { - // Ensure Firebase is configured before swizzling, as interacting with the - // Query class can trigger SDK initialization that requires a configured app. let app = Self.firebaseApp - let swizzler = Swizzler() + let swizzler = Swizzler( + Query.self, + original: #selector(Query.addSnapshotListener(includeMetadataChanges:listener:)), + swizzled: #selector(Query.swizzled_addSnapshotListener(includeMetadataChanges:listener:)) + ) defer { withExtendedLifetime(swizzler) {} } - let actor = TestStateActor() + let actor = TestStateActor() let query = Firestore.firestore(app: app) .collection("test-\(UUID().uuidString)") let key = Unmanaged.passUnretained(Self.associationKey).toOpaque() @@ -168,77 +244,152 @@ struct AsyncSequenceTests { } } -private enum TestError: Error, Equatable { - case mockError -} +// MARK: - DocumentReference Tests -// We can finally use a real actor, which is much safer and cleaner. -private actor TestStateActor { - private var capturedListenerWrapper: SendableListenerWrapper? - private var listenerSetupContinuation: CheckedContinuation? - private var listenerRemovedContinuation: CheckedContinuation? +@Suite("DocumentReference AsyncSequence Tests") +struct DocumentReferenceAsyncSequenceTests { + fileprivate static let associationKey = AssociationKey() - private var hasSetUpListener = false - private var hasRemovedListener = false + @MainActor + private static let firebaseApp: FirebaseApp = { + // This will either configure a new app or return the existing one + // from the Query tests, which is safe. + if let app = FirebaseApp.app() { return app } + let options = FirebaseOptions(googleAppID: "1:1234567890:ios:abcdef", + gcmSenderID: "1234s567890") + options.projectID = "Firestore-Testing-Project" + FirebaseApp.configure(options: options) + return FirebaseApp.app()! + }() - func waitForListenerSetup() async { - if hasSetUpListener { return } - await withCheckedContinuation { continuation in - self.listenerSetupContinuation = continuation + @available(iOS 18.0, *) + @MainActor + @Test("Stream handles cancellation correctly") + func test_snapshotStream_handlesCancellationCorrectly() async throws { + let app = Self.firebaseApp + let swizzler = Swizzler( + DocumentReference.self, + original: #selector(DocumentReference.addSnapshotListener(includeMetadataChanges:listener:)), + swizzled: #selector(DocumentReference.swizzled_addSnapshotListener(includeMetadataChanges:listener:)) + ) + defer { withExtendedLifetime(swizzler) {} } + + let actor = TestStateActor() + let docRef = Firestore.firestore(app: app) + .collection("test-\(UUID().uuidString)").document() + let key = Unmanaged.passUnretained(Self.associationKey).toOpaque() + objc_setAssociatedObject(docRef, key, actor, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + + let task = Task { + for try await _ in docRef.snapshots { + // Do nothing + } } + + await actor.waitForListenerSetup() + task.cancel() + await actor.waitForListenerRemoval() } - func waitForListenerRemoval() async { - if hasRemovedListener { return } - await withCheckedContinuation { continuation in - self.listenerRemovedContinuation = continuation + @available(iOS 18.0, *) + @MainActor + @Test("Stream propagates errors") + func test_snapshotStream_propagatesErrors() async throws { + let app = Self.firebaseApp + let swizzler = Swizzler( + DocumentReference.self, + original: #selector(DocumentReference.addSnapshotListener(includeMetadataChanges:listener:)), + swizzled: #selector(DocumentReference.swizzled_addSnapshotListener(includeMetadataChanges:listener:)) + ) + defer { withExtendedLifetime(swizzler) {} } + + let actor = TestStateActor() + let docRef = Firestore.firestore(app: app) + .collection("test-\(UUID().uuidString)").document() + let key = Unmanaged.passUnretained(Self.associationKey).toOpaque() + objc_setAssociatedObject(docRef, key, actor, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + + let task = Task { + do { + for try await _ in docRef.snapshots {} + throw "Stream did not throw" + } catch { + return error + } } - } - func listenerDidSetUp(wrapper: SendableListenerWrapper) { - capturedListenerWrapper = wrapper - hasSetUpListener = true - listenerSetupContinuation?.resume() - listenerSetupContinuation = nil - } + await actor.waitForListenerSetup() + await actor.invokeListener(withSnapshot: nil, error: TestError.mockError) - func listenerDidRemove() { - hasRemovedListener = true - listenerRemovedContinuation?.resume() - listenerRemovedContinuation = nil + let caughtError = await task.value + #expect(caughtError as? TestError == .mockError) + task.cancel() } - func invokeListener(withSnapshot snapshot: QuerySnapshot?, error: Error?) { - capturedListenerWrapper?.listener(snapshot, error) - } -} + @available(iOS 18.0, *) + @MainActor + @Test("Stream handles (nil, nil) events gracefully") + func test_snapshotStream_handlesNilSnapshotAndNilErrorGracefully() async throws { + let app = Self.firebaseApp + let swizzler = Swizzler( + DocumentReference.self, + original: #selector(DocumentReference.addSnapshotListener(includeMetadataChanges:listener:)), + swizzled: #selector(DocumentReference.swizzled_addSnapshotListener(includeMetadataChanges:listener:)) + ) + defer { withExtendedLifetime(swizzler) {} } -private final class MockListenerRegistration: NSObject, ListenerRegistration { - private var actor: TestStateActor + let actor = TestStateActor() + let docRef = Firestore.firestore(app: app) + .collection("test-\(UUID().uuidString)").document() + let key = Unmanaged.passUnretained(Self.associationKey).toOpaque() + objc_setAssociatedObject(docRef, key, actor, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - init(actor: TestStateActor) { - self.actor = actor + let task = Task { + for try await _ in docRef.snapshots { + #expect(false, "The stream should not have produced any values.") + } + } + + await actor.waitForListenerSetup() + await actor.invokeListener(withSnapshot: nil, error: nil) + task.cancel() + await actor.waitForListenerRemoval() + + // Awaiting the task will rethrow a CancellationError, which is expected + // and handled by the `throws` on the test function. + try await task.value } +} - func remove() { - let actor = self.actor +// MARK: - Method Swizzling + +extension Query { + @objc func swizzled_addSnapshotListener( + includeMetadataChanges: Bool, + listener: @escaping (QuerySnapshot?, Error?) -> Void + ) -> ListenerRegistration { + let key = Unmanaged.passUnretained(QueryAsyncSequenceTests.associationKey).toOpaque() + let actor = objc_getAssociatedObject(self, key) as! TestStateActor + let registration = MockListenerRegistration(actor: actor) + let wrapper = SendableListenerWrapper(listener) Task { - await actor.listenerDidRemove() + await actor.listenerDidSetUp(wrapper: wrapper) } + return registration } } -extension Query { +extension DocumentReference { @objc func swizzled_addSnapshotListener( includeMetadataChanges: Bool, - listener: @escaping (QuerySnapshot?, Error?) -> Void + listener: @escaping (DocumentSnapshot?, Error?) -> Void ) -> ListenerRegistration { - let key = Unmanaged.passUnretained(AsyncSequenceTests.associationKey).toOpaque() - let actor = objc_getAssociatedObject(self, key) as! TestStateActor + let key = Unmanaged.passUnretained(DocumentReferenceAsyncSequenceTests.associationKey).toOpaque() + let actor = objc_getAssociatedObject(self, key) as! TestStateActor let registration = MockListenerRegistration(actor: actor) let wrapper = SendableListenerWrapper(listener) Task { - await actor.listenerDidSetUp(wrapper: wrapper) + await actor.listenerDidSetUp(wrapper: wrapper) } return registration } From 1fcc19a01156a67bf3ceda7552e9440bdb3cd2c1 Mon Sep 17 00:00:00 2001 From: peterfriese Date: Thu, 13 Nov 2025 16:57:03 +0100 Subject: [PATCH 09/10] Fix styling --- .../DocumentReference+AsyncSequence.swift | 8 ++--- .../AsyncAwait/Query+AsyncSequence.swift | 8 ++--- .../Unit/AsyncAwait/AsyncSequenceTests.swift | 31 ++++++++++--------- Package.swift | 2 +- 4 files changed, 26 insertions(+), 23 deletions(-) diff --git a/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift b/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift index 24e035dc14e..f6e33941905 100644 --- a/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift +++ b/Firestore/Swift/Source/AsyncAwait/DocumentReference+AsyncSequence.swift @@ -51,9 +51,9 @@ public extension DocumentReference { public typealias AsyncIterator = Iterator @usableFromInline - internal let documentReference: DocumentReference + let documentReference: DocumentReference @usableFromInline - internal let includeMetadataChanges: Bool + let includeMetadataChanges: Bool /// Creates a new sequence for monitoring document snapshots. /// - Parameters: @@ -78,9 +78,9 @@ public extension DocumentReference { public struct Iterator: AsyncIteratorProtocol { public typealias Element = DocumentSnapshot @usableFromInline - internal let stream: AsyncThrowingStream + let stream: AsyncThrowingStream @usableFromInline - internal var streamIterator: AsyncThrowingStream.Iterator + var streamIterator: AsyncThrowingStream.Iterator /// Initializes the iterator with the provided `DocumentReference` instance. /// This sets up the `AsyncThrowingStream` and registers the necessary listener. diff --git a/Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift b/Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift index ae1b58dd04f..862a2dfa506 100644 --- a/Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift +++ b/Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift @@ -53,9 +53,9 @@ public extension Query { public typealias AsyncIterator = Iterator @usableFromInline - internal let query: Query + let query: Query @usableFromInline - internal let includeMetadataChanges: Bool + let includeMetadataChanges: Bool /// Creates a new sequence for monitoring query snapshots. /// - Parameters: @@ -80,9 +80,9 @@ public extension Query { public struct Iterator: AsyncIteratorProtocol { public typealias Element = QuerySnapshot @usableFromInline - internal let stream: AsyncThrowingStream + let stream: AsyncThrowingStream @usableFromInline - internal var streamIterator: AsyncThrowingStream.Iterator + var streamIterator: AsyncThrowingStream.Iterator /// Initializes the iterator with the provided `Query` instance. /// This sets up the `AsyncThrowingStream` and registers the necessary listener. diff --git a/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift b/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift index adc4c94e8a4..4fdb05fae73 100644 --- a/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift +++ b/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift @@ -14,9 +14,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import Testing import FirebaseCore @testable import FirebaseFirestore +import Testing // MARK: - Shared Test Helpers @@ -119,7 +119,8 @@ private struct Swizzler: ~Copyable { Self.swizzle(cls, originalSelector: swizzled, swizzledSelector: original) } - private static func swizzle(_ cls: AnyClass, originalSelector: Selector, swizzledSelector: Selector) { + private static func swizzle(_ cls: AnyClass, originalSelector: Selector, + swizzledSelector: Selector) { guard let originalMethod = class_getInstanceMethod(cls, originalSelector), let swizzledMethod = class_getInstanceMethod(cls, swizzledSelector) else { #expect(false, "Failed to get methods for swizzling") @@ -270,7 +271,8 @@ struct DocumentReferenceAsyncSequenceTests { let swizzler = Swizzler( DocumentReference.self, original: #selector(DocumentReference.addSnapshotListener(includeMetadataChanges:listener:)), - swizzled: #selector(DocumentReference.swizzled_addSnapshotListener(includeMetadataChanges:listener:)) + swizzled: #selector(DocumentReference + .swizzled_addSnapshotListener(includeMetadataChanges:listener:)) ) defer { withExtendedLifetime(swizzler) {} } @@ -299,7 +301,8 @@ struct DocumentReferenceAsyncSequenceTests { let swizzler = Swizzler( DocumentReference.self, original: #selector(DocumentReference.addSnapshotListener(includeMetadataChanges:listener:)), - swizzled: #selector(DocumentReference.swizzled_addSnapshotListener(includeMetadataChanges:listener:)) + swizzled: #selector(DocumentReference + .swizzled_addSnapshotListener(includeMetadataChanges:listener:)) ) defer { withExtendedLifetime(swizzler) {} } @@ -334,7 +337,8 @@ struct DocumentReferenceAsyncSequenceTests { let swizzler = Swizzler( DocumentReference.self, original: #selector(DocumentReference.addSnapshotListener(includeMetadataChanges:listener:)), - swizzled: #selector(DocumentReference.swizzled_addSnapshotListener(includeMetadataChanges:listener:)) + swizzled: #selector(DocumentReference + .swizzled_addSnapshotListener(includeMetadataChanges:listener:)) ) defer { withExtendedLifetime(swizzler) {} } @@ -364,10 +368,9 @@ struct DocumentReferenceAsyncSequenceTests { // MARK: - Method Swizzling extension Query { - @objc func swizzled_addSnapshotListener( - includeMetadataChanges: Bool, - listener: @escaping (QuerySnapshot?, Error?) -> Void - ) -> ListenerRegistration { + @objc func swizzled_addSnapshotListener(includeMetadataChanges: Bool, + listener: @escaping (QuerySnapshot?, Error?) -> Void) + -> ListenerRegistration { let key = Unmanaged.passUnretained(QueryAsyncSequenceTests.associationKey).toOpaque() let actor = objc_getAssociatedObject(self, key) as! TestStateActor let registration = MockListenerRegistration(actor: actor) @@ -380,11 +383,11 @@ extension Query { } extension DocumentReference { - @objc func swizzled_addSnapshotListener( - includeMetadataChanges: Bool, - listener: @escaping (DocumentSnapshot?, Error?) -> Void - ) -> ListenerRegistration { - let key = Unmanaged.passUnretained(DocumentReferenceAsyncSequenceTests.associationKey).toOpaque() + @objc func swizzled_addSnapshotListener(includeMetadataChanges: Bool, + listener: @escaping (DocumentSnapshot?, Error?) -> Void) + -> ListenerRegistration { + let key = Unmanaged.passUnretained(DocumentReferenceAsyncSequenceTests.associationKey) + .toOpaque() let actor = objc_getAssociatedObject(self, key) as! TestStateActor let registration = MockListenerRegistration(actor: actor) let wrapper = SendableListenerWrapper(listener) diff --git a/Package.swift b/Package.swift index b649d08d375..5288224f44b 100644 --- a/Package.swift +++ b/Package.swift @@ -1557,7 +1557,7 @@ func firestoreTargets() -> [Target] { dependencies: [ "Firebase", "FirebaseCore", - "FirebaseFirestoreTarget" + "FirebaseFirestoreTarget", ], path: "Firestore/Swift/Tests/Unit", cSettings: [ From 51b028fad62fba316aa32443473968407a98168a Mon Sep 17 00:00:00 2001 From: Nick Cooke <36927374+ncooke3@users.noreply.github.com> Date: Thu, 20 Nov 2025 13:50:12 -0500 Subject: [PATCH 10/10] fix: Resolve build warnings, move test target, check in new test scheme (#15518) --- .github/workflows/firestore.yml | 3 +- .../Unit/AsyncAwait/AsyncSequenceTests.swift | 8 +-- Package.swift | 24 ++++----- .../FirebaseFirestoreTests.xcscheme | 54 +++++++++++++++++++ 4 files changed, 71 insertions(+), 18 deletions(-) create mode 100644 scripts/spm_test_schemes/FirebaseFirestoreTests.xcscheme diff --git a/.github/workflows/firestore.yml b/.github/workflows/firestore.yml index 548153496f4..613b1a292d4 100644 --- a/.github/workflows/firestore.yml +++ b/.github/workflows/firestore.yml @@ -514,9 +514,8 @@ jobs: spm-binary: uses: ./.github/workflows/common.yml with: - target: FirebaseFirestore + target: FirebaseFirestoreTests platforms: iOS - buildonly_platforms: iOS check-firestore-internal-public-headers: needs: check diff --git a/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift b/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift index 4fdb05fae73..37f454a4c56 100644 --- a/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift +++ b/Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift @@ -123,7 +123,7 @@ private struct Swizzler: ~Copyable { swizzledSelector: Selector) { guard let originalMethod = class_getInstanceMethod(cls, originalSelector), let swizzledMethod = class_getInstanceMethod(cls, swizzledSelector) else { - #expect(false, "Failed to get methods for swizzling") + #expect(Bool(false), "Failed to get methods for swizzling") return } method_exchangeImplementations(originalMethod, swizzledMethod) @@ -230,7 +230,7 @@ struct QueryAsyncSequenceTests { let task = Task { for try await _ in query.snapshots { - #expect(false, "The stream should not have produced any values.") + #expect(Bool(false), "The stream should not have produced any values.") } } @@ -350,7 +350,7 @@ struct DocumentReferenceAsyncSequenceTests { let task = Task { for try await _ in docRef.snapshots { - #expect(false, "The stream should not have produced any values.") + #expect(Bool(false), "The stream should not have produced any values.") } } @@ -398,4 +398,4 @@ extension DocumentReference { } } -extension String: Error {} +extension String: @retroactive Error {} diff --git a/Package.swift b/Package.swift index 5288224f44b..c8eb1117d02 100644 --- a/Package.swift +++ b/Package.swift @@ -1377,6 +1377,18 @@ let package = Package( .headerSearchPath("../../.."), ] ), + .testTarget( + name: "FirebaseFirestoreTests", + dependencies: [ + "Firebase", + "FirebaseCore", + "FirebaseFirestoreTarget", + ], + path: "Firestore/Swift/Tests/Unit", + cSettings: [ + .headerSearchPath("../../../"), + ] + ), ] + firestoreTargets(), cxxLanguageStandard: CXXLanguageStandard.gnucxx14 ) @@ -1552,18 +1564,6 @@ func firestoreTargets() -> [Target] { .swiftLanguageMode(SwiftLanguageMode.v5), ] ), - .testTarget( - name: "FirebaseFirestoreTests", - dependencies: [ - "Firebase", - "FirebaseCore", - "FirebaseFirestoreTarget", - ], - path: "Firestore/Swift/Tests/Unit", - cSettings: [ - .headerSearchPath("../../../"), - ] - ), ] } diff --git a/scripts/spm_test_schemes/FirebaseFirestoreTests.xcscheme b/scripts/spm_test_schemes/FirebaseFirestoreTests.xcscheme new file mode 100644 index 00000000000..4dcedcdd29d --- /dev/null +++ b/scripts/spm_test_schemes/FirebaseFirestoreTests.xcscheme @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + +