From c93d6e3265f4c549cc309eade73747e239f4817d Mon Sep 17 00:00:00 2001 From: Mauro <34335419+Velin92@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:39:35 +0200 Subject: [PATCH] Unit Tests migration to swift 6.2 (#5781) --- ElementX.xcodeproj/project.pbxproj | 6 + ...NotificationSettingsEditScreenModels.swift | 2 +- Enterprise | 2 +- UnitTests/Sources/BugReportServiceTests.swift | 2 +- .../Sources/ClassicAppMediaLoaderTests.swift | 10 +- .../Sources/CreateRoomViewModelTests.swift | 2 +- .../Sources/ElementCallServiceTests.swift | 4 +- .../JoinRoomScreenViewModelTests.swift | 2 +- .../Sources/LayoutTests/LayoutMocks.swift | 4 +- .../MediaProvider/MockImageCache.swift | 2 +- UnitTests/Sources/NSItemProviderTests.swift | 10 +- .../NotificationContentBuilderTests.swift | 2 +- .../NotificationManager/MockCoder.swift | 2 +- .../NotificationManagerTests.swift | 2 +- ...curityAndPrivacyScreenViewModelTests.swift | 2 +- .../TestUtilities/DeferredFulfillment.swift | 122 ++++++++---------- .../TestUtilities/WaitingConfirmation.swift | 4 +- UnitTests/Sources/UserPreferenceTests.swift | 4 +- .../Sources/VoiceMessageCacheTests.swift | 2 +- UnitTests/SupportingFiles/target.yml | 3 + 20 files changed, 99 insertions(+), 90 deletions(-) diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 6056b82ab..7e8835a96 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -9710,6 +9710,9 @@ PRODUCT_BUNDLE_IDENTIFIER = "${BASE_BUNDLE_IDENTIFIER}.unit.tests"; PRODUCT_NAME = UnitTests; SDKROOT = iphoneos; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_VERSION = 6.2; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ElementX.app/ElementX"; }; @@ -9906,6 +9909,9 @@ PRODUCT_BUNDLE_IDENTIFIER = "${BASE_BUNDLE_IDENTIFIER}.unit.tests"; PRODUCT_NAME = UnitTests; SDKROOT = iphoneos; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_VERSION = 6.2; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ElementX.app/ElementX"; }; diff --git a/ElementX/Sources/Screens/Settings/NotificationSettingsEditScreen/NotificationSettingsEditScreenModels.swift b/ElementX/Sources/Screens/Settings/NotificationSettingsEditScreen/NotificationSettingsEditScreenModels.swift index 078825d97..7032d34b7 100644 --- a/ElementX/Sources/Screens/Settings/NotificationSettingsEditScreen/NotificationSettingsEditScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/NotificationSettingsEditScreen/NotificationSettingsEditScreenModels.swift @@ -12,7 +12,7 @@ enum NotificationSettingsEditScreenViewModelAction { case requestRoomNotificationSettingsPresentation(roomID: String) } -enum NotificationSettingsEditScreenDefaultMode { +nonisolated enum NotificationSettingsEditScreenDefaultMode { case allMessages case mentionsAndKeywordsOnly } diff --git a/Enterprise b/Enterprise index 538306062..bd9d5b94a 160000 --- a/Enterprise +++ b/Enterprise @@ -1 +1 @@ -Subproject commit 5383060626db89816734199ba6188c728a02eb4a +Subproject commit bd9d5b94a2d6b385ab3494006493d3079630537f diff --git a/UnitTests/Sources/BugReportServiceTests.swift b/UnitTests/Sources/BugReportServiceTests.swift index d026efc0a..f58a9c119 100644 --- a/UnitTests/Sources/BugReportServiceTests.swift +++ b/UnitTests/Sources/BugReportServiceTests.swift @@ -145,7 +145,7 @@ final class BugReportServiceTests { } } -private class MockURLProtocol: URLProtocol { +private nonisolated class MockURLProtocol: URLProtocol { override func startLoading() { guard let url = request.url else { return } let reportURL = url.deletingLastPathComponent().appending(path: "123") diff --git a/UnitTests/Sources/ClassicAppMediaLoaderTests.swift b/UnitTests/Sources/ClassicAppMediaLoaderTests.swift index d6c633518..f25a344ee 100644 --- a/UnitTests/Sources/ClassicAppMediaLoaderTests.swift +++ b/UnitTests/Sources/ClassicAppMediaLoaderTests.swift @@ -7,6 +7,7 @@ @testable import ElementX import Foundation +import Synchronization import Testing final class ClassicAppMediaLoaderTests { @@ -126,7 +127,7 @@ final class ClassicAppMediaLoaderTests { // MARK: - MockURLProtocol -private class MockURLProtocol: URLProtocol { +private nonisolated class MockURLProtocol: URLProtocol { /// The MXC URL whose media requests will be served successfully. static let mxcURL: URL = "mxc://matrix.org/testmediaid" /// The MXC URL whose media requests will return a 404. @@ -141,7 +142,10 @@ private class MockURLProtocol: URLProtocol { static let thumbnailData = Data("thumbnail data".utf8) /// The last request handled, for URL/header inspection in tests. - static var lastRequest: URLRequest? + private static let _lastRequest: Mutex = .init(nil) + static var lastRequest: URLRequest? { + _lastRequest.withLock { $0 } + } /// Maps a URL path to a fixed `(statusCode, Data)` response. private static let responses: [String: (Int, Data)] = [ @@ -150,7 +154,7 @@ private class MockURLProtocol: URLProtocol { ] override func startLoading() { - MockURLProtocol.lastRequest = request + MockURLProtocol._lastRequest.withLock { $0 = request } guard let url = request.url else { client?.urlProtocol(self, didFailWithError: URLError(.badURL)) diff --git a/UnitTests/Sources/CreateRoomViewModelTests.swift b/UnitTests/Sources/CreateRoomViewModelTests.swift index 5d42e9d97..87d45c306 100644 --- a/UnitTests/Sources/CreateRoomViewModelTests.swift +++ b/UnitTests/Sources/CreateRoomViewModelTests.swift @@ -29,7 +29,7 @@ final class CreateRoomScreenViewModelTests { appSettings = AppSettings.volatile() } - deinit { + isolated deinit { viewModel = nil clientProxy = nil spaceService = nil diff --git a/UnitTests/Sources/ElementCallServiceTests.swift b/UnitTests/Sources/ElementCallServiceTests.swift index 4fe6f0447..4de0ef197 100644 --- a/UnitTests/Sources/ElementCallServiceTests.swift +++ b/UnitTests/Sources/ElementCallServiceTests.swift @@ -30,7 +30,7 @@ final class ElementCallServiceTests { service = ElementCallService(callProvider: callProvider, timeProvider: TimeProvider(clock: testClock, now: dateProvider)) } - deinit { + isolated deinit { callProvider = nil currentDate = nil testClock = nil @@ -256,7 +256,7 @@ final class ElementCallServiceTests { } } -private class PKPushPayloadMock: PKPushPayload { +private nonisolated class PKPushPayloadMock: PKPushPayload { var dict: [AnyHashable: Any] = [:] override init() { diff --git a/UnitTests/Sources/JoinRoomScreenViewModelTests.swift b/UnitTests/Sources/JoinRoomScreenViewModelTests.swift index 37ee4d0a9..372399f66 100644 --- a/UnitTests/Sources/JoinRoomScreenViewModelTests.swift +++ b/UnitTests/Sources/JoinRoomScreenViewModelTests.swift @@ -32,7 +32,7 @@ final class JoinRoomScreenViewModelTests { appSettings = AppSettings.volatile() } - deinit { + isolated deinit { viewModel = nil clientProxy = nil } diff --git a/UnitTests/Sources/LayoutTests/LayoutMocks.swift b/UnitTests/Sources/LayoutTests/LayoutMocks.swift index fdfb8d29c..fe060ff67 100644 --- a/UnitTests/Sources/LayoutTests/LayoutMocks.swift +++ b/UnitTests/Sources/LayoutTests/LayoutMocks.swift @@ -11,7 +11,7 @@ import Foundation import SwiftUI /// A mock of the SwiftUI `LayoutSubviews` struct -struct LayoutSubviewsMock: Equatable, RandomAccessCollection { +nonisolated struct LayoutSubviewsMock: Equatable, RandomAccessCollection { var subviews: [LayoutSubviewMock] /// A type that contains a subsequence of proxy values. @@ -45,7 +45,7 @@ struct LayoutSubviewsMock: Equatable, RandomAccessCollection { } /// A mock of the SwiftUI `LayoutSubview` struct -struct LayoutSubviewMock: FlowLayoutSubview, Equatable { +nonisolated struct LayoutSubviewMock: FlowLayoutSubview, Equatable { var size: CGSize var layoutValues = [String: Any]() var placedPositionCallback: (CGRect) -> Void diff --git a/UnitTests/Sources/MediaProvider/MockImageCache.swift b/UnitTests/Sources/MediaProvider/MockImageCache.swift index 425a25fdc..b70d8969c 100644 --- a/UnitTests/Sources/MediaProvider/MockImageCache.swift +++ b/UnitTests/Sources/MediaProvider/MockImageCache.swift @@ -9,7 +9,7 @@ @testable import Kingfisher import UIKit -class MockImageCache: ImageCache, @unchecked Sendable { +nonisolated class MockImageCache: ImageCache, @unchecked Sendable { var retrievedImagesInMemory = [String: UIImage]() var retrievedImages = [String: UIImage]() var storedImages = [String: UIImage]() diff --git a/UnitTests/Sources/NSItemProviderTests.swift b/UnitTests/Sources/NSItemProviderTests.swift index 217c4393e..518d1a55c 100644 --- a/UnitTests/Sources/NSItemProviderTests.swift +++ b/UnitTests/Sources/NSItemProviderTests.swift @@ -44,7 +44,10 @@ struct NSItemProviderTests { func storeDataAppendsExtensionWhenSuggestedNameLooksLikeAScreenshot() async throws { let imageURL = try #require(Bundle(for: BundleAnchor.self).url(forResource: "test_image.png", withExtension: nil), "Failed retrieving fixture") - let provider = try #require(NSItemProvider(contentsOf: imageURL)) + guard let provider = NSItemProvider(contentsOf: imageURL) else { + Issue.record("Failed creating the item provider") + return + } provider.suggestedName = "Screenshot 2026-05-22 at 14.49.40" let storedURL = try #require(await provider.storeData()) @@ -57,7 +60,10 @@ struct NSItemProviderTests { func storeDataPreservesSuggestedNameWithRealExtension() async throws { let imageURL = try #require(Bundle(for: BundleAnchor.self).url(forResource: "test_image.png", withExtension: nil), "Failed retrieving fixture") - let provider = try #require(NSItemProvider(contentsOf: imageURL)) + guard let provider = NSItemProvider(contentsOf: imageURL) else { + Issue.record("Failed creating the item provider") + return + } provider.suggestedName = "IMG_1234.png" let storedURL = try #require(await provider.storeData()) diff --git a/UnitTests/Sources/NotificationContentBuilderTests.swift b/UnitTests/Sources/NotificationContentBuilderTests.swift index 816600651..f1e646256 100644 --- a/UnitTests/Sources/NotificationContentBuilderTests.swift +++ b/UnitTests/Sources/NotificationContentBuilderTests.swift @@ -11,7 +11,7 @@ import MatrixRustSDK import Testing import UserNotifications -struct NotificationContentBuilderTests { +nonisolated struct NotificationContentBuilderTests { var notificationContentBuilder: NotificationContentBuilder var mediaProvider: MediaProviderMock var notificationContent: UNMutableNotificationContent diff --git a/UnitTests/Sources/NotificationManager/MockCoder.swift b/UnitTests/Sources/NotificationManager/MockCoder.swift index 6ce84eba6..52debc4c2 100644 --- a/UnitTests/Sources/NotificationManager/MockCoder.swift +++ b/UnitTests/Sources/NotificationManager/MockCoder.swift @@ -8,7 +8,7 @@ import Foundation -final class MockCoder: NSKeyedArchiver { +final nonisolated class MockCoder: NSKeyedArchiver { deinit { finishEncoding() } diff --git a/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift b/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift index fe844c42e..99c9196ad 100644 --- a/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift +++ b/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift @@ -36,7 +36,7 @@ final class NotificationManagerTests { notificationManager.setUserSession(mockUserSession) } - deinit { + isolated deinit { notificationCenter = nil notificationManager = nil } diff --git a/UnitTests/Sources/SecurityAndPrivacyScreenViewModelTests.swift b/UnitTests/Sources/SecurityAndPrivacyScreenViewModelTests.swift index 4ef4c8421..63d99c2b4 100644 --- a/UnitTests/Sources/SecurityAndPrivacyScreenViewModelTests.swift +++ b/UnitTests/Sources/SecurityAndPrivacyScreenViewModelTests.swift @@ -21,7 +21,7 @@ final class SecurityAndPrivacyScreenViewModelTests { viewModel.context } - deinit { + isolated deinit { viewModel = nil roomProxy = nil } diff --git a/UnitTests/Sources/TestUtilities/DeferredFulfillment.swift b/UnitTests/Sources/TestUtilities/DeferredFulfillment.swift index ca5a41600..bcda99c31 100644 --- a/UnitTests/Sources/TestUtilities/DeferredFulfillment.swift +++ b/UnitTests/Sources/TestUtilities/DeferredFulfillment.swift @@ -38,6 +38,21 @@ private struct DeferredFulfillmentError: Error { } } +/// Forwards every value of a publisher into an `AsyncStream` continuation. +/// +/// This is intentionally `nonisolated`: the `sink` closures only touch the (thread-safe) continuation, +/// so they can run on whatever queue the publisher emits on — including background queues such as the +/// ones used by `AudioRecorder`/`AudioPlayer`. Filtering against the caller's condition happens on the +/// consuming side, where it can safely run on the caller's actor. +private nonisolated func subscribe(_ publisher: P, + forwardingTo continuation: AsyncStream.Continuation) -> AnyCancellable where P.Failure == Never, P.Output: Sendable { + publisher.sink { _ in + continuation.finish() + } receiveValue: { value in + continuation.yield(value) + } +} + /// Test utility that assists in subscribing to a publisher and deferring the fulfilment and results until some other actions have been performed. /// - Parameters: /// - publisher: The publisher to wait on. @@ -50,41 +65,29 @@ func deferFulfillment>(_ publisher: P, timeout: Duration = .seconds(10), message: String? = nil, sourceLocation: SourceLocation = #_sourceLocation, - until condition: @escaping (P.Output) -> Bool) -> DeferredFulfillment { + until condition: @escaping (P.Output) -> Bool) -> DeferredFulfillment where P.Output: Sendable { let (stream, continuation) = AsyncStream.makeStream() - - let cancellable = publisher - .sink { _ in - continuation.finish() - } receiveValue: { value in - guard condition(value) else { return } - continuation.yield(value) - continuation.finish() - } + let cancellable = subscribe(publisher, forwardingTo: continuation) return DeferredFulfillment { defer { cancellable.cancel() } - return try await withThrowingTaskGroup(of: P.Output.self) { group in - group.addTask { - for await result in stream { - return result - } - guard !Task.isCancelled else { - // Required to avoid a double recording of the issue in the case where the task is cancelled due to timeout. - throw DeferredFulfillmentError.empty - } - throw DeferredFulfillmentError.noOutput(message: message, sourceLocation: sourceLocation) - } - - group.addTask { - try await Task.sleep(for: timeout) - throw DeferredFulfillmentError.noOutput(message: message, sourceLocation: sourceLocation) - } - - defer { group.cancelAll() } - return try #require(try await group.next()) + let timeoutTask = Task { + try? await Task.sleep(for: timeout) + continuation.finish() } + defer { timeoutTask.cancel() } + + // `condition` runs on the caller's actor, not the publisher's (possibly background) emission queue. + for await value in stream where condition(value) { + return value + } + + guard !Task.isCancelled else { + // Required to avoid a double recording of the issue in the case where the task is cancelled due to timeout. + throw DeferredFulfillmentError.empty + } + throw DeferredFulfillmentError.noOutput(message: message, sourceLocation: sourceLocation) } } @@ -96,11 +99,11 @@ func deferFulfillment>(_ publisher: P, /// - sourceLocation: The source location to attach to any recorded issues. /// - until: callback that evaluates outputs until some condition is reached /// - Returns: The deferred fulfilment to be executed after some actions and that returns the result of the sequence. -func deferFulfillment(_ asyncSequence: any AsyncSequence, - timeout: Duration = .seconds(10), - message: String? = nil, - sourceLocation: SourceLocation = #_sourceLocation, - until condition: @escaping (Value) -> Bool) -> DeferredFulfillment { +func deferFulfillment(_ asyncSequence: any AsyncSequence, + timeout: Duration = .seconds(10), + message: String? = nil, + sourceLocation: SourceLocation = #_sourceLocation, + until condition: @escaping (Value) -> Bool) -> DeferredFulfillment { let (stream, continuation) = AsyncStream.makeStream() let task = Task { @@ -151,7 +154,7 @@ func deferFulfillment, K: KeyPath, V: transitionValues: [V], timeout: Duration = .seconds(10), message: String? = nil, - sourceLocation: SourceLocation = #_sourceLocation) -> DeferredFulfillment { + sourceLocation: SourceLocation = #_sourceLocation) -> DeferredFulfillment where P.Output: Sendable { var expectedOrder = transitionValues return deferFulfillment(publisher, timeout: timeout, message: message, sourceLocation: sourceLocation) { value in let receivedValue = value[keyPath: keyPath] @@ -169,11 +172,11 @@ func deferFulfillment, K: KeyPath, V: /// - timeout: A timeout after which we give up. /// - sourceLocation: The source location to attach to any recorded issues. /// - Returns: The deferred fulfilment to be executed after some actions and that returns the result of the sequence. -func deferFulfillment(_ asyncSequence: any AsyncSequence, - transitionValues: [Value], - timeout: Duration = .seconds(10), - message: String? = nil, - sourceLocation: SourceLocation = #_sourceLocation) -> DeferredFulfillment { +func deferFulfillment(_ asyncSequence: any AsyncSequence, + transitionValues: [Value], + timeout: Duration = .seconds(10), + message: String? = nil, + sourceLocation: SourceLocation = #_sourceLocation) -> DeferredFulfillment { var expectedOrder = transitionValues return deferFulfillment(asyncSequence, timeout: timeout, message: message, sourceLocation: sourceLocation) { value in if let index = expectedOrder.firstIndex(where: { $0 == value }), index == 0 { @@ -195,36 +198,23 @@ func deferFailure>(_ publisher: P, timeout: Duration, message: String? = nil, sourceLocation: SourceLocation = #_sourceLocation, - until condition: @escaping (P.Output) -> Bool) -> DeferredFulfillment where P.Failure == Never { - let (stream, continuation) = AsyncStream.makeStream() - - let cancellable = publisher - .sink { value in - guard condition(value) else { return } - continuation.yield(()) - continuation.finish() - } + until condition: @escaping (P.Output) -> Bool) -> DeferredFulfillment where P.Failure == Never, P.Output: Sendable { + let (stream, continuation) = AsyncStream.makeStream() + let cancellable = subscribe(publisher, forwardingTo: continuation) return DeferredFulfillment { defer { cancellable.cancel() } - try await withThrowingTaskGroup(of: Void.self) { group in - // If the condition fires before timeout, that's the unexpected failure. - group.addTask { - for await _ in stream { - throw DeferredFulfillmentError.unexpectedFulfillment(message: message, sourceLocation: sourceLocation) - } - // Stream finished without condition firing — this shouldn't happen - // but is safe to treat as success. - } - // Timeout elapsing without the condition firing = success. - group.addTask { - try await Task.sleep(for: timeout) - } - - defer { group.cancelAll() } - - return try #require(try await group.next()) + let timeoutTask = Task { + try? await Task.sleep(for: timeout) + continuation.finish() + } + defer { timeoutTask.cancel() } + + // `condition` runs on the caller's actor, not the publisher's (possibly background) emission queue. + // The condition firing before the timeout is the unexpected failure; the timeout elapsing is success. + for await value in stream where condition(value) { + throw DeferredFulfillmentError.unexpectedFulfillment(message: message, sourceLocation: sourceLocation) } } } diff --git a/UnitTests/Sources/TestUtilities/WaitingConfirmation.swift b/UnitTests/Sources/TestUtilities/WaitingConfirmation.swift index bfa076024..c46b1f958 100644 --- a/UnitTests/Sources/TestUtilities/WaitingConfirmation.swift +++ b/UnitTests/Sources/TestUtilities/WaitingConfirmation.swift @@ -131,8 +131,8 @@ func waitForConfirmation(_ comment: Comment? = nil, /// isolation parameter, so without this hop a body that synchronously triggers main actor /// work (e.g. sending a view action) would crash on the runtime's executor check. private func runBody(on isolation: isolated (any Actor)?, - _ body: (WaitingConfirmation) throws -> R, - with confirmation: WaitingConfirmation) rethrows -> R { + _ body: (WaitingConfirmation) throws -> sending R, + with confirmation: WaitingConfirmation) rethrows -> sending R { try body(confirmation) } diff --git a/UnitTests/Sources/UserPreferenceTests.swift b/UnitTests/Sources/UserPreferenceTests.swift index b023036db..7db3b42b1 100644 --- a/UnitTests/Sources/UserPreferenceTests.swift +++ b/UnitTests/Sources/UserPreferenceTests.swift @@ -160,7 +160,7 @@ struct UserPreferenceTests { } } -private struct TestPreferences { +private nonisolated struct TestPreferences { @UserPreference var volatileVar: String? @@ -190,7 +190,7 @@ private struct CodableTestType: Equatable, Codable { let b: [Int] } -private struct TestsKey: PreferenceKeyable, RawRepresentable { +private nonisolated struct TestsKey: PreferenceKeyable, RawRepresentable { static let testKey = Self(rawValue: "testKey") static let key1 = Self(rawValue: "foo.volatile") static let key2 = Self(rawValue: "foo.plist") diff --git a/UnitTests/Sources/VoiceMessageCacheTests.swift b/UnitTests/Sources/VoiceMessageCacheTests.swift index b779c4e50..0c600a31f 100644 --- a/UnitTests/Sources/VoiceMessageCacheTests.swift +++ b/UnitTests/Sources/VoiceMessageCacheTests.swift @@ -33,7 +33,7 @@ final class VoiceMessageCacheTests { try fileManager.createDirectory(at: testTemporaryDirectory, withIntermediateDirectories: true) } - deinit { + isolated deinit { voiceMessageCache.clearCache() try? fileManager.removeItem(at: testTemporaryDirectory) } diff --git a/UnitTests/SupportingFiles/target.yml b/UnitTests/SupportingFiles/target.yml index 2c9deafb4..cc0606c6f 100644 --- a/UnitTests/SupportingFiles/target.yml +++ b/UnitTests/SupportingFiles/target.yml @@ -43,6 +43,9 @@ targets: base: PRODUCT_NAME: UnitTests PRODUCT_BUNDLE_IDENTIFIER: ${BASE_BUNDLE_IDENTIFIER}.unit.tests + SWIFT_VERSION: 6.2 + SWIFT_APPROACHABLE_CONCURRENCY: YES + SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor debug: release: