From 10ceabd3d94a413cdf541024da398fa1836f290f Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:56:50 +0200 Subject: [PATCH 01/29] Publish raw test report to help with investigations --- .github/workflows/ci_cd.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 4cb40a4..4322554 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -70,6 +70,14 @@ jobs: xcodebuild test -project ORLib.xcodeproj -sdk iphoneos \ -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.5' -scheme ORLib -resultBundlePath ORLib.xcresult + - name: Upload XCResult bundle + uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 + if: ${{ always() && !cancelled() && hashFiles('ORLib.xcresult/**') != '' }} + with: + name: ORLib-xcresult + path: ORLib.xcresult + if-no-files-found: error + - name: Extract JUnit test report run: | xcresultparser -o junit ORLib.xcresult > junit.xml From f75b358a4f3c08f956c27145774c45ad16cd84e9 Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:18:33 +0200 Subject: [PATCH 02/29] Make wifiScan test more robust, not based on timings but more instrumentation of mock --- Tests/ESPProvisionProviderTest.swift | 62 ++++++++++++++++++++++------ Tests/ORESPDeviceMock.swift | 32 ++++++++++++++ 2 files changed, 81 insertions(+), 13 deletions(-) diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index a449e39..20afc18 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -24,6 +24,46 @@ import Testing @testable import ORLib +private final class CallbackRecorder { + private let lock = NSLock() + private var messages = [[String:Any]]() + private var awaitedAction: String? + private var continuation: CheckedContinuation<[String:Any], Never>? + + func record(_ data: [String:Any]) { + var continuationToResume: CheckedContinuation<[String:Any], Never>? + + lock.lock() + messages.append(data) + if let awaitedAction, + awaitedAction == data["action"] as? String, + let continuation { + self.awaitedAction = nil + self.continuation = nil + continuationToResume = continuation + } + lock.unlock() + + continuationToResume?.resume(returning: data) + } + + func waitForFirstMessage(matchingAction action: String, after trigger: () -> Void) async -> [String:Any] { + await withCheckedContinuation { continuation in + lock.lock() + awaitedAction = action + self.continuation = continuation + lock.unlock() + trigger() + } + } + + func messageCount() -> Int { + lock.lock() + defer { lock.unlock() } + return messages.count + } +} + class ESPORProvisionManagerMock: ORESPProvisionManager { var searchESPDevicesCallCount = 0 var stopESPDevicesSearchCallCount = 0 @@ -521,25 +561,20 @@ struct ESPProvisionProviderTest { let device = await getDevice(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) - var receivedData: [String:Any] = [:] + defer { provider.stopWifiScan() } - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) + } + let receivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.startWifiScan) { provider.startWifiScan() } - // Even if I wait for a moment, if no new devices are discovered, I should only received the callback once - try await Task.sleep(nanoseconds: UInt64(0.1 * Double(NSEC_PER_SEC))) + await mockDevice.waitForWifiScanCompletions(atLeast: 3) - #expect(mockDevice.scanWifiListCallCount >= 1) + #expect(mockDevice.scanWifiListCallCount >= 3) #expect(provider.wifiScanning) #expect(receivedData["provider"] as? String == Providers.espprovision) @@ -551,6 +586,7 @@ struct ESPProvisionProviderTest { let network = networks.first! #expect(network["ssid"] as? String == "SSID-1") #expect(network["signalStrength"] as? Int32 == -50) + #expect(callbackRecorder.messageCount() == 1) } @Test func wifiScanUpdatedRssi() async throws { diff --git a/Tests/ORESPDeviceMock.swift b/Tests/ORESPDeviceMock.swift index d9fd2f7..f6df118 100644 --- a/Tests/ORESPDeviceMock.swift +++ b/Tests/ORESPDeviceMock.swift @@ -37,10 +37,37 @@ struct MockResponse { } } +private actor WifiScanCompletionTracker { + private var completedScanCount = 0 + private var waiters = [(targetCount: Int, continuation: CheckedContinuation)]() + + func markScanCompleted() { + completedScanCount += 1 + + let readyWaiters = waiters.filter { completedScanCount >= $0.targetCount } + waiters.removeAll { completedScanCount >= $0.targetCount } + + for waiter in readyWaiters { + waiter.continuation.resume() + } + } + + func waitForCompletedScans(atLeast targetCount: Int) async { + if completedScanCount >= targetCount { + return + } + + await withCheckedContinuation { continuation in + waiters.append((targetCount, continuation)) + } + } +} + class ORESPDeviceMock: ORESPDevice { private var mockResponses: [MockResponse] = [] private var mockResponsesIndex: [MockResponse].Index? = nil + private let wifiScanCompletionTracker = WifiScanCompletionTracker() var scanWifiListCallCount = 0 var scanWifiDuration: TimeInterval = 0 @@ -95,9 +122,14 @@ class ORESPDeviceMock: ORESPDevice { try await Task.sleep(nanoseconds: UInt64(scanWifiDuration * Double(NSEC_PER_SEC))) } completionHandler(networks, nil) + await wifiScanCompletionTracker.markScanCompleted() } } + func waitForWifiScanCompletions(atLeast completedScanCount: Int) async { + await wifiScanCompletionTracker.waitForCompletedScans(atLeast: completedScanCount) + } + func provision(ssid: String?, passPhrase: String?, threadOperationalDataset: Data?, completionHandler: @escaping (ESPProvisionStatus) -> Void) { provisionCalledCount += 1 provisionCalledParameters = (ssid, passPhrase) From 4b546fb7597ed148a5566beb965634ccb8730e58 Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:29:03 +0200 Subject: [PATCH 03/29] Make wifiScanUpdatedRssi() more robust, also removing conditions based on timing --- Tests/ESPProvisionProviderTest.swift | 71 ++++++++++++++++++---------- 1 file changed, 47 insertions(+), 24 deletions(-) diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index 20afc18..12a0db6 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -28,32 +28,51 @@ private final class CallbackRecorder { private let lock = NSLock() private var messages = [[String:Any]]() private var awaitedAction: String? - private var continuation: CheckedContinuation<[String:Any], Never>? + private var awaitedCount: Int? + private var continuation: CheckedContinuation<[[String:Any]], Never>? func record(_ data: [String:Any]) { - var continuationToResume: CheckedContinuation<[String:Any], Never>? + var continuationToResume: CheckedContinuation<[[String:Any]], Never>? + var matchingMessages = [[String:Any]]() lock.lock() messages.append(data) if let awaitedAction, - awaitedAction == data["action"] as? String, + let awaitedCount, let continuation { - self.awaitedAction = nil - self.continuation = nil - continuationToResume = continuation + matchingMessages = recordedMessages(matchingAction: awaitedAction) + if matchingMessages.count >= awaitedCount { + self.awaitedAction = nil + self.awaitedCount = nil + self.continuation = nil + continuationToResume = continuation + } } lock.unlock() - continuationToResume?.resume(returning: data) + continuationToResume?.resume(returning: matchingMessages) + } + + func waitForFirstMessage(matchingAction action: String, after trigger: @escaping () -> Void) async -> [String:Any] { + let messages = await waitForMessages(matchingAction: action, count: 1, after: trigger) + return messages[0] } - func waitForFirstMessage(matchingAction action: String, after trigger: () -> Void) async -> [String:Any] { + func waitForMessages(matchingAction action: String, count: Int, after trigger: (() -> Void)? = nil) async -> [[String:Any]] { await withCheckedContinuation { continuation in lock.lock() + let matchingMessages = recordedMessages(matchingAction: action) + if matchingMessages.count >= count { + lock.unlock() + continuation.resume(returning: matchingMessages) + return + } awaitedAction = action + awaitedCount = count self.continuation = continuation lock.unlock() - trigger() + + trigger?() } } @@ -62,6 +81,10 @@ private final class CallbackRecorder { defer { lock.unlock() } return messages.count } + + private func recordedMessages(matchingAction action: String) -> [[String:Any]] { + messages.filter { ($0["action"] as? String) == action } + } } class ESPORProvisionManagerMock: ORESPProvisionManager { @@ -604,25 +627,24 @@ struct ESPProvisionProviderTest { let device = await getDevice(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) - var receivedData: [String:Any] = [:] + defer { provider.stopWifiScan() } - var firstReceivedData: [String:Any] = [:] - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - if !continuationCalled { - continuationCalled = true - firstReceivedData = data - mockDevice.networks = [ESPWifiNetwork(ssid: "SSID-1", rssi: -60)] - continuation.resume() - } - } + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) + } + + let firstReceivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.startWifiScan) { provider.startWifiScan() } - // I need to wait a moment for the second callback to be received - try await Task.sleep(nanoseconds: UInt64(0.2 * Double(NSEC_PER_SEC))) + mockDevice.networks = [ESPWifiNetwork(ssid: "SSID-1", rssi: -60)] + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startWifiScan, count: 2) + let receivedData = receivedMessages[1] + + await mockDevice.waitForWifiScanCompletions(atLeast: 4) + + #expect(mockDevice.scanWifiListCallCount >= 4) #expect(provider.wifiScanning) #expect(firstReceivedData["provider"] as? String == Providers.espprovision) @@ -644,6 +666,7 @@ struct ESPProvisionProviderTest { let network2 = networks2.first! #expect(network2["ssid"] as? String == "SSID-1") #expect(network2["signalStrength"] as? Int32 == -60) + #expect(callbackRecorder.messageCount() == 2) } @Test func wifiScanTimesout() async throws { From 8097b7b2b26e5022e02d063a7b8ec30102b27434 Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:44:34 +0200 Subject: [PATCH 04/29] Make timeout tests more robust, not relying on timing but events --- Tests/ESPProvisionProviderTest.swift | 51 ++++++++-------------------- 1 file changed, 14 insertions(+), 37 deletions(-) diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index 12a0db6..b3924a2 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -331,30 +331,18 @@ struct ESPProvisionProviderTest { _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - var receivedData: [String:Any] = [:] - var receivedCallbackCount = 0 - - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) + } + let receivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.stopBleScan) { provider.startDevicesScan() #expect(provider.bleScanning) } - // Wait long enough so scan can stop - try await Task.sleep(nanoseconds: UInt64(0.3 * Double(NSEC_PER_SEC))) - - // Ideally should be == 4 but too brittle based on timing during test run - #expect(espProvisionMock.searchESPDevicesCallCount >= 2 && espProvisionMock.searchESPDevicesCallCount <= 4) - #expect(receivedCallbackCount == 1) + #expect(espProvisionMock.searchESPDevicesCallCount >= 1) + #expect(callbackRecorder.messageCount() == 1) #expect(provider.bleScanning == false) #expect(receivedData["provider"] as? String == Providers.espprovision) @@ -685,29 +673,18 @@ struct ESPProvisionProviderTest { try await connectToDevice(provider: provider, deviceId: device["id"] as! String) - var receivedData: [String:Any] = [:] - var receivedCallbackCount = 0 - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) + } + let receivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.stopWifiScan) { provider.startWifiScan() #expect(provider.wifiScanning) } - // Wait long enough so scan can stop - try await Task.sleep(nanoseconds: UInt64(0.3 * Double(NSEC_PER_SEC))) - - // Ideally should be == 4 but too brittle based on timing during test run - #expect(mockDevice.scanWifiListCallCount >= 2 && mockDevice.scanWifiListCallCount <= 4) - #expect(receivedCallbackCount == 1) + #expect(mockDevice.scanWifiListCallCount >= 1) + #expect(callbackRecorder.messageCount() == 1) #expect(provider.wifiScanning == false) #expect(receivedData["provider"] as? String == Providers.espprovision) From bd710de96ca3aee072ecfa8af20143e2eede00db Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:00:17 +0200 Subject: [PATCH 05/29] Make provisionDeviceFailureTimeout() not dependent on timing, important check is that it ends up with timeout error --- Tests/ESPProvisionProviderTest.swift | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index b3924a2..0336666 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -1171,19 +1171,12 @@ struct ESPProvisionProviderTest { try await connectToDevice(provider: provider, deviceId: device["id"] as! String) - var receivedData: [String:Any] = [:] - var receivedCallbackCount = 0 + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) + } - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + let receivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.provisionDevice) { provider.provisionDevice(userToken: "OAUTH_TOKEN") } @@ -1192,8 +1185,10 @@ struct ESPProvisionProviderTest { #expect(receivedData["connected"] as? Bool != nil) #expect(receivedData["connected"] as? Bool == false) #expect(receivedData["errorCode"] as? Int == ESPProviderErrorCode.timeoutError.rawValue) + #expect(callbackRecorder.messageCount() == 1) - try #require(mockDevice.receivedData.count == 5) + let requestCount = mockDevice.receivedData.count + try #require(requestCount >= 3) var request = try Request(serializedBytes: mockDevice.receivedData[0]) #expect(request.id == "0") @@ -1212,7 +1207,7 @@ struct ESPProvisionProviderTest { Issue.record("Received an unexpected response: \(request)") } - for i in 2...4 { + for i in 2.. Date: Mon, 27 Apr 2026 09:18:39 +0200 Subject: [PATCH 06/29] Improve other tests by using the CallbackRecoder helper --- Tests/ESPProvisionProviderTest.swift | 344 ++++++++++----------------- 1 file changed, 122 insertions(+), 222 deletions(-) diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index 0336666..36e80b4 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -88,24 +88,56 @@ private final class CallbackRecorder { } class ESPORProvisionManagerMock: ORESPProvisionManager { + private actor DeviceScanCompletionTracker { + private var completedScanCount = 0 + private var waiters = [(targetCount: Int, continuation: CheckedContinuation)]() + + func markScanCompleted() { + completedScanCount += 1 + + let readyWaiters = waiters.filter { completedScanCount >= $0.targetCount } + waiters.removeAll { completedScanCount >= $0.targetCount } + + for waiter in readyWaiters { + waiter.continuation.resume() + } + } + + func waitForCompletedScans(atLeast targetCount: Int) async { + if completedScanCount >= targetCount { + return + } + + await withCheckedContinuation { continuation in + waiters.append((targetCount, continuation)) + } + } + } + var searchESPDevicesCallCount = 0 var stopESPDevicesSearchCallCount = 0 var scanDevicesDuration: TimeInterval = 0 var mockDevices = [ORESPDeviceMock()] + private let deviceScanCompletionTracker = DeviceScanCompletionTracker() func searchESPDevices(devicePrefix: String, transport: ESPTransport, security: ESPSecurity) async throws -> [ORESPDevice] { searchESPDevicesCallCount += 1 if scanDevicesDuration > 0 { try await Task.sleep(nanoseconds: UInt64(scanDevicesDuration * Double(NSEC_PER_SEC))) } + await deviceScanCompletionTracker.markScanCompleted() return mockDevices } func stopESPDevicesSearch() { stopESPDevicesSearchCallCount += 1 } + + func waitForDeviceScanCompletions(atLeast completedScanCount: Int) async { + await deviceScanCompletionTracker.waitForCompletedScans(atLeast: completedScanCount) + } } struct ESPProvisionProviderTest { @@ -119,29 +151,21 @@ struct ESPProvisionProviderTest { _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) + defer { provider.stopDevicesScan() } - var receivedData: [String:Any] = [:] - var receivedCallbackCount = 0 - - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) + } + let receivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.startBleScan) { provider.startDevicesScan() } - // Even if I wait for a moment, if no new devices are discovered, I should only received the callback once - try await Task.sleep(nanoseconds: UInt64(0.1 * Double(NSEC_PER_SEC))) + await espProvisionMock.waitForDeviceScanCompletions(atLeast: 3) - #expect(espProvisionMock.searchESPDevicesCallCount >= 1) - #expect(receivedCallbackCount == 1) + #expect(espProvisionMock.searchESPDevicesCallCount >= 3) + #expect(callbackRecorder.messageCount() == 1) #expect(receivedData["provider"] as? String == Providers.espprovision) #expect(receivedData["action"] as? String == Actions.startBleScan) @@ -156,37 +180,32 @@ struct ESPProvisionProviderTest { @Test func searchDevicesMultipleBatches() async throws { let espProvisionMock = ESPORProvisionManagerMock() + espProvisionMock.scanDevicesDuration = 0.05 let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) + defer { provider.stopDevicesScan() } - var receivedData: [String:Any] = [:] - var receivedCallbackCount = 0 - - var firstReceivedData: [String:Any] = [:] + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) + } - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - firstReceivedData = data - espProvisionMock.mockDevices.append(ORESPDeviceMock(name: "TestDevice2")) - continuation.resume() - } - } + let firstReceivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.startBleScan) { provider.startDevicesScan() } - // Need to wait a moment for second callback to be received - try await Task.sleep(nanoseconds: UInt64(0.1 * Double(NSEC_PER_SEC))) + espProvisionMock.mockDevices.append(ORESPDeviceMock(name: "TestDevice2")) + + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startBleScan, count: 2) + let receivedData = receivedMessages[1] - #expect(espProvisionMock.searchESPDevicesCallCount >= 2) - #expect(receivedCallbackCount == 2) + await espProvisionMock.waitForDeviceScanCompletions(atLeast: 4) + + #expect(espProvisionMock.searchESPDevicesCallCount >= 4) + #expect(callbackRecorder.messageCount() == 2) #expect(firstReceivedData["provider"] as? String == Providers.espprovision) #expect(firstReceivedData["action"] as? String == Actions.startBleScan) @@ -229,12 +248,7 @@ struct ESPProvisionProviderTest { try await Task.sleep(nanoseconds: UInt64(0.1 * Double(NSEC_PER_SEC))) - var receivedData: [String:Any] = [:] - await withCheckedContinuation { continuation in - provider.sendDataCallback = { data in - receivedData = data - continuation.resume() - } + let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.stopBleScan) { _ = provider.disable() } @@ -266,16 +280,7 @@ struct ESPProvisionProviderTest { try await Task.sleep(nanoseconds: UInt64(0.1 * Double(NSEC_PER_SEC))) - var receivedData: [String:Any] = [:] - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.stopBleScan) { provider.stopDevicesScan() } @@ -299,16 +304,7 @@ struct ESPProvisionProviderTest { _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - var receivedData: [String:Any] = [:] - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.stopBleScan) { provider.stopDevicesScan() } @@ -360,29 +356,18 @@ struct ESPProvisionProviderTest { _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - var receivedData: [String:Any] = [:] - var receivedCallbackCount = 0 - - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) + } + let receivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.stopBleScan) { provider.startDevicesScan() #expect(provider.bleScanning) } - // Wait long enough so scan can stop - try await Task.sleep(nanoseconds: UInt64(0.3 * Double(NSEC_PER_SEC))) - #expect(espProvisionMock.searchESPDevicesCallCount == 5) - #expect(receivedCallbackCount == 1) + #expect(callbackRecorder.messageCount() == 1) #expect(provider.bleScanning == false) #expect(receivedData["provider"] as? String == Providers.espprovision) @@ -398,60 +383,46 @@ struct ESPProvisionProviderTest { _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) + defer { provider.stopDevicesScan() } - var receivedData: [String:Any] = [:] - var receivedCallbackCount = 0 - - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + let firstCallbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + firstCallbackRecorder.record(data) + } + let firstReceivedData = await firstCallbackRecorder.waitForFirstMessage(matchingAction: Actions.startBleScan) { provider.startDevicesScan() } - try await Task.sleep(nanoseconds: UInt64(0.1 * Double(NSEC_PER_SEC))) + await espProvisionMock.waitForDeviceScanCompletions(atLeast: 3) - #expect(espProvisionMock.searchESPDevicesCallCount >= 1) - #expect(receivedCallbackCount == 1) + #expect(espProvisionMock.searchESPDevicesCallCount >= 3) + #expect(firstCallbackRecorder.messageCount() == 1) - #expect(receivedData["provider"] as? String == Providers.espprovision) - #expect(receivedData["action"] as? String == Actions.startBleScan) + #expect(firstReceivedData["provider"] as? String == Providers.espprovision) + #expect(firstReceivedData["action"] as? String == Actions.startBleScan) - try #require(receivedData["devices"] as? [[String:Any]] != nil) - var devices = receivedData["devices"] as! [[String:Any]] + try #require(firstReceivedData["devices"] as? [[String:Any]] != nil) + var devices = firstReceivedData["devices"] as! [[String:Any]] #expect(devices.count == 1) var device = devices.first! #expect(device["name"] as? String == "TestDevice") #expect(device["id"] != nil) // Calling it a second time while the first is still on-going - receivedData = [:] - receivedCallbackCount = 0 - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + let secondCallbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + secondCallbackRecorder.record(data) + } + let receivedData = await secondCallbackRecorder.waitForFirstMessage(matchingAction: Actions.startBleScan) { provider.startDevicesScan() } - try await Task.sleep(nanoseconds: UInt64(0.1 * Double(NSEC_PER_SEC))) + await espProvisionMock.waitForDeviceScanCompletions(atLeast: 5) - #expect(espProvisionMock.searchESPDevicesCallCount >= 2) - #expect(receivedCallbackCount == 1) + #expect(espProvisionMock.searchESPDevicesCallCount >= 5) + #expect(secondCallbackRecorder.messageCount() == 1) #expect(receivedData["provider"] as? String == Providers.espprovision) #expect(receivedData["action"] as? String == Actions.startBleScan) @@ -508,15 +479,12 @@ struct ESPProvisionProviderTest { _ = await getDevice(provider: provider) #expect(provider.bleScanning) - var receivedData: [String:Any] = [:] + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) + } - await withCheckedContinuation { continuation in - provider.sendDataCallback = { data in - if (data["action"] as? String) == Actions.connectToDevice { - receivedData = data - continuation.resume() - } - } + let receivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.connectToDevice) { provider.connectTo(deviceId: "INVALID_ID") } @@ -708,28 +676,18 @@ struct ESPProvisionProviderTest { try await connectToDevice(provider: provider, deviceId: device["id"] as! String) - var receivedData: [String:Any] = [:] - var receivedCallbackCount = 0 - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) + } + let receivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.stopWifiScan) { provider.startWifiScan() #expect(provider.wifiScanning) } - // Wait long enough so scan can stop - try await Task.sleep(nanoseconds: UInt64(0.3 * Double(NSEC_PER_SEC))) - #expect(mockDevice.scanWifiListCallCount == 5) - #expect(receivedCallbackCount == 1) + #expect(callbackRecorder.messageCount() == 1) #expect(provider.wifiScanning == false) #expect(receivedData["provider"] as? String == Providers.espprovision) @@ -760,16 +718,7 @@ struct ESPProvisionProviderTest { } try await Task.sleep(nanoseconds: UInt64(0.1 * Double(NSEC_PER_SEC))) - var receivedData: [String:Any] = [:] - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.stopWifiScan) { provider.stopWifiScan() } #expect(mockDevice.scanWifiListCallCount == 1) @@ -828,19 +777,12 @@ struct ESPProvisionProviderTest { let device = await getDevice(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) - var receivedData: [String:Any] = [:] - - - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) + } + var receivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.startWifiScan) { provider.startWifiScan() } #expect(provider.wifiScanning) @@ -900,18 +842,12 @@ struct ESPProvisionProviderTest { let device = await getDevice(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) + } - var receivedData: [String:Any] = [:] - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } - + var receivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.startWifiScan) { provider.startWifiScan() } #expect(provider.wifiScanning) @@ -1000,25 +936,19 @@ struct ESPProvisionProviderTest { try await connectToDevice(provider: provider, deviceId: device["id"] as! String) - var receivedData: [String:Any] = [:] - var receivedCallbackCount = 0 + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) + } - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + let receivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.provisionDevice) { provider.provisionDevice(userToken: "OAUTH_TOKEN") } #expect(receivedData["provider"] as? String == Providers.espprovision) #expect(receivedData["action"] as? String == Actions.provisionDevice) #expect(receivedData["connected"] as? Bool == true) + #expect(callbackRecorder.messageCount() == 1) #expect(mockDevice.receivedData.count == 3) @@ -1086,25 +1016,19 @@ struct ESPProvisionProviderTest { try await connectToDevice(provider: provider, deviceId: device["id"] as! String) - var receivedData: [String:Any] = [:] - var receivedCallbackCount = 0 + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) + } - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + let receivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.provisionDevice) { provider.provisionDevice(userToken: "OAUTH_TOKEN") } #expect(receivedData["provider"] as? String == Providers.espprovision) #expect(receivedData["action"] as? String == Actions.provisionDevice) #expect(receivedData["connected"] as? Bool == true) + #expect(callbackRecorder.messageCount() == 1) try #require(mockDevice.receivedData.count == 5) @@ -1232,19 +1156,7 @@ struct ESPProvisionProviderTest { _ = await getDevice(provider: provider) - var receivedData: [String:Any] = [:] - var receivedCallbackCount = 0 - - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.provisionDevice) { provider.provisionDevice(userToken: "OAUTH_TOKEN") } @@ -1274,19 +1186,7 @@ struct ESPProvisionProviderTest { try await connectToDevice(provider: provider, deviceId: device["id"] as! String) - var receivedData: [String:Any] = [:] - var receivedCallbackCount = 0 - - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.exitProvisioning) { provider.exitProvisioning() } From d8b781c7a8a000b95df5bb383a2a720d0f81d60d Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:31:48 +0200 Subject: [PATCH 07/29] More tests improvements by removing dependency on sleep --- Tests/ESPProvisionProviderTest.swift | 36 ++++++++++++++++++++++++---- Tests/ORESPDeviceMock.swift | 28 ++++++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index 36e80b4..8af5653 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -89,9 +89,22 @@ private final class CallbackRecorder { class ESPORProvisionManagerMock: ORESPProvisionManager { private actor DeviceScanCompletionTracker { + private var startedScanCount = 0 private var completedScanCount = 0 + private var startWaiters = [(targetCount: Int, continuation: CheckedContinuation)]() private var waiters = [(targetCount: Int, continuation: CheckedContinuation)]() + func markScanStarted() { + startedScanCount += 1 + + let readyWaiters = startWaiters.filter { startedScanCount >= $0.targetCount } + startWaiters.removeAll { startedScanCount >= $0.targetCount } + + for waiter in readyWaiters { + waiter.continuation.resume() + } + } + func markScanCompleted() { completedScanCount += 1 @@ -103,6 +116,16 @@ class ESPORProvisionManagerMock: ORESPProvisionManager { } } + func waitForStartedScans(atLeast targetCount: Int) async { + if startedScanCount >= targetCount { + return + } + + await withCheckedContinuation { continuation in + startWaiters.append((targetCount, continuation)) + } + } + func waitForCompletedScans(atLeast targetCount: Int) async { if completedScanCount >= targetCount { return @@ -124,6 +147,7 @@ class ESPORProvisionManagerMock: ORESPProvisionManager { func searchESPDevices(devicePrefix: String, transport: ESPTransport, security: ESPSecurity) async throws -> [ORESPDevice] { searchESPDevicesCallCount += 1 + await deviceScanCompletionTracker.markScanStarted() if scanDevicesDuration > 0 { try await Task.sleep(nanoseconds: UInt64(scanDevicesDuration * Double(NSEC_PER_SEC))) } @@ -135,6 +159,10 @@ class ESPORProvisionManagerMock: ORESPProvisionManager { stopESPDevicesSearchCallCount += 1 } + func waitForDeviceScanStarts(atLeast startedScanCount: Int) async { + await deviceScanCompletionTracker.waitForStartedScans(atLeast: startedScanCount) + } + func waitForDeviceScanCompletions(atLeast completedScanCount: Int) async { await deviceScanCompletionTracker.waitForCompletedScans(atLeast: completedScanCount) } @@ -245,8 +273,7 @@ struct ESPProvisionProviderTest { provider.sendDataCallback = { _ in receivedDeviceInformation = true } - - try await Task.sleep(nanoseconds: UInt64(0.1 * Double(NSEC_PER_SEC))) + await espProvisionMock.waitForDeviceScanStarts(atLeast: 1) let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.stopBleScan) { _ = provider.disable() @@ -277,8 +304,7 @@ struct ESPProvisionProviderTest { provider.sendDataCallback = { _ in receivedDeviceInformation = true } - - try await Task.sleep(nanoseconds: UInt64(0.1 * Double(NSEC_PER_SEC))) + await espProvisionMock.waitForDeviceScanStarts(atLeast: 1) let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.stopBleScan) { provider.stopDevicesScan() @@ -716,7 +742,7 @@ struct ESPProvisionProviderTest { provider.sendDataCallback = { _ in receivedDeviceInformation = true } - try await Task.sleep(nanoseconds: UInt64(0.1 * Double(NSEC_PER_SEC))) + await mockDevice.waitForWifiScanStarts(atLeast: 1) let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.stopWifiScan) { provider.stopWifiScan() diff --git a/Tests/ORESPDeviceMock.swift b/Tests/ORESPDeviceMock.swift index f6df118..be50db2 100644 --- a/Tests/ORESPDeviceMock.swift +++ b/Tests/ORESPDeviceMock.swift @@ -38,9 +38,22 @@ struct MockResponse { } private actor WifiScanCompletionTracker { + private var startedScanCount = 0 private var completedScanCount = 0 + private var startWaiters = [(targetCount: Int, continuation: CheckedContinuation)]() private var waiters = [(targetCount: Int, continuation: CheckedContinuation)]() + func markScanStarted() { + startedScanCount += 1 + + let readyWaiters = startWaiters.filter { startedScanCount >= $0.targetCount } + startWaiters.removeAll { startedScanCount >= $0.targetCount } + + for waiter in readyWaiters { + waiter.continuation.resume() + } + } + func markScanCompleted() { completedScanCount += 1 @@ -52,6 +65,16 @@ private actor WifiScanCompletionTracker { } } + func waitForStartedScans(atLeast targetCount: Int) async { + if startedScanCount >= targetCount { + return + } + + await withCheckedContinuation { continuation in + startWaiters.append((targetCount, continuation)) + } + } + func waitForCompletedScans(atLeast targetCount: Int) async { if completedScanCount >= targetCount { return @@ -118,6 +141,7 @@ class ORESPDeviceMock: ORESPDevice { func scanWifiList(completionHandler: @escaping ([ESPWifiNetwork]?, ESPWiFiScanError?) -> Void) { scanWifiListCallCount += 1 Task { + await wifiScanCompletionTracker.markScanStarted() if scanWifiDuration > 0 { try await Task.sleep(nanoseconds: UInt64(scanWifiDuration * Double(NSEC_PER_SEC))) } @@ -126,6 +150,10 @@ class ORESPDeviceMock: ORESPDevice { } } + func waitForWifiScanStarts(atLeast startedScanCount: Int) async { + await wifiScanCompletionTracker.waitForStartedScans(atLeast: startedScanCount) + } + func waitForWifiScanCompletions(atLeast completedScanCount: Int) async { await wifiScanCompletionTracker.waitForCompletedScans(atLeast: completedScanCount) } From b5fa5ee076c2a67a66b2c7f35044f05607cec1a9 Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:46:53 +0200 Subject: [PATCH 08/29] More test clean-up, fully using CallbackRecorder to wait for messages --- Tests/ESPProvisionProviderTest.swift | 188 +++++++++++++++------------ 1 file changed, 106 insertions(+), 82 deletions(-) diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index 8af5653..a48e494 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -25,32 +25,40 @@ import Testing @testable import ORLib private final class CallbackRecorder { + private enum WaitCondition { + case action(name: String, count: Int) + case orderedActions([String]) + } + + private struct WaitResult { + let matchedMessages: [[String:Any]] + let allMessagesAtMatchTime: [[String:Any]] + } + private let lock = NSLock() private var messages = [[String:Any]]() - private var awaitedAction: String? - private var awaitedCount: Int? - private var continuation: CheckedContinuation<[[String:Any]], Never>? + private var waitCondition: WaitCondition? + private var continuation: CheckedContinuation? func record(_ data: [String:Any]) { - var continuationToResume: CheckedContinuation<[[String:Any]], Never>? - var matchingMessages = [[String:Any]]() + var continuationToResume: CheckedContinuation? + var waitResult: WaitResult? lock.lock() messages.append(data) - if let awaitedAction, - let awaitedCount, - let continuation { - matchingMessages = recordedMessages(matchingAction: awaitedAction) - if matchingMessages.count >= awaitedCount { - self.awaitedAction = nil - self.awaitedCount = nil - self.continuation = nil - continuationToResume = continuation - } + if let waitCondition, + let continuation, + let matchedMessages = matchedMessages(for: waitCondition, in: messages) { + self.waitCondition = nil + self.continuation = nil + continuationToResume = continuation + waitResult = WaitResult(matchedMessages: matchedMessages, allMessagesAtMatchTime: messages) } lock.unlock() - continuationToResume?.resume(returning: matchingMessages) + if let continuationToResume, let waitResult { + continuationToResume.resume(returning: waitResult) + } } func waitForFirstMessage(matchingAction action: String, after trigger: @escaping () -> Void) async -> [String:Any] { @@ -59,16 +67,32 @@ private final class CallbackRecorder { } func waitForMessages(matchingAction action: String, count: Int, after trigger: (() -> Void)? = nil) async -> [[String:Any]] { + let waitResult = await wait(until: .action(name: action, count: count), after: trigger) + return waitResult.matchedMessages + } + + func waitForMessages(matchingActions actions: [String], after trigger: (() -> Void)? = nil) async -> [[String:Any]] { + let waitResult = await wait(until: .orderedActions(actions), after: trigger) + recordUnexpectedMessages(in: waitResult.allMessagesAtMatchTime, whileMatchingActions: actions) + return waitResult.matchedMessages + } + + func messageCount() -> Int { + lock.lock() + defer { lock.unlock() } + return messages.count + } + + private func wait(until waitCondition: WaitCondition, after trigger: (() -> Void)? = nil) async -> WaitResult { await withCheckedContinuation { continuation in lock.lock() - let matchingMessages = recordedMessages(matchingAction: action) - if matchingMessages.count >= count { + if let matchedMessages = matchedMessages(for: waitCondition, in: messages) { + let waitResult = WaitResult(matchedMessages: matchedMessages, allMessagesAtMatchTime: messages) lock.unlock() - continuation.resume(returning: matchingMessages) + continuation.resume(returning: waitResult) return } - awaitedAction = action - awaitedCount = count + self.waitCondition = waitCondition self.continuation = continuation lock.unlock() @@ -76,14 +100,52 @@ private final class CallbackRecorder { } } - func messageCount() -> Int { - lock.lock() - defer { lock.unlock() } - return messages.count + private func matchedMessages(for waitCondition: WaitCondition, in recordedMessages: [[String:Any]]) -> [[String:Any]]? { + switch waitCondition { + case let .action(name, count): + let matchingMessages = recordedMessages.filter { ($0["action"] as? String) == name } + guard matchingMessages.count >= count else { + return nil + } + return Array(matchingMessages.prefix(count)) + + case let .orderedActions(actions): + return orderedMessages(matchingActions: actions, in: recordedMessages) + } } - private func recordedMessages(matchingAction action: String) -> [[String:Any]] { - messages.filter { ($0["action"] as? String) == action } + private func orderedMessages(matchingActions actions: [String], in recordedMessages: [[String:Any]]) -> [[String:Any]]? { + var matchingMessages = [[String:Any]]() + var actionIndex = 0 + + for message in recordedMessages { + guard actionIndex < actions.count else { + break + } + + if (message["action"] as? String) == actions[actionIndex] { + matchingMessages.append(message) + actionIndex += 1 + } + } + + return actionIndex == actions.count ? matchingMessages : nil + } + + private func recordUnexpectedMessages(in recordedMessages: [[String:Any]], whileMatchingActions actions: [String]) { + var actionIndex = 0 + + for message in recordedMessages { + guard actionIndex < actions.count else { + break + } + + if (message["action"] as? String) == actions[actionIndex] { + actionIndex += 1 + } else { + Issue.record("Received an unexpected action: \(message)") + } + } } } @@ -1246,37 +1308,17 @@ struct ESPProvisionProviderTest { // MARK: helpers private func enable(provider: ESPProvisionProvider) async -> Bool { - var receivedData: [String:Any] = [:] - - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } - + let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.providerEnable) { provider.enable() } + #expect(receivedData["provider"] as? String == Providers.espprovision) + #expect(receivedData["action"] as? String == Actions.providerEnable) return (receivedData["success"] as! Bool) } private func getDevice(provider: ESPProvisionProvider) async -> [String: Any] { - var receivedData: [String:Any] = [:] - - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } - + let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.startBleScan) { provider.startDevicesScan() } @@ -1296,40 +1338,22 @@ struct ESPProvisionProviderTest { #expect(receivedData.count == 2) } - private func waitForMessage(provider: ESPProvisionProvider, expectingAction action: String, afterCalling trigger: (() -> (Void))) async -> [String:Any] { - var receivedData: [String:Any] = [:] - await withCheckedContinuation { continuation in - provider.sendDataCallback = { data in - if (data["action"] as? String) == action { - receivedData = data - continuation.resume() - } else { - Issue.record("Received an unexpected action: \(data)") - } - } - trigger() + private func waitForMessage(provider: ESPProvisionProvider, expectingAction action: String, afterCalling trigger: @escaping () -> Void) async -> [String:Any] { + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) } - return receivedData + + let receivedMessages = await callbackRecorder.waitForMessages(matchingActions: [action], after: trigger) + return receivedMessages[0] } - private func waitForMessages(provider: ESPProvisionProvider, expectingActions actions: [String], afterCalling trigger: (() -> (Void))) async -> [[String:Any]] { - var receivedData: [[String:Any]] = [] - var actionsIterator = actions.makeIterator() - var expectedAction = actionsIterator.next() - await withCheckedContinuation { continuation in - provider.sendDataCallback = { data in - if (data["action"] as? String) == expectedAction { - receivedData.append(data) - expectedAction = actionsIterator.next() - if expectedAction == nil { - continuation.resume() - } - } else { - Issue.record("Received an unexpected action: \(data)") - } - } - trigger() + private func waitForMessages(provider: ESPProvisionProvider, expectingActions actions: [String], afterCalling trigger: @escaping () -> Void) async -> [[String:Any]] { + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) } - return receivedData + + return await callbackRecorder.waitForMessages(matchingActions: actions, after: trigger) } } From b384f2aa98c039c8811fefd033fde260779ecb8f Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:50:22 +0200 Subject: [PATCH 09/29] Make ESPProvisionProvider tests deterministic with step-driven mocks --- Tests/ESPProvisionProviderTest.swift | 288 +++++++++++++++++++-------- Tests/ORESPDeviceMock.swift | 232 ++++++++++++++++++--- 2 files changed, 408 insertions(+), 112 deletions(-) diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index a48e494..05e488d 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -149,6 +149,47 @@ private final class CallbackRecorder { } } +private final class ManualDeviceScanController { + private let lock = NSLock() + private var manualMode = false + private var pendingRequests = [CheckedContinuation<[ORESPDevice], Error>]() + + func setManualMode(_ manualMode: Bool) { + lock.lock() + self.manualMode = manualMode + lock.unlock() + } + + func isManualModeEnabled() -> Bool { + lock.lock() + defer { lock.unlock() } + return manualMode + } + + func enqueuePendingRequest(_ continuation: CheckedContinuation<[ORESPDevice], Error>) { + lock.lock() + pendingRequests.append(continuation) + lock.unlock() + } + + func dequeuePendingRequest() -> CheckedContinuation<[ORESPDevice], Error>? { + lock.lock() + defer { lock.unlock() } + guard !pendingRequests.isEmpty else { + return nil + } + return pendingRequests.removeFirst() + } + + func drainPendingRequests() -> [CheckedContinuation<[ORESPDevice], Error>] { + lock.lock() + defer { lock.unlock() } + let continuations = pendingRequests + pendingRequests.removeAll() + return continuations + } +} + class ESPORProvisionManagerMock: ORESPProvisionManager { private actor DeviceScanCompletionTracker { private var startedScanCount = 0 @@ -203,13 +244,31 @@ class ESPORProvisionManagerMock: ORESPProvisionManager { var stopESPDevicesSearchCallCount = 0 var scanDevicesDuration: TimeInterval = 0 + var manualDeviceScans = false { + didSet { + manualDeviceScanController.setManualMode(manualDeviceScans) + } + } var mockDevices = [ORESPDeviceMock()] private let deviceScanCompletionTracker = DeviceScanCompletionTracker() + private let manualDeviceScanController = ManualDeviceScanController() func searchESPDevices(devicePrefix: String, transport: ESPTransport, security: ESPSecurity) async throws -> [ORESPDevice] { searchESPDevicesCallCount += 1 await deviceScanCompletionTracker.markScanStarted() + if manualDeviceScanController.isManualModeEnabled() { + do { + let devices = try await withCheckedThrowingContinuation { continuation in + manualDeviceScanController.enqueuePendingRequest(continuation) + } + await deviceScanCompletionTracker.markScanCompleted() + return devices + } catch { + await deviceScanCompletionTracker.markScanCompleted() + throw error + } + } if scanDevicesDuration > 0 { try await Task.sleep(nanoseconds: UInt64(scanDevicesDuration * Double(NSEC_PER_SEC))) } @@ -219,14 +278,34 @@ class ESPORProvisionManagerMock: ORESPProvisionManager { func stopESPDevicesSearch() { stopESPDevicesSearchCallCount += 1 + let pendingRequests = manualDeviceScanController.drainPendingRequests() + for continuation in pendingRequests { + continuation.resume(returning: mockDevices) + } + } + + func waitForDeviceSearchRequests(atLeast requestCount: Int) async { + await deviceScanCompletionTracker.waitForStartedScans(atLeast: requestCount) } - func waitForDeviceScanStarts(atLeast startedScanCount: Int) async { - await deviceScanCompletionTracker.waitForStartedScans(atLeast: startedScanCount) + func waitForCompletedDeviceSearchRequests(atLeast requestCount: Int) async { + await deviceScanCompletionTracker.waitForCompletedScans(atLeast: requestCount) } - func waitForDeviceScanCompletions(atLeast completedScanCount: Int) async { - await deviceScanCompletionTracker.waitForCompletedScans(atLeast: completedScanCount) + func completeNextDeviceScan(with devices: [ORESPDevice]? = nil) { + guard let continuation = manualDeviceScanController.dequeuePendingRequest() else { + Issue.record("No pending device scan to complete") + return + } + continuation.resume(returning: devices ?? mockDevices) + } + + func failNextDeviceScan(with error: Error) { + guard let continuation = manualDeviceScanController.dequeuePendingRequest() else { + Issue.record("No pending device scan to fail") + return + } + continuation.resume(throwing: error) } } @@ -252,7 +331,7 @@ struct ESPProvisionProviderTest { provider.startDevicesScan() } - await espProvisionMock.waitForDeviceScanCompletions(atLeast: 3) + await espProvisionMock.waitForCompletedDeviceSearchRequests(atLeast: 3) #expect(espProvisionMock.searchESPDevicesCallCount >= 3) #expect(callbackRecorder.messageCount() == 1) @@ -270,7 +349,7 @@ struct ESPProvisionProviderTest { @Test func searchDevicesMultipleBatches() async throws { let espProvisionMock = ESPORProvisionManagerMock() - espProvisionMock.scanDevicesDuration = 0.05 + espProvisionMock.manualDeviceScans = true let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) _ = provider.initialize() @@ -283,18 +362,21 @@ struct ESPProvisionProviderTest { callbackRecorder.record(data) } - let firstReceivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.startBleScan) { - provider.startDevicesScan() - } + provider.startDevicesScan() + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 1) + espProvisionMock.completeNextDeviceScan() + + let firstReceivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startBleScan, count: 1) + let firstReceivedData = firstReceivedMessages[0] espProvisionMock.mockDevices.append(ORESPDeviceMock(name: "TestDevice2")) + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 2) + espProvisionMock.completeNextDeviceScan() let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startBleScan, count: 2) let receivedData = receivedMessages[1] - await espProvisionMock.waitForDeviceScanCompletions(atLeast: 4) - - #expect(espProvisionMock.searchESPDevicesCallCount >= 4) + #expect(espProvisionMock.searchESPDevicesCallCount >= 2) #expect(callbackRecorder.messageCount() == 2) #expect(firstReceivedData["provider"] as? String == Providers.espprovision) @@ -322,7 +404,7 @@ struct ESPProvisionProviderTest { @Test func testDisableStopsDeviceSearch() async throws { let espProvisionMock = ESPORProvisionManagerMock() - espProvisionMock.scanDevicesDuration = 0.5 + espProvisionMock.manualDeviceScans = true let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) _ = provider.initialize() @@ -335,7 +417,7 @@ struct ESPProvisionProviderTest { provider.sendDataCallback = { _ in receivedDeviceInformation = true } - await espProvisionMock.waitForDeviceScanStarts(atLeast: 1) + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 1) let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.stopBleScan) { _ = provider.disable() @@ -353,7 +435,7 @@ struct ESPProvisionProviderTest { @Test func testStopDeviceSearch() async throws { let espProvisionMock = ESPORProvisionManagerMock() - espProvisionMock.scanDevicesDuration = 0.5 + espProvisionMock.manualDeviceScans = true let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) _ = provider.initialize() @@ -366,7 +448,7 @@ struct ESPProvisionProviderTest { provider.sendDataCallback = { _ in receivedDeviceInformation = true } - await espProvisionMock.waitForDeviceScanStarts(atLeast: 1) + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 1) let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.stopBleScan) { provider.stopDevicesScan() @@ -465,7 +547,7 @@ struct ESPProvisionProviderTest { @Test func multipleSearchDevices() async throws { let espProvisionMock = ESPORProvisionManagerMock() - espProvisionMock.scanDevicesDuration = 0.05 + espProvisionMock.manualDeviceScans = true let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) _ = provider.initialize() @@ -478,13 +560,14 @@ struct ESPProvisionProviderTest { firstCallbackRecorder.record(data) } - let firstReceivedData = await firstCallbackRecorder.waitForFirstMessage(matchingAction: Actions.startBleScan) { - provider.startDevicesScan() - } + provider.startDevicesScan() + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 1) + espProvisionMock.completeNextDeviceScan() - await espProvisionMock.waitForDeviceScanCompletions(atLeast: 3) + let firstReceivedMessages = await firstCallbackRecorder.waitForMessages(matchingAction: Actions.startBleScan, count: 1) + let firstReceivedData = firstReceivedMessages[0] - #expect(espProvisionMock.searchESPDevicesCallCount >= 3) + #expect(espProvisionMock.searchESPDevicesCallCount >= 1) #expect(firstCallbackRecorder.messageCount() == 1) #expect(firstReceivedData["provider"] as? String == Providers.espprovision) @@ -503,13 +586,14 @@ struct ESPProvisionProviderTest { secondCallbackRecorder.record(data) } - let receivedData = await secondCallbackRecorder.waitForFirstMessage(matchingAction: Actions.startBleScan) { - provider.startDevicesScan() - } + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 2) + provider.startDevicesScan() + espProvisionMock.completeNextDeviceScan() - await espProvisionMock.waitForDeviceScanCompletions(atLeast: 5) + let receivedMessages = await secondCallbackRecorder.waitForMessages(matchingAction: Actions.startBleScan, count: 1) + let receivedData = receivedMessages[0] - #expect(espProvisionMock.searchESPDevicesCallCount >= 5) + #expect(espProvisionMock.searchESPDevicesCallCount >= 2) #expect(secondCallbackRecorder.messageCount() == 1) #expect(receivedData["provider"] as? String == Providers.espprovision) @@ -618,6 +702,7 @@ struct ESPProvisionProviderTest { @Test func wifiScan() async throws { let espProvisionMock = ESPORProvisionManagerMock() let mockDevice = ORESPDeviceMock() + mockDevice.manualWifiScans = true espProvisionMock.mockDevices = [mockDevice] let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) @@ -635,10 +720,17 @@ struct ESPProvisionProviderTest { callbackRecorder.record(data) } - let receivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.startWifiScan) { - provider.startWifiScan() - } + provider.startWifiScan() + await mockDevice.waitForWifiScanStarts(atLeast: 1) + mockDevice.completeNextWifiScan() + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startWifiScan, count: 1) + let receivedData = receivedMessages[0] + + await mockDevice.waitForWifiScanStarts(atLeast: 2) + mockDevice.completeNextWifiScan() + await mockDevice.waitForWifiScanStarts(atLeast: 3) + mockDevice.completeNextWifiScan() await mockDevice.waitForWifiScanCompletions(atLeast: 3) #expect(mockDevice.scanWifiListCallCount >= 3) @@ -659,7 +751,7 @@ struct ESPProvisionProviderTest { @Test func wifiScanUpdatedRssi() async throws { let espProvisionMock = ESPORProvisionManagerMock() let mockDevice = ORESPDeviceMock() - mockDevice.scanWifiDuration = 0.1 + mockDevice.manualWifiScans = true mockDevice.networks = [ESPWifiNetwork(ssid: "SSID-1", rssi: -50)] espProvisionMock.mockDevices = [mockDevice] @@ -678,17 +770,24 @@ struct ESPProvisionProviderTest { callbackRecorder.record(data) } - let firstReceivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.startWifiScan) { - provider.startWifiScan() - } + provider.startWifiScan() + await mockDevice.waitForWifiScanStarts(atLeast: 1) + mockDevice.completeNextWifiScan() + + let firstReceivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startWifiScan, count: 1) + let firstReceivedData = firstReceivedMessages[0] mockDevice.networks = [ESPWifiNetwork(ssid: "SSID-1", rssi: -60)] + await mockDevice.waitForWifiScanStarts(atLeast: 2) + mockDevice.completeNextWifiScan() let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startWifiScan, count: 2) let receivedData = receivedMessages[1] - await mockDevice.waitForWifiScanCompletions(atLeast: 4) + await mockDevice.waitForWifiScanStarts(atLeast: 3) + mockDevice.completeNextWifiScan() + await mockDevice.waitForWifiScanCompletions(atLeast: 3) - #expect(mockDevice.scanWifiListCallCount >= 4) + #expect(mockDevice.scanWifiListCallCount >= 3) #expect(provider.wifiScanning) #expect(firstReceivedData["provider"] as? String == Providers.espprovision) @@ -786,7 +885,7 @@ struct ESPProvisionProviderTest { @Test func testStopWifiScan() async throws { let espProvisionMock = ESPORProvisionManagerMock() let mockDevice = ORESPDeviceMock() - mockDevice.scanWifiDuration = 0.5 + mockDevice.manualWifiScans = true espProvisionMock.mockDevices = [mockDevice] let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) @@ -855,6 +954,7 @@ struct ESPProvisionProviderTest { @Test func sendWifiConfigurationSuccess() async throws { let espProvisionMock = ESPORProvisionManagerMock() let mockDevice = ORESPDeviceMock() + mockDevice.manualWifiScans = true espProvisionMock.mockDevices = [mockDevice] let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) @@ -870,9 +970,12 @@ struct ESPProvisionProviderTest { callbackRecorder.record(data) } - var receivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.startWifiScan) { - provider.startWifiScan() - } + provider.startWifiScan() + await mockDevice.waitForWifiScanStarts(atLeast: 1) + mockDevice.completeNextWifiScan() + + let firstReceivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startWifiScan, count: 1) + var receivedData = firstReceivedMessages[0] #expect(provider.wifiScanning) let network = (receivedData["networks"] as! [[String:Any]]).first! @@ -919,6 +1022,7 @@ struct ESPProvisionProviderTest { let (provisionError, providerErrorCode) = errorTupple let espProvisionMock = ESPORProvisionManagerMock() let mockDevice = ORESPDeviceMock() + mockDevice.manualWifiScans = true mockDevice.provisionError = provisionError espProvisionMock.mockDevices = [mockDevice] @@ -935,9 +1039,12 @@ struct ESPProvisionProviderTest { callbackRecorder.record(data) } - var receivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.startWifiScan) { - provider.startWifiScan() - } + provider.startWifiScan() + await mockDevice.waitForWifiScanStarts(atLeast: 1) + mockDevice.completeNextWifiScan() + + let firstReceivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startWifiScan, count: 1) + var receivedData = firstReceivedMessages[0] #expect(provider.wifiScanning) let network = (receivedData["networks"] as! [[String:Any]]).first! @@ -996,6 +1103,7 @@ struct ESPProvisionProviderTest { @Test func provisionDeviceSuccess() async throws { let espProvisionMock = ESPORProvisionManagerMock() let mockDevice = ORESPDeviceMock() + mockDevice.manualSendDataResponses = true var expectedDeviceInfo = Response.DeviceInfo() expectedDeviceInfo.deviceID = "123456789ABC" @@ -1007,9 +1115,6 @@ struct ESPProvisionProviderTest { var expectedBackendConnectionStatus = Response.BackendConnectionStatus() expectedBackendConnectionStatus.status = .connected - mockDevice.addMockData(ORConfigChannelTest.responseData(body: .deviceInfo(expectedDeviceInfo))) - mockDevice.addMockData(ORConfigChannelTest.responseData(id: "1", body: .openRemoteConfig(expectedOpenRemoteConfig))) - mockDevice.addMockData(ORConfigChannelTest.responseData(id: "2", body: .backendConnectionStatus(expectedBackendConnectionStatus))) espProvisionMock.mockDevices = [mockDevice] let deviceProvisionAPIMock = DeviceProvisionAPIMock() @@ -1029,22 +1134,14 @@ struct ESPProvisionProviderTest { callbackRecorder.record(data) } - let receivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.provisionDevice) { - provider.provisionDevice(userToken: "OAUTH_TOKEN") - } - - #expect(receivedData["provider"] as? String == Providers.espprovision) - #expect(receivedData["action"] as? String == Actions.provisionDevice) - #expect(receivedData["connected"] as? Bool == true) - #expect(callbackRecorder.messageCount() == 1) - - #expect(mockDevice.receivedData.count == 3) + provider.provisionDevice(userToken: "OAUTH_TOKEN") - var request = try Request(serializedBytes: mockDevice.receivedData[0]) + var request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 0) #expect(request.id == "0") #expect(request.body == .deviceInfo(Request.DeviceInfo())) + mockDevice.completeNextSendDataRequest(data: ORConfigChannelTest.responseData(body: .deviceInfo(expectedDeviceInfo))) - request = try Request(serializedBytes: mockDevice.receivedData[1]) + request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 1) #expect(request.id == "1") if case let .openRemoteConfig(openRemoteConfig) = request.body { #expect(openRemoteConfig.realm == "master") @@ -1052,14 +1149,25 @@ struct ESPProvisionProviderTest { #expect(openRemoteConfig.user == expectedDeviceInfo.deviceID.lowercased(with: Locale(identifier: "en"))) #expect(openRemoteConfig.mqttPassword == deviceProvisionAPIMock.receivedPassword) #expect(openRemoteConfig.assetID == "AssetID") - } else { Issue.record("Received an unexpected response: \(request)") } + mockDevice.completeNextSendDataRequest(data: ORConfigChannelTest.responseData(id: "1", body: .openRemoteConfig(expectedOpenRemoteConfig))) - request = try Request(serializedBytes: mockDevice.receivedData[2]) + request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 2) #expect(request.id == "2") #expect(request.body == .backendConnectionStatus(Request.BackendConnectionStatus())) + mockDevice.completeNextSendDataRequest(data: ORConfigChannelTest.responseData(id: "2", body: .backendConnectionStatus(expectedBackendConnectionStatus))) + + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.provisionDevice, count: 1) + let receivedData = receivedMessages[0] + + #expect(receivedData["provider"] as? String == Providers.espprovision) + #expect(receivedData["action"] as? String == Actions.provisionDevice) + #expect(receivedData["connected"] as? Bool == true) + #expect(callbackRecorder.messageCount() == 1) + + #expect(mockDevice.receivedData.count == 3) #expect(deviceProvisionAPIMock.provisionCallCount == 1) #expect(deviceProvisionAPIMock.receivedModelName == expectedDeviceInfo.modelName) @@ -1071,6 +1179,7 @@ struct ESPProvisionProviderTest { @Test func provisionDeviceSuccessAfterMultipleStatusRequest() async throws { let espProvisionMock = ESPORProvisionManagerMock() let mockDevice = ORESPDeviceMock() + mockDevice.manualSendDataResponses = true var expectedDeviceInfo = Response.DeviceInfo() expectedDeviceInfo.deviceID = "123456789ABC" @@ -1085,11 +1194,6 @@ struct ESPProvisionProviderTest { var expectedBackendConnectionStatusFailure = Response.BackendConnectionStatus() expectedBackendConnectionStatusFailure.status = .disconnected - mockDevice.addMockData(ORConfigChannelTest.responseData(body: .deviceInfo(expectedDeviceInfo))) - mockDevice.addMockData(ORConfigChannelTest.responseData(id: "1", body: .openRemoteConfig(expectedOpenRemoteConfig))) - mockDevice.addMockData(ORConfigChannelTest.responseData(id: "2", body: .backendConnectionStatus(expectedBackendConnectionStatusFailure))) - mockDevice.addMockData(ORConfigChannelTest.responseData(id: "3", body: .backendConnectionStatus(expectedBackendConnectionStatusFailure))) - mockDevice.addMockData(ORConfigChannelTest.responseData(id: "4", body: .backendConnectionStatus(expectedBackendConnectionStatusSuccess))) espProvisionMock.mockDevices = [mockDevice] let deviceProvisionAPIMock = DeviceProvisionAPIMock() @@ -1109,22 +1213,14 @@ struct ESPProvisionProviderTest { callbackRecorder.record(data) } - let receivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.provisionDevice) { - provider.provisionDevice(userToken: "OAUTH_TOKEN") - } + provider.provisionDevice(userToken: "OAUTH_TOKEN") - #expect(receivedData["provider"] as? String == Providers.espprovision) - #expect(receivedData["action"] as? String == Actions.provisionDevice) - #expect(receivedData["connected"] as? Bool == true) - #expect(callbackRecorder.messageCount() == 1) - - try #require(mockDevice.receivedData.count == 5) - - var request = try Request(serializedBytes: mockDevice.receivedData[0]) + var request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 0) #expect(request.id == "0") #expect(request.body == .deviceInfo(Request.DeviceInfo())) + mockDevice.completeNextSendDataRequest(data: ORConfigChannelTest.responseData(body: .deviceInfo(expectedDeviceInfo))) - request = try Request(serializedBytes: mockDevice.receivedData[1]) + request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 1) #expect(request.id == "1") if case let .openRemoteConfig(openRemoteConfig) = request.body { #expect(openRemoteConfig.realm == "master") @@ -1132,17 +1228,32 @@ struct ESPProvisionProviderTest { #expect(openRemoteConfig.user == expectedDeviceInfo.deviceID.lowercased(with: Locale(identifier: "en"))) #expect(openRemoteConfig.mqttPassword == deviceProvisionAPIMock.receivedPassword) #expect(openRemoteConfig.assetID == "AssetID") - } else { Issue.record("Received an unexpected response: \(request)") } + mockDevice.completeNextSendDataRequest(data: ORConfigChannelTest.responseData(id: "1", body: .openRemoteConfig(expectedOpenRemoteConfig))) for i in 2...4 { - request = try Request(serializedBytes: mockDevice.receivedData[i]) + request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: i) #expect(request.id == String(i)) #expect(request.body == .backendConnectionStatus(Request.BackendConnectionStatus())) + + let response = i == 4 + ? ORConfigChannelTest.responseData(id: "4", body: .backendConnectionStatus(expectedBackendConnectionStatusSuccess)) + : ORConfigChannelTest.responseData(id: String(i), body: .backendConnectionStatus(expectedBackendConnectionStatusFailure)) + mockDevice.completeNextSendDataRequest(data: response) } + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.provisionDevice, count: 1) + let receivedData = receivedMessages[0] + + #expect(receivedData["provider"] as? String == Providers.espprovision) + #expect(receivedData["action"] as? String == Actions.provisionDevice) + #expect(receivedData["connected"] as? Bool == true) + #expect(callbackRecorder.messageCount() == 1) + + try #require(mockDevice.receivedData.count == 5) + #expect(deviceProvisionAPIMock.provisionCallCount == 1) #expect(deviceProvisionAPIMock.receivedModelName == expectedDeviceInfo.modelName) #expect(deviceProvisionAPIMock.receivedDeviceId == expectedDeviceInfo.deviceID) @@ -1258,6 +1369,7 @@ struct ESPProvisionProviderTest { @Test func exitProvisioningSuccess() async throws { let espProvisionMock = ESPORProvisionManagerMock() let mockDevice = ORESPDeviceMock() + mockDevice.manualSendDataResponses = true espProvisionMock.mockDevices = [mockDevice] @@ -1265,7 +1377,6 @@ struct ESPProvisionProviderTest { let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, deviceProvisionAPI: deviceProvisionAPIMock) - mockDevice.addMockData(ORConfigChannelTest.responseData(body: .exitProvisioning(Response.ExitProvisioning()))) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -1274,10 +1385,18 @@ struct ESPProvisionProviderTest { try await connectToDevice(provider: provider, deviceId: device["id"] as! String) - let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.exitProvisioning) { - provider.exitProvisioning() + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) } + provider.exitProvisioning() + _ = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 0) + mockDevice.completeNextSendDataRequest(data: ORConfigChannelTest.responseData(body: .exitProvisioning(Response.ExitProvisioning()))) + + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.exitProvisioning, count: 1) + let receivedData = receivedMessages[0] + #expect(receivedData["provider"] as? String == Providers.espprovision) #expect(receivedData["action"] as? String == Actions.exitProvisioning) #expect(receivedData["exit"] as? Bool == true) @@ -1307,6 +1426,11 @@ struct ESPProvisionProviderTest { // MARK: helpers + private func waitForNextPendingRequest(on mockDevice: ORESPDeviceMock, requestIndex: Int) async throws -> Request { + await mockDevice.waitForPendingSendDataRequests(atLeast: 1) + return try Request(serializedBytes: mockDevice.receivedData[requestIndex]) + } + private func enable(provider: ESPProvisionProvider) async -> Bool { let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.providerEnable) { provider.enable() diff --git a/Tests/ORESPDeviceMock.swift b/Tests/ORESPDeviceMock.swift index be50db2..d18f5b5 100644 --- a/Tests/ORESPDeviceMock.swift +++ b/Tests/ORESPDeviceMock.swift @@ -37,6 +37,148 @@ struct MockResponse { } } +private final class ManualWifiScanController { + typealias CompletionHandler = ([ESPWifiNetwork]?, ESPWiFiScanError?) -> Void + + private let lock = NSLock() + private var manualMode = false + private var pendingScans = [CompletionHandler]() + + func setManualMode(_ manualMode: Bool) { + lock.lock() + self.manualMode = manualMode + lock.unlock() + } + + func isManualModeEnabled() -> Bool { + lock.lock() + defer { lock.unlock() } + return manualMode + } + + func enqueuePendingScan(_ completionHandler: @escaping CompletionHandler) { + lock.lock() + pendingScans.append(completionHandler) + lock.unlock() + } + + func dequeuePendingScan() -> CompletionHandler? { + lock.lock() + defer { lock.unlock() } + guard !pendingScans.isEmpty else { + return nil + } + return pendingScans.removeFirst() + } +} + +private final class SendDataController { + typealias CompletionHandler = (Data?, ESPSessionError?) -> Void + + private struct PendingRequest { + let completionHandler: CompletionHandler + } + + private let lock = NSLock() + private var manualMode = false + private var pendingRequests = [PendingRequest]() + private var pendingRequestWaiters = [(targetCount: Int, continuation: CheckedContinuation)]() + private var mockResponses = [MockResponse]() + private var mockResponsesIndex: [MockResponse].Index? = nil + + func setManualMode(_ manualMode: Bool) { + lock.lock() + self.manualMode = manualMode + lock.unlock() + } + + func isManualModeEnabled() -> Bool { + lock.lock() + defer { lock.unlock() } + return manualMode + } + + func resetMockResponses() { + lock.lock() + mockResponses = [] + mockResponsesIndex = nil + lock.unlock() + } + + func addMockResponse(_ response: MockResponse) { + lock.lock() + mockResponses.append(response) + lock.unlock() + } + + func enqueuePendingRequest(_ completionHandler: @escaping CompletionHandler) { + var waitersToResume = [CheckedContinuation]() + + lock.lock() + pendingRequests.append(PendingRequest(completionHandler: completionHandler)) + let pendingRequestCount = pendingRequests.count + let readyWaiters = pendingRequestWaiters.filter { pendingRequestCount >= $0.targetCount } + pendingRequestWaiters.removeAll { pendingRequestCount >= $0.targetCount } + waitersToResume = readyWaiters.map(\.continuation) + lock.unlock() + + for continuation in waitersToResume { + continuation.resume() + } + } + + func waitForPendingRequests(atLeast targetCount: Int) async { + if hasPendingRequests(atLeast: targetCount) { + return + } + + await withCheckedContinuation { continuation in + lock.lock() + if pendingRequests.count >= targetCount { + lock.unlock() + continuation.resume() + return + } + pendingRequestWaiters.append((targetCount, continuation)) + lock.unlock() + } + } + + private func hasPendingRequests(atLeast targetCount: Int) -> Bool { + lock.lock() + defer { lock.unlock() } + return pendingRequests.count >= targetCount + } + + func dequeuePendingRequest() -> CompletionHandler? { + lock.lock() + defer { lock.unlock() } + guard !pendingRequests.isEmpty else { + return nil + } + return pendingRequests.removeFirst().completionHandler + } + + func getNextMockResponse() -> MockResponse? { + lock.lock() + defer { lock.unlock() } + if mockResponses.isEmpty { + return nil + } + if let currentIndex = mockResponsesIndex { + let nextIndex = mockResponses.index(after: currentIndex) + if nextIndex >= mockResponses.endIndex { + mockResponsesIndex = mockResponses.startIndex + } else { + mockResponsesIndex = nextIndex + } + } else { + mockResponsesIndex = mockResponses.startIndex + } + return mockResponses[mockResponsesIndex!] + } +} + private actor WifiScanCompletionTracker { private var startedScanCount = 0 private var completedScanCount = 0 @@ -88,13 +230,23 @@ private actor WifiScanCompletionTracker { class ORESPDeviceMock: ORESPDevice { - private var mockResponses: [MockResponse] = [] - private var mockResponsesIndex: [MockResponse].Index? = nil + private let manualWifiScanController = ManualWifiScanController() + private let sendDataController = SendDataController() private let wifiScanCompletionTracker = WifiScanCompletionTracker() var scanWifiListCallCount = 0 var scanWifiDuration: TimeInterval = 0 var networks = [ESPWifiNetwork(ssid: "SSID-1", rssi: -50)] + var manualWifiScans = false { + didSet { + manualWifiScanController.setManualMode(manualWifiScans) + } + } + var manualSendDataResponses = false { + didSet { + sendDataController.setManualMode(manualSendDataResponses) + } + } var provisionError: ESPProvisionError? var provisionCalledCount = 0 @@ -115,16 +267,15 @@ class ORESPDeviceMock: ORESPDevice { var name: String func resetMockResponses() { - mockResponses = [] - mockResponsesIndex = nil + sendDataController.resetMockResponses() } func addMockData(_ data: Data, delay: TimeInterval = 0) { - mockResponses.append(MockResponse(mockData: data, delay: delay)) + sendDataController.addMockResponse(MockResponse(mockData: data, delay: delay)) } func addMockError(_ error: ESPSessionError, delay: TimeInterval = 0) { - mockResponses.append(MockResponse(mockError: error, delay: delay)) + sendDataController.addMockResponse(MockResponse(mockError: error, delay: delay)) } func connect(delegate: (any ESPDeviceConnectionDelegate)?, completionHandler: @escaping (ESPSessionStatus) -> Void) { @@ -140,13 +291,33 @@ class ORESPDeviceMock: ORESPDevice { func scanWifiList(completionHandler: @escaping ([ESPWifiNetwork]?, ESPWiFiScanError?) -> Void) { scanWifiListCallCount += 1 + let scanResult = (networks, error: Optional.none) + let usesManualWifiScans = manualWifiScanController.isManualModeEnabled() Task { await wifiScanCompletionTracker.markScanStarted() + + if usesManualWifiScans { + manualWifiScanController.enqueuePendingScan(completionHandler) + return + } + if scanWifiDuration > 0 { try await Task.sleep(nanoseconds: UInt64(scanWifiDuration * Double(NSEC_PER_SEC))) } - completionHandler(networks, nil) await wifiScanCompletionTracker.markScanCompleted() + completionHandler(scanResult.0, scanResult.error) + } + } + + func completeNextWifiScan(networks: [ESPWifiNetwork]? = nil, error: ESPWiFiScanError? = nil) { + guard let completionHandler = manualWifiScanController.dequeuePendingScan() else { + Issue.record("No pending wifi scan to complete") + return + } + + Task { + await wifiScanCompletionTracker.markScanCompleted() + completionHandler(networks ?? self.networks, error) } } @@ -158,6 +329,18 @@ class ORESPDeviceMock: ORESPDevice { await wifiScanCompletionTracker.waitForCompletedScans(atLeast: completedScanCount) } + func waitForPendingSendDataRequests(atLeast requestCount: Int) async { + await sendDataController.waitForPendingRequests(atLeast: requestCount) + } + + func completeNextSendDataRequest(data: Data? = nil, error: ESPSessionError? = nil) { + guard let completionHandler = sendDataController.dequeuePendingRequest() else { + Issue.record("No pending sendData request to complete") + return + } + completionHandler(data, error) + } + func provision(ssid: String?, passPhrase: String?, threadOperationalDataset: Data?, completionHandler: @escaping (ESPProvisionStatus) -> Void) { provisionCalledCount += 1 provisionCalledParameters = (ssid, passPhrase) @@ -171,36 +354,25 @@ class ORESPDeviceMock: ORESPDevice { func sendData(path: String, data: Data, completionHandler: @escaping (Data?, ESPSessionError?) -> Void) { receivedData.append(data) - let response = getNextMockResponse() + if sendDataController.isManualModeEnabled() { + sendDataController.enqueuePendingRequest(completionHandler) + return + } + + let response = sendDataController.getNextMockResponse() guard let response else { completionHandler(nil, nil) return } - Task { - if response.delay > 0 { - try await Task.sleep(nanoseconds: UInt64(response.delay * Double(NSEC_PER_SEC))) - } + + if response.delay == 0 { completionHandler(response.mockData, response.mockError) + return } - } - private func getNextMockResponse() -> MockResponse? { - if mockResponses.isEmpty { - return nil - } else { - if mockResponsesIndex != nil { - mockResponsesIndex = mockResponses.index(after: mockResponsesIndex!) - if mockResponsesIndex! >= mockResponses.endIndex { - mockResponsesIndex = mockResponses.startIndex - return mockResponses[mockResponsesIndex!] - } else { - return mockResponses[mockResponsesIndex!] - } - } else { - mockResponsesIndex = mockResponses.startIndex - return mockResponses[mockResponsesIndex!] - } + Task { + try await Task.sleep(nanoseconds: UInt64(response.delay * Double(NSEC_PER_SEC))) + completionHandler(response.mockData, response.mockError) } - } } From a25c64a618829a6be54931a23e592a87b4489afb Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:56:26 +0200 Subject: [PATCH 10/29] Extract ORESPProvisionManager mock to its own file and use name aligned with other mock --- ORLib.xcodeproj/project.pbxproj | 44 +++--- Tests/ESPProvisionProviderTest.swift | 212 ++++---------------------- Tests/ORESPProvisionManagerMock.swift | 185 ++++++++++++++++++++++ 3 files changed, 235 insertions(+), 206 deletions(-) create mode 100644 Tests/ORESPProvisionManagerMock.swift diff --git a/ORLib.xcodeproj/project.pbxproj b/ORLib.xcodeproj/project.pbxproj index ae89f7b..c59c54b 100644 --- a/ORLib.xcodeproj/project.pbxproj +++ b/ORLib.xcodeproj/project.pbxproj @@ -76,10 +76,11 @@ 91F216622D887BCF008F9CA7 /* ORConfigChannelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216562D887BCF008F9CA7 /* ORConfigChannelProtocol.swift */; }; 91F216632D887BCF008F9CA7 /* DeviceConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216512D887BCF008F9CA7 /* DeviceConnection.swift */; }; 91F216642D887BCF008F9CA7 /* CallbackChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216502D887BCF008F9CA7 /* CallbackChannel.swift */; }; - 91F216692D887C53008F9CA7 /* DeviceProvisionAPIMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216652D887C53008F9CA7 /* DeviceProvisionAPIMock.swift */; }; - 91F2166A2D887C53008F9CA7 /* ESPProvisionProviderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216662D887C53008F9CA7 /* ESPProvisionProviderTest.swift */; }; - 91F2166B2D887C53008F9CA7 /* ORESPDeviceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216682D887C53008F9CA7 /* ORESPDeviceMock.swift */; }; - 91F2166C2D887C53008F9CA7 /* ORConfigChannelTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216672D887C53008F9CA7 /* ORConfigChannelTest.swift */; }; + 91F216692D887C53008F9CA7 /* DeviceProvisionAPIMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216652D887C53008F9CA7 /* DeviceProvisionAPIMock.swift */; }; + 91F2166A2D887C53008F9CA7 /* ESPProvisionProviderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216662D887C53008F9CA7 /* ESPProvisionProviderTest.swift */; }; + 91F2166B2D887C53008F9CA7 /* ORESPDeviceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216682D887C53008F9CA7 /* ORESPDeviceMock.swift */; }; + 91F2166C2D887C53008F9CA7 /* ORConfigChannelTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216672D887C53008F9CA7 /* ORConfigChannelTest.swift */; }; + 91F2166D2D887C53008F9CA7 /* ORESPProvisionManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F2166E2D887C53008F9CA7 /* ORESPProvisionManagerMock.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -178,10 +179,11 @@ 91F216562D887BCF008F9CA7 /* ORConfigChannelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORConfigChannelProtocol.swift; sourceTree = ""; }; 91F216572D887BCF008F9CA7 /* ORESPDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORESPDevice.swift; sourceTree = ""; }; 91F216582D887BCF008F9CA7 /* WifiProvisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WifiProvisioner.swift; sourceTree = ""; }; - 91F216652D887C53008F9CA7 /* DeviceProvisionAPIMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProvisionAPIMock.swift; sourceTree = ""; }; - 91F216662D887C53008F9CA7 /* ESPProvisionProviderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESPProvisionProviderTest.swift; sourceTree = ""; }; - 91F216672D887C53008F9CA7 /* ORConfigChannelTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORConfigChannelTest.swift; sourceTree = ""; }; - 91F216682D887C53008F9CA7 /* ORESPDeviceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORESPDeviceMock.swift; sourceTree = ""; }; + 91F216652D887C53008F9CA7 /* DeviceProvisionAPIMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProvisionAPIMock.swift; sourceTree = ""; }; + 91F216662D887C53008F9CA7 /* ESPProvisionProviderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESPProvisionProviderTest.swift; sourceTree = ""; }; + 91F216672D887C53008F9CA7 /* ORConfigChannelTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORConfigChannelTest.swift; sourceTree = ""; }; + 91F216682D887C53008F9CA7 /* ORESPDeviceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORESPDeviceMock.swift; sourceTree = ""; }; + 91F2166E2D887C53008F9CA7 /* ORESPProvisionManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORESPProvisionManagerMock.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -345,13 +347,14 @@ 91A9A8FA28BF6A4900DF8928 /* ConfigManagerTest.swift */, 91A9A90028BF6EA000DF8928 /* FileApiManager.swift */, 91AA79F228D628E9005B9913 /* Fixture.swift */, - 91F216652D887C53008F9CA7 /* DeviceProvisionAPIMock.swift */, - 91F216662D887C53008F9CA7 /* ESPProvisionProviderTest.swift */, - 91F216672D887C53008F9CA7 /* ORConfigChannelTest.swift */, - 91F216682D887C53008F9CA7 /* ORESPDeviceMock.swift */, - 9154E29F2D9EB0D50055E565 /* StringUtilsTest.swift */, - 9154E2A12D9EB3220055E565 /* URLTest.swift */, - ); + 91F216652D887C53008F9CA7 /* DeviceProvisionAPIMock.swift */, + 91F216662D887C53008F9CA7 /* ESPProvisionProviderTest.swift */, + 91F216672D887C53008F9CA7 /* ORConfigChannelTest.swift */, + 91F216682D887C53008F9CA7 /* ORESPDeviceMock.swift */, + 91F2166E2D887C53008F9CA7 /* ORESPProvisionManagerMock.swift */, + 9154E29F2D9EB0D50055E565 /* StringUtilsTest.swift */, + 9154E2A12D9EB3220055E565 /* URLTest.swift */, + ); path = Tests; sourceTree = ""; }; @@ -576,11 +579,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 91F216692D887C53008F9CA7 /* DeviceProvisionAPIMock.swift in Sources */, - 91F2166A2D887C53008F9CA7 /* ESPProvisionProviderTest.swift in Sources */, - 91F2166B2D887C53008F9CA7 /* ORESPDeviceMock.swift in Sources */, - 91F2166C2D887C53008F9CA7 /* ORConfigChannelTest.swift in Sources */, - 91A9A90128BF6EA000DF8928 /* FileApiManager.swift in Sources */, + 91F216692D887C53008F9CA7 /* DeviceProvisionAPIMock.swift in Sources */, + 91F2166A2D887C53008F9CA7 /* ESPProvisionProviderTest.swift in Sources */, + 91F2166B2D887C53008F9CA7 /* ORESPDeviceMock.swift in Sources */, + 91F2166C2D887C53008F9CA7 /* ORConfigChannelTest.swift in Sources */, + 91F2166D2D887C53008F9CA7 /* ORESPProvisionManagerMock.swift in Sources */, + 91A9A90128BF6EA000DF8928 /* FileApiManager.swift in Sources */, 9154E2A22D9EB3220055E565 /* URLTest.swift in Sources */, 9154E2A02D9EB0D50055E565 /* StringUtilsTest.swift in Sources */, 91A9A8FB28BF6A4900DF8928 /* ConfigManagerTest.swift in Sources */, diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index 05e488d..4426f80 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -149,172 +149,12 @@ private final class CallbackRecorder { } } -private final class ManualDeviceScanController { - private let lock = NSLock() - private var manualMode = false - private var pendingRequests = [CheckedContinuation<[ORESPDevice], Error>]() - - func setManualMode(_ manualMode: Bool) { - lock.lock() - self.manualMode = manualMode - lock.unlock() - } - - func isManualModeEnabled() -> Bool { - lock.lock() - defer { lock.unlock() } - return manualMode - } - - func enqueuePendingRequest(_ continuation: CheckedContinuation<[ORESPDevice], Error>) { - lock.lock() - pendingRequests.append(continuation) - lock.unlock() - } - - func dequeuePendingRequest() -> CheckedContinuation<[ORESPDevice], Error>? { - lock.lock() - defer { lock.unlock() } - guard !pendingRequests.isEmpty else { - return nil - } - return pendingRequests.removeFirst() - } - - func drainPendingRequests() -> [CheckedContinuation<[ORESPDevice], Error>] { - lock.lock() - defer { lock.unlock() } - let continuations = pendingRequests - pendingRequests.removeAll() - return continuations - } -} - -class ESPORProvisionManagerMock: ORESPProvisionManager { - private actor DeviceScanCompletionTracker { - private var startedScanCount = 0 - private var completedScanCount = 0 - private var startWaiters = [(targetCount: Int, continuation: CheckedContinuation)]() - private var waiters = [(targetCount: Int, continuation: CheckedContinuation)]() - - func markScanStarted() { - startedScanCount += 1 - - let readyWaiters = startWaiters.filter { startedScanCount >= $0.targetCount } - startWaiters.removeAll { startedScanCount >= $0.targetCount } - - for waiter in readyWaiters { - waiter.continuation.resume() - } - } - - func markScanCompleted() { - completedScanCount += 1 - - let readyWaiters = waiters.filter { completedScanCount >= $0.targetCount } - waiters.removeAll { completedScanCount >= $0.targetCount } - - for waiter in readyWaiters { - waiter.continuation.resume() - } - } - - func waitForStartedScans(atLeast targetCount: Int) async { - if startedScanCount >= targetCount { - return - } - - await withCheckedContinuation { continuation in - startWaiters.append((targetCount, continuation)) - } - } - - func waitForCompletedScans(atLeast targetCount: Int) async { - if completedScanCount >= targetCount { - return - } - - await withCheckedContinuation { continuation in - waiters.append((targetCount, continuation)) - } - } - } - - var searchESPDevicesCallCount = 0 - var stopESPDevicesSearchCallCount = 0 - - var scanDevicesDuration: TimeInterval = 0 - var manualDeviceScans = false { - didSet { - manualDeviceScanController.setManualMode(manualDeviceScans) - } - } - - var mockDevices = [ORESPDeviceMock()] - private let deviceScanCompletionTracker = DeviceScanCompletionTracker() - private let manualDeviceScanController = ManualDeviceScanController() - - func searchESPDevices(devicePrefix: String, transport: ESPTransport, security: ESPSecurity) async throws -> [ORESPDevice] { - searchESPDevicesCallCount += 1 - await deviceScanCompletionTracker.markScanStarted() - if manualDeviceScanController.isManualModeEnabled() { - do { - let devices = try await withCheckedThrowingContinuation { continuation in - manualDeviceScanController.enqueuePendingRequest(continuation) - } - await deviceScanCompletionTracker.markScanCompleted() - return devices - } catch { - await deviceScanCompletionTracker.markScanCompleted() - throw error - } - } - if scanDevicesDuration > 0 { - try await Task.sleep(nanoseconds: UInt64(scanDevicesDuration * Double(NSEC_PER_SEC))) - } - await deviceScanCompletionTracker.markScanCompleted() - return mockDevices - } - - func stopESPDevicesSearch() { - stopESPDevicesSearchCallCount += 1 - let pendingRequests = manualDeviceScanController.drainPendingRequests() - for continuation in pendingRequests { - continuation.resume(returning: mockDevices) - } - } - - func waitForDeviceSearchRequests(atLeast requestCount: Int) async { - await deviceScanCompletionTracker.waitForStartedScans(atLeast: requestCount) - } - - func waitForCompletedDeviceSearchRequests(atLeast requestCount: Int) async { - await deviceScanCompletionTracker.waitForCompletedScans(atLeast: requestCount) - } - - func completeNextDeviceScan(with devices: [ORESPDevice]? = nil) { - guard let continuation = manualDeviceScanController.dequeuePendingRequest() else { - Issue.record("No pending device scan to complete") - return - } - continuation.resume(returning: devices ?? mockDevices) - } - - func failNextDeviceScan(with error: Error) { - guard let continuation = manualDeviceScanController.dequeuePendingRequest() else { - Issue.record("No pending device scan to fail") - return - } - continuation.resume(throwing: error) - } -} - struct ESPProvisionProviderTest { // MARK: device scan @Test func searchDeviceSuccess() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) _ = provider.initialize() @@ -348,7 +188,7 @@ struct ESPProvisionProviderTest { } @Test func searchDevicesMultipleBatches() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() espProvisionMock.manualDeviceScans = true let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) @@ -403,7 +243,7 @@ struct ESPProvisionProviderTest { } @Test func testDisableStopsDeviceSearch() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() espProvisionMock.manualDeviceScans = true let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) @@ -434,7 +274,7 @@ struct ESPProvisionProviderTest { } @Test func testStopDeviceSearch() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() espProvisionMock.manualDeviceScans = true let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) @@ -466,7 +306,7 @@ struct ESPProvisionProviderTest { } @Test func testStopDeviceSearchNotStarted() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() espProvisionMock.scanDevicesDuration = 0.5 let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) @@ -488,7 +328,7 @@ struct ESPProvisionProviderTest { } @Test func searchDevicesTimesout() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() espProvisionMock.mockDevices = [] espProvisionMock.scanDevicesDuration = 0.05 @@ -517,7 +357,7 @@ struct ESPProvisionProviderTest { } @Test func searchDevicesMaximumIteration() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() espProvisionMock.mockDevices = [] espProvisionMock.scanDevicesDuration = 0.05 @@ -546,7 +386,7 @@ struct ESPProvisionProviderTest { } @Test func multipleSearchDevices() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() espProvisionMock.manualDeviceScans = true let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) @@ -610,7 +450,7 @@ struct ESPProvisionProviderTest { // MARK: Device connection @Test func connectToDevice() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) _ = provider.initialize() @@ -641,7 +481,7 @@ struct ESPProvisionProviderTest { } @Test func connectToDeviceFailsForInvalidId() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) _ = provider.initialize() @@ -678,7 +518,7 @@ struct ESPProvisionProviderTest { // MARK: Wifi scan @Test func startWifiScanNotConnected() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) _ = provider.initialize() @@ -700,7 +540,7 @@ struct ESPProvisionProviderTest { } @Test func wifiScan() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() mockDevice.manualWifiScans = true espProvisionMock.mockDevices = [mockDevice] @@ -749,7 +589,7 @@ struct ESPProvisionProviderTest { } @Test func wifiScanUpdatedRssi() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() mockDevice.manualWifiScans = true mockDevice.networks = [ESPWifiNetwork(ssid: "SSID-1", rssi: -50)] @@ -813,7 +653,7 @@ struct ESPProvisionProviderTest { } @Test func wifiScanTimesout() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() mockDevice.scanWifiDuration = 0.05 mockDevice.networks = [] @@ -848,7 +688,7 @@ struct ESPProvisionProviderTest { } @Test func wifiScanMaximumIterations() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() mockDevice.scanWifiDuration = 0.05 mockDevice.networks = [] @@ -883,7 +723,7 @@ struct ESPProvisionProviderTest { } @Test func testStopWifiScan() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() mockDevice.manualWifiScans = true espProvisionMock.mockDevices = [mockDevice] @@ -920,7 +760,7 @@ struct ESPProvisionProviderTest { } @Test func testStopWifiScanNotStarted() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() mockDevice.scanWifiDuration = 0.5 espProvisionMock.mockDevices = [mockDevice] @@ -952,7 +792,7 @@ struct ESPProvisionProviderTest { // TODO: start device scan during wifi search @Test func sendWifiConfigurationSuccess() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() mockDevice.manualWifiScans = true espProvisionMock.mockDevices = [mockDevice] @@ -1020,7 +860,7 @@ struct ESPProvisionProviderTest { ]) func sendWifiConfigurationProvisionErrors(errorTupple: (ESPProvisionError, ESPProviderErrorCode)) async throws { let (provisionError, providerErrorCode) = errorTupple - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() mockDevice.manualWifiScans = true mockDevice.provisionError = provisionError @@ -1075,7 +915,7 @@ struct ESPProvisionProviderTest { } @Test func sendWifiConfigurationNotConnected() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() espProvisionMock.mockDevices = [mockDevice] @@ -1101,7 +941,7 @@ struct ESPProvisionProviderTest { @Test func provisionDeviceSuccess() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() mockDevice.manualSendDataResponses = true @@ -1177,7 +1017,7 @@ struct ESPProvisionProviderTest { } @Test func provisionDeviceSuccessAfterMultipleStatusRequest() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() mockDevice.manualSendDataResponses = true @@ -1262,7 +1102,7 @@ struct ESPProvisionProviderTest { } @Test func provisionDeviceFailureTimeout() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() var expectedDeviceInfo = Response.DeviceInfo() @@ -1344,7 +1184,7 @@ struct ESPProvisionProviderTest { } @Test func provisionDeviceNotConnected() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() espProvisionMock.mockDevices = [mockDevice] @@ -1367,7 +1207,7 @@ struct ESPProvisionProviderTest { // MARK: Exit provisioning @Test func exitProvisioningSuccess() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() mockDevice.manualSendDataResponses = true @@ -1403,7 +1243,7 @@ struct ESPProvisionProviderTest { } @Test func exitProvisioningNotConnected() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) _ = provider.initialize() diff --git a/Tests/ORESPProvisionManagerMock.swift b/Tests/ORESPProvisionManagerMock.swift new file mode 100644 index 0000000..a2642a4 --- /dev/null +++ b/Tests/ORESPProvisionManagerMock.swift @@ -0,0 +1,185 @@ +/* + * Copyright 2017, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +@testable import ESPProvision +import Foundation +import Testing + +@testable import ORLib + +private final class ManualDeviceScanController { + private let lock = NSLock() + private var manualMode = false + private var pendingRequests = [CheckedContinuation<[ORESPDevice], Error>]() + + func setManualMode(_ manualMode: Bool) { + lock.lock() + self.manualMode = manualMode + lock.unlock() + } + + func isManualModeEnabled() -> Bool { + lock.lock() + defer { lock.unlock() } + return manualMode + } + + func enqueuePendingRequest(_ continuation: CheckedContinuation<[ORESPDevice], Error>) { + lock.lock() + pendingRequests.append(continuation) + lock.unlock() + } + + func dequeuePendingRequest() -> CheckedContinuation<[ORESPDevice], Error>? { + lock.lock() + defer { lock.unlock() } + guard !pendingRequests.isEmpty else { + return nil + } + return pendingRequests.removeFirst() + } + + func drainPendingRequests() -> [CheckedContinuation<[ORESPDevice], Error>] { + lock.lock() + defer { lock.unlock() } + let continuations = pendingRequests + pendingRequests.removeAll() + return continuations + } +} + +final class ORESPProvisionManagerMock: ORESPProvisionManager { + private actor DeviceScanCompletionTracker { + private var startedScanCount = 0 + private var completedScanCount = 0 + private var startWaiters = [(targetCount: Int, continuation: CheckedContinuation)]() + private var waiters = [(targetCount: Int, continuation: CheckedContinuation)]() + + func markScanStarted() { + startedScanCount += 1 + + let readyWaiters = startWaiters.filter { startedScanCount >= $0.targetCount } + startWaiters.removeAll { startedScanCount >= $0.targetCount } + + for waiter in readyWaiters { + waiter.continuation.resume() + } + } + + func markScanCompleted() { + completedScanCount += 1 + + let readyWaiters = waiters.filter { completedScanCount >= $0.targetCount } + waiters.removeAll { completedScanCount >= $0.targetCount } + + for waiter in readyWaiters { + waiter.continuation.resume() + } + } + + func waitForStartedScans(atLeast targetCount: Int) async { + if startedScanCount >= targetCount { + return + } + + await withCheckedContinuation { continuation in + startWaiters.append((targetCount, continuation)) + } + } + + func waitForCompletedScans(atLeast targetCount: Int) async { + if completedScanCount >= targetCount { + return + } + + await withCheckedContinuation { continuation in + waiters.append((targetCount, continuation)) + } + } + } + + var searchESPDevicesCallCount = 0 + var stopESPDevicesSearchCallCount = 0 + + var scanDevicesDuration: TimeInterval = 0 + var manualDeviceScans = false { + didSet { + manualDeviceScanController.setManualMode(manualDeviceScans) + } + } + + var mockDevices = [ORESPDeviceMock()] + private let deviceScanCompletionTracker = DeviceScanCompletionTracker() + private let manualDeviceScanController = ManualDeviceScanController() + + func searchESPDevices(devicePrefix: String, transport: ESPTransport, security: ESPSecurity) async throws -> [ORESPDevice] { + searchESPDevicesCallCount += 1 + await deviceScanCompletionTracker.markScanStarted() + if manualDeviceScanController.isManualModeEnabled() { + do { + let devices = try await withCheckedThrowingContinuation { continuation in + manualDeviceScanController.enqueuePendingRequest(continuation) + } + await deviceScanCompletionTracker.markScanCompleted() + return devices + } catch { + await deviceScanCompletionTracker.markScanCompleted() + throw error + } + } + if scanDevicesDuration > 0 { + try await Task.sleep(nanoseconds: UInt64(scanDevicesDuration * Double(NSEC_PER_SEC))) + } + await deviceScanCompletionTracker.markScanCompleted() + return mockDevices + } + + func stopESPDevicesSearch() { + stopESPDevicesSearchCallCount += 1 + let pendingRequests = manualDeviceScanController.drainPendingRequests() + for continuation in pendingRequests { + continuation.resume(returning: mockDevices) + } + } + + func waitForDeviceSearchRequests(atLeast requestCount: Int) async { + await deviceScanCompletionTracker.waitForStartedScans(atLeast: requestCount) + } + + func waitForCompletedDeviceSearchRequests(atLeast requestCount: Int) async { + await deviceScanCompletionTracker.waitForCompletedScans(atLeast: requestCount) + } + + func completeNextDeviceScan(with devices: [ORESPDevice]? = nil) { + guard let continuation = manualDeviceScanController.dequeuePendingRequest() else { + Issue.record("No pending device scan to complete") + return + } + continuation.resume(returning: devices ?? mockDevices) + } + + func failNextDeviceScan(with error: Error) { + guard let continuation = manualDeviceScanController.dequeuePendingRequest() else { + Issue.record("No pending device scan to fail") + return + } + continuation.resume(throwing: error) + } +} From 82df9c01cae084f139a119f97732835b296fc0b0 Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:14:24 +0200 Subject: [PATCH 11/29] Add monotonic (non wall clock) time source that's iOS 15 compatible and advances if device is asleep --- ORLib.xcodeproj/project.pbxproj | 54 ++++++++++--------- .../ESPProvision/TimeSource.swift | 40 ++++++++++++++ 2 files changed, 69 insertions(+), 25 deletions(-) create mode 100644 ORLib/ConsoleProviders/ESPProvision/TimeSource.swift diff --git a/ORLib.xcodeproj/project.pbxproj b/ORLib.xcodeproj/project.pbxproj index c59c54b..ec4a678 100644 --- a/ORLib.xcodeproj/project.pbxproj +++ b/ORLib.xcodeproj/project.pbxproj @@ -71,12 +71,13 @@ 91F2165C2D887BCF008F9CA7 /* ORConfigChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216552D887BCF008F9CA7 /* ORConfigChannel.swift */; }; 91F2165E2D887BCF008F9CA7 /* DeviceRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216522D887BCF008F9CA7 /* DeviceRegistry.swift */; }; 91F2165F2D887BCF008F9CA7 /* ORESPDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216572D887BCF008F9CA7 /* ORESPDevice.swift */; }; - 91F216602D887BCF008F9CA7 /* WifiProvisioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216582D887BCF008F9CA7 /* WifiProvisioner.swift */; }; - 91F216612D887BCF008F9CA7 /* ESPProvisionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216532D887BCF008F9CA7 /* ESPProvisionProvider.swift */; }; - 91F216622D887BCF008F9CA7 /* ORConfigChannelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216562D887BCF008F9CA7 /* ORConfigChannelProtocol.swift */; }; - 91F216632D887BCF008F9CA7 /* DeviceConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216512D887BCF008F9CA7 /* DeviceConnection.swift */; }; - 91F216642D887BCF008F9CA7 /* CallbackChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216502D887BCF008F9CA7 /* CallbackChannel.swift */; }; - 91F216692D887C53008F9CA7 /* DeviceProvisionAPIMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216652D887C53008F9CA7 /* DeviceProvisionAPIMock.swift */; }; + 91F216602D887BCF008F9CA7 /* WifiProvisioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216582D887BCF008F9CA7 /* WifiProvisioner.swift */; }; + 91F216612D887BCF008F9CA7 /* ESPProvisionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216532D887BCF008F9CA7 /* ESPProvisionProvider.swift */; }; + 91F216622D887BCF008F9CA7 /* ORConfigChannelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216562D887BCF008F9CA7 /* ORConfigChannelProtocol.swift */; }; + 91F216632D887BCF008F9CA7 /* DeviceConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216512D887BCF008F9CA7 /* DeviceConnection.swift */; }; + 91F216642D887BCF008F9CA7 /* CallbackChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216502D887BCF008F9CA7 /* CallbackChannel.swift */; }; + 91F216702D887BCF008F9CA7 /* TimeSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216712D887BCF008F9CA7 /* TimeSource.swift */; }; + 91F216692D887C53008F9CA7 /* DeviceProvisionAPIMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216652D887C53008F9CA7 /* DeviceProvisionAPIMock.swift */; }; 91F2166A2D887C53008F9CA7 /* ESPProvisionProviderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216662D887C53008F9CA7 /* ESPProvisionProviderTest.swift */; }; 91F2166B2D887C53008F9CA7 /* ORESPDeviceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216682D887C53008F9CA7 /* ORESPDeviceMock.swift */; }; 91F2166C2D887C53008F9CA7 /* ORConfigChannelTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216672D887C53008F9CA7 /* ORConfigChannelTest.swift */; }; @@ -175,11 +176,12 @@ 91F216522D887BCF008F9CA7 /* DeviceRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRegistry.swift; sourceTree = ""; }; 91F216532D887BCF008F9CA7 /* ESPProvisionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESPProvisionProvider.swift; sourceTree = ""; }; 91F216542D887BCF008F9CA7 /* LoopDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopDetector.swift; sourceTree = ""; }; - 91F216552D887BCF008F9CA7 /* ORConfigChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORConfigChannel.swift; sourceTree = ""; }; - 91F216562D887BCF008F9CA7 /* ORConfigChannelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORConfigChannelProtocol.swift; sourceTree = ""; }; - 91F216572D887BCF008F9CA7 /* ORESPDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORESPDevice.swift; sourceTree = ""; }; - 91F216582D887BCF008F9CA7 /* WifiProvisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WifiProvisioner.swift; sourceTree = ""; }; - 91F216652D887C53008F9CA7 /* DeviceProvisionAPIMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProvisionAPIMock.swift; sourceTree = ""; }; + 91F216552D887BCF008F9CA7 /* ORConfigChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORConfigChannel.swift; sourceTree = ""; }; + 91F216562D887BCF008F9CA7 /* ORConfigChannelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORConfigChannelProtocol.swift; sourceTree = ""; }; + 91F216572D887BCF008F9CA7 /* ORESPDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORESPDevice.swift; sourceTree = ""; }; + 91F216582D887BCF008F9CA7 /* WifiProvisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WifiProvisioner.swift; sourceTree = ""; }; + 91F216712D887BCF008F9CA7 /* TimeSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeSource.swift; sourceTree = ""; }; + 91F216652D887C53008F9CA7 /* DeviceProvisionAPIMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProvisionAPIMock.swift; sourceTree = ""; }; 91F216662D887C53008F9CA7 /* ESPProvisionProviderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESPProvisionProviderTest.swift; sourceTree = ""; }; 91F216672D887C53008F9CA7 /* ORConfigChannelTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORConfigChannelTest.swift; sourceTree = ""; }; 91F216682D887C53008F9CA7 /* ORESPDeviceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORESPDeviceMock.swift; sourceTree = ""; }; @@ -366,13 +368,14 @@ 91F216522D887BCF008F9CA7 /* DeviceRegistry.swift */, 91F216532D887BCF008F9CA7 /* ESPProvisionProvider.swift */, 91F216542D887BCF008F9CA7 /* LoopDetector.swift */, - 91F216552D887BCF008F9CA7 /* ORConfigChannel.swift */, - 91F216562D887BCF008F9CA7 /* ORConfigChannelProtocol.swift */, - 91F216572D887BCF008F9CA7 /* ORESPDevice.swift */, - 91F216582D887BCF008F9CA7 /* WifiProvisioner.swift */, - 91DD55A02DE7306100957233 /* BLEPermissionsChecker.swift */, - 915CCC942EF163E8006B65AA /* DeviceProvisionAPI.swift */, - 915CCC962EF16456006B65AA /* DeviceProvision.swift */, + 91F216552D887BCF008F9CA7 /* ORConfigChannel.swift */, + 91F216562D887BCF008F9CA7 /* ORConfigChannelProtocol.swift */, + 91F216572D887BCF008F9CA7 /* ORESPDevice.swift */, + 91F216582D887BCF008F9CA7 /* WifiProvisioner.swift */, + 91F216712D887BCF008F9CA7 /* TimeSource.swift */, + 91DD55A02DE7306100957233 /* BLEPermissionsChecker.swift */, + 915CCC942EF163E8006B65AA /* DeviceProvisionAPI.swift */, + 915CCC962EF16456006B65AA /* DeviceProvision.swift */, ); path = ESPProvision; sourceTree = ""; @@ -550,13 +553,14 @@ 91F2165C2D887BCF008F9CA7 /* ORConfigChannel.swift in Sources */, 91DD55A12DE7306100957233 /* BLEPermissionsChecker.swift in Sources */, 91F2165E2D887BCF008F9CA7 /* DeviceRegistry.swift in Sources */, - 91F2165F2D887BCF008F9CA7 /* ORESPDevice.swift in Sources */, - 91F216602D887BCF008F9CA7 /* WifiProvisioner.swift in Sources */, - 91F216612D887BCF008F9CA7 /* ESPProvisionProvider.swift in Sources */, - 91F216622D887BCF008F9CA7 /* ORConfigChannelProtocol.swift in Sources */, - 91F216632D887BCF008F9CA7 /* DeviceConnection.swift in Sources */, - 915CCC952EF163E8006B65AA /* DeviceProvisionAPI.swift in Sources */, - 91F216642D887BCF008F9CA7 /* CallbackChannel.swift in Sources */, + 91F2165F2D887BCF008F9CA7 /* ORESPDevice.swift in Sources */, + 91F216602D887BCF008F9CA7 /* WifiProvisioner.swift in Sources */, + 91F216612D887BCF008F9CA7 /* ESPProvisionProvider.swift in Sources */, + 91F216622D887BCF008F9CA7 /* ORConfigChannelProtocol.swift in Sources */, + 91F216632D887BCF008F9CA7 /* DeviceConnection.swift in Sources */, + 91F216702D887BCF008F9CA7 /* TimeSource.swift in Sources */, + 915CCC952EF163E8006B65AA /* DeviceProvisionAPI.swift in Sources */, + 91F216642D887BCF008F9CA7 /* CallbackChannel.swift in Sources */, 4CBDF2CA2AE2869D00C7D94C /* ApiManager.swift in Sources */, 4CBDF2CB2AE2869D00C7D94C /* ORTextInput.swift in Sources */, 914348322F05400200D4B3D3 /* ORConfigChannelProtocol.proto in Sources */, diff --git a/ORLib/ConsoleProviders/ESPProvision/TimeSource.swift b/ORLib/ConsoleProviders/ESPProvision/TimeSource.swift new file mode 100644 index 0000000..0bc292d --- /dev/null +++ b/ORLib/ConsoleProviders/ESPProvision/TimeSource.swift @@ -0,0 +1,40 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import Foundation +import Darwin + +protocol TimeSource { + var now: TimeInterval { get } +} + +struct SystemTimeSource: TimeSource { + private static let timebaseInfo: mach_timebase_info_data_t = { + var timebaseInfo = mach_timebase_info_data_t() + mach_timebase_info(&timebaseInfo) + return timebaseInfo + }() + + // Use continuous system time so elapsed-time checks continue to advance while the device sleeps. + var now: TimeInterval { + let elapsedTicks = mach_continuous_time() + let elapsedNanoseconds = Double(elapsedTicks) * Double(Self.timebaseInfo.numer) / Double(Self.timebaseInfo.denom) + return elapsedNanoseconds / 1_000_000_000 + } +} From 1e6731e446be5610c2c3cfaaa2e6598508310291 Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:36:29 +0200 Subject: [PATCH 12/29] ESPProvisionProvider code now using the new time source instead of wall clock (Date.now) --- .../ESPProvision/DeviceProvision.swift | 8 +++-- .../ESPProvision/DeviceRegistry.swift | 10 ++++--- .../ESPProvision/ESPProvisionProvider.swift | 29 +++++++++++++------ .../ESPProvision/LoopDetector.swift | 10 ++++--- .../ESPProvision/WifiProvisioner.swift | 14 ++++++--- 5 files changed, 47 insertions(+), 24 deletions(-) diff --git a/ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift b/ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift index 1fe8ed8..cc91fa8 100644 --- a/ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift +++ b/ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift @@ -34,14 +34,16 @@ class DeviceProvision { private var deviceConnection: DeviceConnection? var callbackChannel: CallbackChannel? + private let timeSource: any TimeSource var apiURL: URL var deviceProvisionAPI: DeviceProvisionAPI var backendConnectionTimeout: TimeInterval = 60 - init (deviceConnection: DeviceConnection?, callbackChannel: CallbackChannel?, apiURL: URL) { + init (deviceConnection: DeviceConnection?, callbackChannel: CallbackChannel?, apiURL: URL, timeSource: any TimeSource = SystemTimeSource()) { self.deviceConnection = deviceConnection self.callbackChannel = callbackChannel + self.timeSource = timeSource self.apiURL = apiURL self.deviceProvisionAPI = DeviceProvisionAPIREST(apiURL: apiURL) } @@ -65,9 +67,9 @@ class DeviceProvision { var status = BackendConnectionStatus.connecting // TODO: what about other status values ? Is status connecting while it connects ? or disconnected ? -> test with real device - let startTime = Date.now + let startTime = timeSource.now while status != .connected { - if Date.now.timeIntervalSince(startTime) > backendConnectionTimeout { + if timeSource.now - startTime > backendConnectionTimeout { sendProvisionDeviceStatus(connected: false, error: .timeoutError, errorMessage: "Timeout waiting for backend to get connected") return } diff --git a/ORLib/ConsoleProviders/ESPProvision/DeviceRegistry.swift b/ORLib/ConsoleProviders/ESPProvision/DeviceRegistry.swift index 68e4371..0443cc5 100644 --- a/ORLib/ConsoleProviders/ESPProvision/DeviceRegistry.swift +++ b/ORLib/ConsoleProviders/ESPProvision/DeviceRegistry.swift @@ -64,13 +64,14 @@ class DeviceRegistry { var callbackChannel: CallbackChannel? + private let timeSource: any TimeSource private var loopDetector: LoopDetector var searchDeviceTimeout: TimeInterval { get { loopDetector.timeout } set { - self.loopDetector = LoopDetector(timeout: newValue, maxIterations: searchDeviceMaxIterations) + self.loopDetector = LoopDetector(timeout: newValue, maxIterations: searchDeviceMaxIterations, timeSource: timeSource) } } @@ -79,7 +80,7 @@ class DeviceRegistry { loopDetector.maxIterations } set { - self.loopDetector = LoopDetector(timeout: searchDeviceTimeout, maxIterations: newValue) + self.loopDetector = LoopDetector(timeout: searchDeviceTimeout, maxIterations: newValue, timeSource: timeSource) } } @@ -92,8 +93,9 @@ class DeviceRegistry { public private(set) var bleScanning = false - init(searchDeviceTimeout: TimeInterval, searchDeviceMaxIterations: Int) { - self.loopDetector = LoopDetector(timeout: searchDeviceTimeout, maxIterations: searchDeviceMaxIterations) + init(searchDeviceTimeout: TimeInterval, searchDeviceMaxIterations: Int, timeSource: any TimeSource = SystemTimeSource()) { + self.timeSource = timeSource + self.loopDetector = LoopDetector(timeout: searchDeviceTimeout, maxIterations: searchDeviceMaxIterations, timeSource: timeSource) } func enable() { diff --git a/ORLib/ConsoleProviders/ESPProvision/ESPProvisionProvider.swift b/ORLib/ConsoleProviders/ESPProvision/ESPProvisionProvider.swift index 75aeeff..12b3ac6 100644 --- a/ORLib/ConsoleProviders/ESPProvision/ESPProvisionProvider.swift +++ b/ORLib/ConsoleProviders/ESPProvision/ESPProvisionProvider.swift @@ -36,6 +36,7 @@ class ESPProvisionProvider: NSObject { private var searchWifiTimeout: TimeInterval = 120 private var searchWifiMaxIterations = 25 + private let timeSource: any TimeSource private let deviceRegistry: DeviceRegistry private var deviceConnection: DeviceConnection? @@ -48,7 +49,7 @@ class ESPProvisionProvider: NSObject { typealias DeviceProvisionFactory = () -> DeviceProvision private lazy var deviceProvisionFactory: DeviceProvisionFactory = { - DeviceProvision(deviceConnection: self.deviceConnection, callbackChannel: self.callbackChannel, apiURL: self.apiURL) + DeviceProvision(deviceConnection: self.deviceConnection, callbackChannel: self.callbackChannel, apiURL: self.apiURL, timeSource: self.timeSource) } private var callbackChannel: CallbackChannel? @@ -63,13 +64,20 @@ class ESPProvisionProvider: NSObject { } } - public override init() { - self.deviceRegistry = DeviceRegistry(searchDeviceTimeout: searchDeviceTimeout, searchDeviceMaxIterations: searchDeviceMaxIterations) + init(timeSource: any TimeSource = SystemTimeSource()) { + self.timeSource = timeSource + self.deviceRegistry = DeviceRegistry(searchDeviceTimeout: searchDeviceTimeout, + searchDeviceMaxIterations: searchDeviceMaxIterations, + timeSource: timeSource) super.init() } + public override convenience init() { + self.init(timeSource: SystemTimeSource()) + } + public convenience init(apiURL: URL = URL(string: "http://localhost:8080/api/master")!) { - self.init() + self.init(timeSource: SystemTimeSource()) self.apiURL = apiURL } @@ -174,7 +182,8 @@ class ESPProvisionProvider: NSObject { wifiProvisioner = WifiProvisioner(deviceConnection: deviceConnection, callbackChannel: callbackChannel, searchWifiTimeout: searchWifiTimeout, - searchWifiMaxIterations: searchWifiMaxIterations) + searchWifiMaxIterations: searchWifiMaxIterations, + timeSource: timeSource) } wifiProvisioner!.startWifiScan() } @@ -188,7 +197,8 @@ class ESPProvisionProvider: NSObject { wifiProvisioner = WifiProvisioner(deviceConnection: deviceConnection, callbackChannel: callbackChannel, searchWifiTimeout: searchWifiTimeout, - searchWifiMaxIterations: searchWifiMaxIterations) + searchWifiMaxIterations: searchWifiMaxIterations, + timeSource: timeSource) } wifiProvisioner?.sendWifiConfiguration(ssid: ssid, password: password) } @@ -228,8 +238,9 @@ extension ESPProvisionProvider { public convenience init(searchDeviceTimeout: TimeInterval = 120, searchDeviceMaxIterations: Int = 25, searchWifiTimeout: TimeInterval = 120, searchWifiMaxIterations: Int = 25, deviceProvisionAPI: DeviceProvisionAPI? = nil, backendConnectionTimeout: TimeInterval? = nil, - apiURL: URL = URL(string: "http://localhost:8080/api/master")!) { - self.init() + apiURL: URL = URL(string: "http://localhost:8080/api/master")!, + timeSource: (any TimeSource)? = nil) { + self.init(timeSource: timeSource ?? SystemTimeSource()) self.searchDeviceTimeout = searchDeviceTimeout self.searchDeviceMaxIterations = searchDeviceMaxIterations self.deviceRegistry.searchDeviceMaxIterations = searchDeviceMaxIterations @@ -239,7 +250,7 @@ extension ESPProvisionProvider { self.searchWifiMaxIterations = searchWifiMaxIterations self.deviceProvisionFactory = { - let deviceProvision = DeviceProvision(deviceConnection: self.deviceConnection, callbackChannel: self.callbackChannel, apiURL: apiURL) + let deviceProvision = DeviceProvision(deviceConnection: self.deviceConnection, callbackChannel: self.callbackChannel, apiURL: apiURL, timeSource: self.timeSource) if let deviceProvisionAPI { deviceProvision.deviceProvisionAPI = deviceProvisionAPI } diff --git a/ORLib/ConsoleProviders/ESPProvision/LoopDetector.swift b/ORLib/ConsoleProviders/ESPProvision/LoopDetector.swift index 92cc50f..dee2c95 100644 --- a/ORLib/ConsoleProviders/ESPProvision/LoopDetector.swift +++ b/ORLib/ConsoleProviders/ESPProvision/LoopDetector.swift @@ -23,16 +23,18 @@ class LoopDetector { let timeout: TimeInterval let maxIterations: Int - private var startTime: Date? + private let timeSource: any TimeSource + private var startTime: TimeInterval? private var iterationCount = 0 - init(timeout: TimeInterval = 120, maxIterations: Int = 25) { + init(timeout: TimeInterval = 120, maxIterations: Int = 25, timeSource: any TimeSource = SystemTimeSource()) { self.timeout = timeout self.maxIterations = maxIterations + self.timeSource = timeSource } func reset() { - startTime = .now + startTime = timeSource.now iterationCount = 0 } @@ -44,7 +46,7 @@ class LoopDetector { guard let startTime else { return true } - if Date.now.timeIntervalSince(startTime) > timeout { + if timeSource.now - startTime > timeout { return true } return false diff --git a/ORLib/ConsoleProviders/ESPProvision/WifiProvisioner.swift b/ORLib/ConsoleProviders/ESPProvision/WifiProvisioner.swift index a863f5c..0c4bc0f 100644 --- a/ORLib/ConsoleProviders/ESPProvision/WifiProvisioner.swift +++ b/ORLib/ConsoleProviders/ESPProvision/WifiProvisioner.swift @@ -30,13 +30,14 @@ class WifiProvisioner { private var deviceConnection: DeviceConnection? var callbackChannel: CallbackChannel? + private let timeSource: any TimeSource private var loopDetector: LoopDetector var searchWifiTimeout: TimeInterval { get { loopDetector.timeout } set { - self.loopDetector = LoopDetector(timeout: newValue, maxIterations: searchWifiMaxIterations) + self.loopDetector = LoopDetector(timeout: newValue, maxIterations: searchWifiMaxIterations, timeSource: timeSource) } } @@ -45,7 +46,7 @@ class WifiProvisioner { loopDetector.maxIterations } set { - self.loopDetector = LoopDetector(timeout: searchWifiTimeout, maxIterations: newValue) + self.loopDetector = LoopDetector(timeout: searchWifiTimeout, maxIterations: newValue, timeSource: timeSource) } } @@ -59,10 +60,15 @@ class WifiProvisioner { private var wifiNetworks = [ESPWifiNetwork]() - init(deviceConnection: DeviceConnection?, callbackChannel: CallbackChannel?, searchWifiTimeout: TimeInterval = 120, searchWifiMaxIterations: Int = 25) { + init(deviceConnection: DeviceConnection?, + callbackChannel: CallbackChannel?, + searchWifiTimeout: TimeInterval = 120, + searchWifiMaxIterations: Int = 25, + timeSource: any TimeSource = SystemTimeSource()) { self.deviceConnection = deviceConnection self.callbackChannel = callbackChannel - self.loopDetector = LoopDetector(timeout: searchWifiTimeout, maxIterations: searchWifiMaxIterations) + self.timeSource = timeSource + self.loopDetector = LoopDetector(timeout: searchWifiTimeout, maxIterations: searchWifiMaxIterations, timeSource: timeSource) } public func startWifiScan() { From 5e2c5d2a9c022dcb3a436a4ed36eefb5f771610f Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:43:59 +0200 Subject: [PATCH 13/29] Use instrumented time source in tests to avoid non determinism --- ORLib.xcodeproj/project.pbxproj | 40 ++++----- Tests/ESPProvisionProviderTest.swift | 116 ++++++++++++++++----------- Tests/TestTimeSource.swift | 43 ++++++++++ 3 files changed, 133 insertions(+), 66 deletions(-) create mode 100644 Tests/TestTimeSource.swift diff --git a/ORLib.xcodeproj/project.pbxproj b/ORLib.xcodeproj/project.pbxproj index ec4a678..d27d36c 100644 --- a/ORLib.xcodeproj/project.pbxproj +++ b/ORLib.xcodeproj/project.pbxproj @@ -79,9 +79,10 @@ 91F216702D887BCF008F9CA7 /* TimeSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216712D887BCF008F9CA7 /* TimeSource.swift */; }; 91F216692D887C53008F9CA7 /* DeviceProvisionAPIMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216652D887C53008F9CA7 /* DeviceProvisionAPIMock.swift */; }; 91F2166A2D887C53008F9CA7 /* ESPProvisionProviderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216662D887C53008F9CA7 /* ESPProvisionProviderTest.swift */; }; - 91F2166B2D887C53008F9CA7 /* ORESPDeviceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216682D887C53008F9CA7 /* ORESPDeviceMock.swift */; }; - 91F2166C2D887C53008F9CA7 /* ORConfigChannelTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216672D887C53008F9CA7 /* ORConfigChannelTest.swift */; }; - 91F2166D2D887C53008F9CA7 /* ORESPProvisionManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F2166E2D887C53008F9CA7 /* ORESPProvisionManagerMock.swift */; }; + 91F2166B2D887C53008F9CA7 /* ORESPDeviceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216682D887C53008F9CA7 /* ORESPDeviceMock.swift */; }; + 91F2166C2D887C53008F9CA7 /* ORConfigChannelTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216672D887C53008F9CA7 /* ORConfigChannelTest.swift */; }; + 91F2166D2D887C53008F9CA7 /* ORESPProvisionManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F2166E2D887C53008F9CA7 /* ORESPProvisionManagerMock.swift */; }; + 91F216722D887C53008F9CA7 /* TestTimeSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216732D887C53008F9CA7 /* TestTimeSource.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -182,10 +183,11 @@ 91F216582D887BCF008F9CA7 /* WifiProvisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WifiProvisioner.swift; sourceTree = ""; }; 91F216712D887BCF008F9CA7 /* TimeSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeSource.swift; sourceTree = ""; }; 91F216652D887C53008F9CA7 /* DeviceProvisionAPIMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProvisionAPIMock.swift; sourceTree = ""; }; - 91F216662D887C53008F9CA7 /* ESPProvisionProviderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESPProvisionProviderTest.swift; sourceTree = ""; }; - 91F216672D887C53008F9CA7 /* ORConfigChannelTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORConfigChannelTest.swift; sourceTree = ""; }; - 91F216682D887C53008F9CA7 /* ORESPDeviceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORESPDeviceMock.swift; sourceTree = ""; }; - 91F2166E2D887C53008F9CA7 /* ORESPProvisionManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORESPProvisionManagerMock.swift; sourceTree = ""; }; + 91F216662D887C53008F9CA7 /* ESPProvisionProviderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESPProvisionProviderTest.swift; sourceTree = ""; }; + 91F216672D887C53008F9CA7 /* ORConfigChannelTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORConfigChannelTest.swift; sourceTree = ""; }; + 91F216682D887C53008F9CA7 /* ORESPDeviceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORESPDeviceMock.swift; sourceTree = ""; }; + 91F2166E2D887C53008F9CA7 /* ORESPProvisionManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORESPProvisionManagerMock.swift; sourceTree = ""; }; + 91F216732D887C53008F9CA7 /* TestTimeSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestTimeSource.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -350,12 +352,13 @@ 91A9A90028BF6EA000DF8928 /* FileApiManager.swift */, 91AA79F228D628E9005B9913 /* Fixture.swift */, 91F216652D887C53008F9CA7 /* DeviceProvisionAPIMock.swift */, - 91F216662D887C53008F9CA7 /* ESPProvisionProviderTest.swift */, - 91F216672D887C53008F9CA7 /* ORConfigChannelTest.swift */, - 91F216682D887C53008F9CA7 /* ORESPDeviceMock.swift */, - 91F2166E2D887C53008F9CA7 /* ORESPProvisionManagerMock.swift */, - 9154E29F2D9EB0D50055E565 /* StringUtilsTest.swift */, - 9154E2A12D9EB3220055E565 /* URLTest.swift */, + 91F216662D887C53008F9CA7 /* ESPProvisionProviderTest.swift */, + 91F216672D887C53008F9CA7 /* ORConfigChannelTest.swift */, + 91F216682D887C53008F9CA7 /* ORESPDeviceMock.swift */, + 91F2166E2D887C53008F9CA7 /* ORESPProvisionManagerMock.swift */, + 91F216732D887C53008F9CA7 /* TestTimeSource.swift */, + 9154E29F2D9EB0D50055E565 /* StringUtilsTest.swift */, + 9154E2A12D9EB3220055E565 /* URLTest.swift */, ); path = Tests; sourceTree = ""; @@ -584,11 +587,12 @@ buildActionMask = 2147483647; files = ( 91F216692D887C53008F9CA7 /* DeviceProvisionAPIMock.swift in Sources */, - 91F2166A2D887C53008F9CA7 /* ESPProvisionProviderTest.swift in Sources */, - 91F2166B2D887C53008F9CA7 /* ORESPDeviceMock.swift in Sources */, - 91F2166C2D887C53008F9CA7 /* ORConfigChannelTest.swift in Sources */, - 91F2166D2D887C53008F9CA7 /* ORESPProvisionManagerMock.swift in Sources */, - 91A9A90128BF6EA000DF8928 /* FileApiManager.swift in Sources */, + 91F2166A2D887C53008F9CA7 /* ESPProvisionProviderTest.swift in Sources */, + 91F2166B2D887C53008F9CA7 /* ORESPDeviceMock.swift in Sources */, + 91F2166C2D887C53008F9CA7 /* ORConfigChannelTest.swift in Sources */, + 91F2166D2D887C53008F9CA7 /* ORESPProvisionManagerMock.swift in Sources */, + 91F216722D887C53008F9CA7 /* TestTimeSource.swift in Sources */, + 91A9A90128BF6EA000DF8928 /* FileApiManager.swift in Sources */, 9154E2A22D9EB3220055E565 /* URLTest.swift in Sources */, 9154E2A02D9EB0D50055E565 /* StringUtilsTest.swift in Sources */, 91A9A8FB28BF6A4900DF8928 /* ConfigManagerTest.swift in Sources */, diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index 4426f80..7071780 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -329,10 +329,11 @@ struct ESPProvisionProviderTest { @Test func searchDevicesTimesout() async throws { let espProvisionMock = ORESPProvisionManagerMock() + espProvisionMock.manualDeviceScans = true espProvisionMock.mockDevices = [] - espProvisionMock.scanDevicesDuration = 0.05 + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 0.2) + let provider = ESPProvisionProvider(searchDeviceTimeout: 0.2, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -342,12 +343,16 @@ struct ESPProvisionProviderTest { callbackRecorder.record(data) } - let receivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.stopBleScan) { - provider.startDevicesScan() - #expect(provider.bleScanning) - } + provider.startDevicesScan() + #expect(provider.bleScanning) + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 1) + timeSource.advance(by: 0.3) + espProvisionMock.completeNextDeviceScan(with: []) - #expect(espProvisionMock.searchESPDevicesCallCount >= 1) + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.stopBleScan, count: 1) + let receivedData = receivedMessages[0] + + #expect(espProvisionMock.searchESPDevicesCallCount == 1) #expect(callbackRecorder.messageCount() == 1) #expect(provider.bleScanning == false) @@ -358,8 +363,8 @@ struct ESPProvisionProviderTest { @Test func searchDevicesMaximumIteration() async throws { let espProvisionMock = ORESPProvisionManagerMock() + espProvisionMock.manualDeviceScans = true espProvisionMock.mockDevices = [] - espProvisionMock.scanDevicesDuration = 0.05 let provider = ESPProvisionProvider(searchDeviceTimeout: 120, searchDeviceMaxIterations: 5) _ = provider.initialize() @@ -371,11 +376,16 @@ struct ESPProvisionProviderTest { callbackRecorder.record(data) } - let receivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.stopBleScan) { - provider.startDevicesScan() - #expect(provider.bleScanning) + provider.startDevicesScan() + #expect(provider.bleScanning) + for i in 1...5 { + await espProvisionMock.waitForDeviceSearchRequests(atLeast: i) + espProvisionMock.completeNextDeviceScan(with: []) } + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.stopBleScan, count: 1) + let receivedData = receivedMessages[0] + #expect(espProvisionMock.searchESPDevicesCallCount == 5) #expect(callbackRecorder.messageCount() == 1) #expect(provider.bleScanning == false) @@ -655,11 +665,12 @@ struct ESPProvisionProviderTest { @Test func wifiScanTimesout() async throws { let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() - mockDevice.scanWifiDuration = 0.05 + mockDevice.manualWifiScans = true mockDevice.networks = [] espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 0.2) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 0.2, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -673,12 +684,16 @@ struct ESPProvisionProviderTest { callbackRecorder.record(data) } - let receivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.stopWifiScan) { - provider.startWifiScan() - #expect(provider.wifiScanning) - } + provider.startWifiScan() + #expect(provider.wifiScanning) + await mockDevice.waitForWifiScanStarts(atLeast: 1) + timeSource.advance(by: 0.3) + mockDevice.completeNextWifiScan(networks: []) + + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.stopWifiScan, count: 1) + let receivedData = receivedMessages[0] - #expect(mockDevice.scanWifiListCallCount >= 1) + #expect(mockDevice.scanWifiListCallCount == 1) #expect(callbackRecorder.messageCount() == 1) #expect(provider.wifiScanning == false) @@ -690,7 +705,7 @@ struct ESPProvisionProviderTest { @Test func wifiScanMaximumIterations() async throws { let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() - mockDevice.scanWifiDuration = 0.05 + mockDevice.manualWifiScans = true mockDevice.networks = [] espProvisionMock.mockDevices = [mockDevice] @@ -708,11 +723,16 @@ struct ESPProvisionProviderTest { callbackRecorder.record(data) } - let receivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.stopWifiScan) { - provider.startWifiScan() - #expect(provider.wifiScanning) + provider.startWifiScan() + #expect(provider.wifiScanning) + for i in 1...5 { + await mockDevice.waitForWifiScanStarts(atLeast: i) + mockDevice.completeNextWifiScan(networks: []) } + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.stopWifiScan, count: 1) + let receivedData = receivedMessages[0] + #expect(mockDevice.scanWifiListCallCount == 5) #expect(callbackRecorder.messageCount() == 1) #expect(provider.wifiScanning == false) @@ -1104,6 +1124,7 @@ struct ESPProvisionProviderTest { @Test func provisionDeviceFailureTimeout() async throws { let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() + mockDevice.manualSendDataResponses = true var expectedDeviceInfo = Response.DeviceInfo() expectedDeviceInfo.deviceID = "123456789ABC" @@ -1115,17 +1136,14 @@ struct ESPProvisionProviderTest { var expectedBackendConnectionStatusFailure = Response.BackendConnectionStatus() expectedBackendConnectionStatusFailure.status = .disconnected - mockDevice.addMockData(ORConfigChannelTest.responseData(body: .deviceInfo(expectedDeviceInfo))) - mockDevice.addMockData(ORConfigChannelTest.responseData(id: "1", body: .openRemoteConfig(expectedOpenRemoteConfig))) - mockDevice.addMockData(ORConfigChannelTest.responseData(id: "2", body: .backendConnectionStatus(expectedBackendConnectionStatusFailure)), delay: 0.2) - mockDevice.addMockData(ORConfigChannelTest.responseData(id: "3", body: .backendConnectionStatus(expectedBackendConnectionStatusFailure)), delay: 0.2) - mockDevice.addMockData(ORConfigChannelTest.responseData(id: "4", body: .backendConnectionStatus(expectedBackendConnectionStatusFailure)), delay: 0.2) espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() let deviceProvisionAPIMock = DeviceProvisionAPIMock() let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, - deviceProvisionAPI: deviceProvisionAPIMock, backendConnectionTimeout: 0.5) + deviceProvisionAPI: deviceProvisionAPIMock, backendConnectionTimeout: 0.5, + timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -1139,25 +1157,14 @@ struct ESPProvisionProviderTest { callbackRecorder.record(data) } - let receivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.provisionDevice) { - provider.provisionDevice(userToken: "OAUTH_TOKEN") - } - - #expect(receivedData["provider"] as? String == Providers.espprovision) - #expect(receivedData["action"] as? String == Actions.provisionDevice) - #expect(receivedData["connected"] as? Bool != nil) - #expect(receivedData["connected"] as? Bool == false) - #expect(receivedData["errorCode"] as? Int == ESPProviderErrorCode.timeoutError.rawValue) - #expect(callbackRecorder.messageCount() == 1) - - let requestCount = mockDevice.receivedData.count - try #require(requestCount >= 3) + provider.provisionDevice(userToken: "OAUTH_TOKEN") - var request = try Request(serializedBytes: mockDevice.receivedData[0]) + var request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 0) #expect(request.id == "0") #expect(request.body == .deviceInfo(Request.DeviceInfo())) + mockDevice.completeNextSendDataRequest(data: ORConfigChannelTest.responseData(body: .deviceInfo(expectedDeviceInfo))) - request = try Request(serializedBytes: mockDevice.receivedData[1]) + request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 1) #expect(request.id == "1") if case let .openRemoteConfig(openRemoteConfig) = request.body { #expect(openRemoteConfig.realm == "master") @@ -1169,12 +1176,25 @@ struct ESPProvisionProviderTest { } else { Issue.record("Received an unexpected response: \(request)") } + mockDevice.completeNextSendDataRequest(data: ORConfigChannelTest.responseData(id: "1", body: .openRemoteConfig(expectedOpenRemoteConfig))) - for i in 2... + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import Foundation + +@testable import ORLib + +final class TestTimeSource: TimeSource { + private let lock = NSLock() + private var currentTime: TimeInterval + + init(now: TimeInterval = 0) { + self.currentTime = now + } + + var now: TimeInterval { + lock.lock() + defer { lock.unlock() } + return currentTime + } + + func advance(by duration: TimeInterval) { + lock.lock() + currentTime += duration + lock.unlock() + } +} From 82cbeabfc563bcf36802c659e3e99546c5a10a67 Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:56:56 +0200 Subject: [PATCH 14/29] Remove unused code, not using time interval for tests --- Tests/ESPProvisionProviderTest.swift | 2 -- Tests/ORESPDeviceMock.swift | 4 ---- Tests/ORESPProvisionManagerMock.swift | 4 ---- 3 files changed, 10 deletions(-) diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index 7071780..ec6dc10 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -307,7 +307,6 @@ struct ESPProvisionProviderTest { @Test func testStopDeviceSearchNotStarted() async throws { let espProvisionMock = ORESPProvisionManagerMock() - espProvisionMock.scanDevicesDuration = 0.5 let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) _ = provider.initialize() @@ -782,7 +781,6 @@ struct ESPProvisionProviderTest { @Test func testStopWifiScanNotStarted() async throws { let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() - mockDevice.scanWifiDuration = 0.5 espProvisionMock.mockDevices = [mockDevice] let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) diff --git a/Tests/ORESPDeviceMock.swift b/Tests/ORESPDeviceMock.swift index d18f5b5..74b7948 100644 --- a/Tests/ORESPDeviceMock.swift +++ b/Tests/ORESPDeviceMock.swift @@ -235,7 +235,6 @@ class ORESPDeviceMock: ORESPDevice { private let wifiScanCompletionTracker = WifiScanCompletionTracker() var scanWifiListCallCount = 0 - var scanWifiDuration: TimeInterval = 0 var networks = [ESPWifiNetwork(ssid: "SSID-1", rssi: -50)] var manualWifiScans = false { didSet { @@ -301,9 +300,6 @@ class ORESPDeviceMock: ORESPDevice { return } - if scanWifiDuration > 0 { - try await Task.sleep(nanoseconds: UInt64(scanWifiDuration * Double(NSEC_PER_SEC))) - } await wifiScanCompletionTracker.markScanCompleted() completionHandler(scanResult.0, scanResult.error) } diff --git a/Tests/ORESPProvisionManagerMock.swift b/Tests/ORESPProvisionManagerMock.swift index a2642a4..44b0658 100644 --- a/Tests/ORESPProvisionManagerMock.swift +++ b/Tests/ORESPProvisionManagerMock.swift @@ -118,7 +118,6 @@ final class ORESPProvisionManagerMock: ORESPProvisionManager { var searchESPDevicesCallCount = 0 var stopESPDevicesSearchCallCount = 0 - var scanDevicesDuration: TimeInterval = 0 var manualDeviceScans = false { didSet { manualDeviceScanController.setManualMode(manualDeviceScans) @@ -144,9 +143,6 @@ final class ORESPProvisionManagerMock: ORESPProvisionManager { throw error } } - if scanDevicesDuration > 0 { - try await Task.sleep(nanoseconds: UInt64(scanDevicesDuration * Double(NSEC_PER_SEC))) - } await deviceScanCompletionTracker.markScanCompleted() return mockDevices } From b3d3a136484c95d5ffeaab6d816466b091a83df3 Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:19:11 +0200 Subject: [PATCH 15/29] Some pipeline runs have been stuck running tests forever, add timeouts to prevent that --- .github/workflows/ci_cd.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 4322554..87cdfee 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -66,9 +66,14 @@ jobs: swiftlint lint --no-cache --quiet --config .swiftlint.yml --reporter json ORLib > swiftlint.json || true - name: Build and test + timeout-minutes: 20 run: | xcodebuild test -project ORLib.xcodeproj -sdk iphoneos \ - -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.5' -scheme ORLib -resultBundlePath ORLib.xcresult + -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.5' -scheme ORLib \ + -test-timeouts-enabled YES \ + -default-test-execution-time-allowance 60 \ + -maximum-test-execution-time-allowance 60 \ + -resultBundlePath ORLib.xcresult - name: Upload XCResult bundle uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 From d0dd168f32d97260d9fc554ba52c6bf219220db6 Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:16:19 +0200 Subject: [PATCH 16/29] Fix race condition --- Tests/ORESPDeviceMock.swift | 37 ++++++++++++++++++++++++++- Tests/ORESPProvisionManagerMock.swift | 37 ++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/Tests/ORESPDeviceMock.swift b/Tests/ORESPDeviceMock.swift index 74b7948..05aeda0 100644 --- a/Tests/ORESPDeviceMock.swift +++ b/Tests/ORESPDeviceMock.swift @@ -43,6 +43,8 @@ private final class ManualWifiScanController { private let lock = NSLock() private var manualMode = false private var pendingScans = [CompletionHandler]() + private var enqueuedScanCount = 0 + private var scanWaiters = [(targetCount: Int, continuation: CheckedContinuation)]() func setManualMode(_ manualMode: Bool) { lock.lock() @@ -57,9 +59,19 @@ private final class ManualWifiScanController { } func enqueuePendingScan(_ completionHandler: @escaping CompletionHandler) { + var waitersToResume = [CheckedContinuation]() + lock.lock() pendingScans.append(completionHandler) + enqueuedScanCount += 1 + let readyWaiters = scanWaiters.filter { enqueuedScanCount >= $0.targetCount } + scanWaiters.removeAll { enqueuedScanCount >= $0.targetCount } + waitersToResume = readyWaiters.map(\.continuation) lock.unlock() + + for continuation in waitersToResume { + continuation.resume() + } } func dequeuePendingScan() -> CompletionHandler? { @@ -70,6 +82,29 @@ private final class ManualWifiScanController { } return pendingScans.removeFirst() } + + func waitForEnqueuedScans(atLeast targetCount: Int) async { + if hasEnqueuedScans(atLeast: targetCount) { + return + } + + await withCheckedContinuation { continuation in + lock.lock() + if enqueuedScanCount >= targetCount { + lock.unlock() + continuation.resume() + return + } + scanWaiters.append((targetCount, continuation)) + lock.unlock() + } + } + + private func hasEnqueuedScans(atLeast targetCount: Int) -> Bool { + lock.lock() + defer { lock.unlock() } + return enqueuedScanCount >= targetCount + } } private final class SendDataController { @@ -318,7 +353,7 @@ class ORESPDeviceMock: ORESPDevice { } func waitForWifiScanStarts(atLeast startedScanCount: Int) async { - await wifiScanCompletionTracker.waitForStartedScans(atLeast: startedScanCount) + await manualWifiScanController.waitForEnqueuedScans(atLeast: startedScanCount) } func waitForWifiScanCompletions(atLeast completedScanCount: Int) async { diff --git a/Tests/ORESPProvisionManagerMock.swift b/Tests/ORESPProvisionManagerMock.swift index 44b0658..1146594 100644 --- a/Tests/ORESPProvisionManagerMock.swift +++ b/Tests/ORESPProvisionManagerMock.swift @@ -28,6 +28,8 @@ private final class ManualDeviceScanController { private let lock = NSLock() private var manualMode = false private var pendingRequests = [CheckedContinuation<[ORESPDevice], Error>]() + private var enqueuedRequestCount = 0 + private var requestWaiters = [(targetCount: Int, continuation: CheckedContinuation)]() func setManualMode(_ manualMode: Bool) { lock.lock() @@ -42,9 +44,19 @@ private final class ManualDeviceScanController { } func enqueuePendingRequest(_ continuation: CheckedContinuation<[ORESPDevice], Error>) { + var waitersToResume = [CheckedContinuation]() + lock.lock() pendingRequests.append(continuation) + enqueuedRequestCount += 1 + let readyWaiters = requestWaiters.filter { enqueuedRequestCount >= $0.targetCount } + requestWaiters.removeAll { enqueuedRequestCount >= $0.targetCount } + waitersToResume = readyWaiters.map(\.continuation) lock.unlock() + + for continuation in waitersToResume { + continuation.resume() + } } func dequeuePendingRequest() -> CheckedContinuation<[ORESPDevice], Error>? { @@ -63,6 +75,29 @@ private final class ManualDeviceScanController { pendingRequests.removeAll() return continuations } + + func waitForEnqueuedRequests(atLeast targetCount: Int) async { + if hasEnqueuedRequests(atLeast: targetCount) { + return + } + + await withCheckedContinuation { continuation in + lock.lock() + if enqueuedRequestCount >= targetCount { + lock.unlock() + continuation.resume() + return + } + requestWaiters.append((targetCount, continuation)) + lock.unlock() + } + } + + private func hasEnqueuedRequests(atLeast targetCount: Int) -> Bool { + lock.lock() + defer { lock.unlock() } + return enqueuedRequestCount >= targetCount + } } final class ORESPProvisionManagerMock: ORESPProvisionManager { @@ -156,7 +191,7 @@ final class ORESPProvisionManagerMock: ORESPProvisionManager { } func waitForDeviceSearchRequests(atLeast requestCount: Int) async { - await deviceScanCompletionTracker.waitForStartedScans(atLeast: requestCount) + await manualDeviceScanController.waitForEnqueuedRequests(atLeast: requestCount) } func waitForCompletedDeviceSearchRequests(atLeast requestCount: Int) async { From 5fae2665f641d4c5c9917b179bacb6b57c794a4c Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Tue, 28 Apr 2026 08:35:40 +0200 Subject: [PATCH 17/29] Using 18.6 on simulator when running tests, got some errors of non available simulator on different runs before --- .github/workflows/ci_cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 87cdfee..e8626ab 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -69,7 +69,7 @@ jobs: timeout-minutes: 20 run: | xcodebuild test -project ORLib.xcodeproj -sdk iphoneos \ - -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.5' -scheme ORLib \ + -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.6' -scheme ORLib \ -test-timeouts-enabled YES \ -default-test-execution-time-allowance 60 \ -maximum-test-execution-time-allowance 60 \ From 880f0844732cd85848877d1265e22063ae0cbed7 Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Tue, 28 Apr 2026 09:13:58 +0200 Subject: [PATCH 18/29] Some tests tweaking, hopefully improving stability --- Tests/ESPProvisionProviderTest.swift | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index ec6dc10..27e0089 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -404,20 +404,20 @@ struct ESPProvisionProviderTest { provider.setProvisionManager(espProvisionMock) defer { provider.stopDevicesScan() } - let firstCallbackRecorder = CallbackRecorder() + let callbackRecorder = CallbackRecorder() provider.sendDataCallback = { data in - firstCallbackRecorder.record(data) + callbackRecorder.record(data) } provider.startDevicesScan() await espProvisionMock.waitForDeviceSearchRequests(atLeast: 1) espProvisionMock.completeNextDeviceScan() - let firstReceivedMessages = await firstCallbackRecorder.waitForMessages(matchingAction: Actions.startBleScan, count: 1) + let firstReceivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startBleScan, count: 1) let firstReceivedData = firstReceivedMessages[0] #expect(espProvisionMock.searchESPDevicesCallCount >= 1) - #expect(firstCallbackRecorder.messageCount() == 1) + #expect(callbackRecorder.messageCount() == 1) #expect(firstReceivedData["provider"] as? String == Providers.espprovision) #expect(firstReceivedData["action"] as? String == Actions.startBleScan) @@ -430,20 +430,15 @@ struct ESPProvisionProviderTest { #expect(device["id"] != nil) // Calling it a second time while the first is still on-going - let secondCallbackRecorder = CallbackRecorder() - provider.sendDataCallback = { data in - secondCallbackRecorder.record(data) - } - - await espProvisionMock.waitForDeviceSearchRequests(atLeast: 2) provider.startDevicesScan() + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 2) espProvisionMock.completeNextDeviceScan() - let receivedMessages = await secondCallbackRecorder.waitForMessages(matchingAction: Actions.startBleScan, count: 1) - let receivedData = receivedMessages[0] + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startBleScan, count: 2) + let receivedData = receivedMessages[1] #expect(espProvisionMock.searchESPDevicesCallCount >= 2) - #expect(secondCallbackRecorder.messageCount() == 1) + #expect(callbackRecorder.messageCount() == 2) #expect(receivedData["provider"] as? String == Providers.espprovision) #expect(receivedData["action"] as? String == Actions.startBleScan) @@ -829,12 +824,12 @@ struct ESPProvisionProviderTest { } provider.startWifiScan() + #expect(provider.wifiScanning) await mockDevice.waitForWifiScanStarts(atLeast: 1) mockDevice.completeNextWifiScan() let firstReceivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startWifiScan, count: 1) var receivedData = firstReceivedMessages[0] - #expect(provider.wifiScanning) let network = (receivedData["networks"] as! [[String:Any]]).first! @@ -898,12 +893,12 @@ struct ESPProvisionProviderTest { } provider.startWifiScan() + #expect(provider.wifiScanning) await mockDevice.waitForWifiScanStarts(atLeast: 1) mockDevice.completeNextWifiScan() let firstReceivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startWifiScan, count: 1) var receivedData = firstReceivedMessages[0] - #expect(provider.wifiScanning) let network = (receivedData["networks"] as! [[String:Any]]).first! From bd1270c34770e627081c763a53d5805e2e53bf4b Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:31:12 +0200 Subject: [PATCH 19/29] Changes that should prevent some additional race condition in tests --- Tests/ESPProvisionProviderTest.swift | 12 +++++++----- Tests/ORESPDeviceMock.swift | 3 ++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index 27e0089..cab4c14 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -596,7 +596,9 @@ struct ESPProvisionProviderTest { let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() mockDevice.manualWifiScans = true - mockDevice.networks = [ESPWifiNetwork(ssid: "SSID-1", rssi: -50)] + let initialNetworks = [ESPWifiNetwork(ssid: "SSID-1", rssi: -50)] + let updatedNetworks = [ESPWifiNetwork(ssid: "SSID-1", rssi: -60)] + mockDevice.networks = initialNetworks espProvisionMock.mockDevices = [mockDevice] let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) @@ -616,19 +618,19 @@ struct ESPProvisionProviderTest { provider.startWifiScan() await mockDevice.waitForWifiScanStarts(atLeast: 1) - mockDevice.completeNextWifiScan() + mockDevice.completeNextWifiScan(networks: initialNetworks) let firstReceivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startWifiScan, count: 1) let firstReceivedData = firstReceivedMessages[0] - mockDevice.networks = [ESPWifiNetwork(ssid: "SSID-1", rssi: -60)] + mockDevice.networks = updatedNetworks await mockDevice.waitForWifiScanStarts(atLeast: 2) - mockDevice.completeNextWifiScan() + mockDevice.completeNextWifiScan(networks: updatedNetworks) let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startWifiScan, count: 2) let receivedData = receivedMessages[1] await mockDevice.waitForWifiScanStarts(atLeast: 3) - mockDevice.completeNextWifiScan() + mockDevice.completeNextWifiScan(networks: updatedNetworks) await mockDevice.waitForWifiScanCompletions(atLeast: 3) #expect(mockDevice.scanWifiListCallCount >= 3) diff --git a/Tests/ORESPDeviceMock.swift b/Tests/ORESPDeviceMock.swift index 05aeda0..3b6d2d3 100644 --- a/Tests/ORESPDeviceMock.swift +++ b/Tests/ORESPDeviceMock.swift @@ -345,10 +345,11 @@ class ORESPDeviceMock: ORESPDevice { Issue.record("No pending wifi scan to complete") return } + let resolvedNetworks = networks ?? self.networks Task { await wifiScanCompletionTracker.markScanCompleted() - completionHandler(networks ?? self.networks, error) + completionHandler(resolvedNetworks, error) } } From 1d4ba2af2b4c98a4bffa8e572683aec8388d534d Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:24:26 +0200 Subject: [PATCH 20/29] Changes that should prevent some additional race condition in testss --- Tests/ESPProvisionProviderTest.swift | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index cab4c14..fb00833 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -83,6 +83,12 @@ private final class CallbackRecorder { return messages.count } + func messageCount(matchingAction action: String) -> Int { + lock.lock() + defer { lock.unlock() } + return messages.filter { ($0["action"] as? String) == action }.count + } + private func wait(until waitCondition: WaitCondition, after trigger: (() -> Void)? = nil) async -> WaitResult { await withCheckedContinuation { continuation in lock.lock() @@ -618,15 +624,15 @@ struct ESPProvisionProviderTest { provider.startWifiScan() await mockDevice.waitForWifiScanStarts(atLeast: 1) - mockDevice.completeNextWifiScan(networks: initialNetworks) - - let firstReceivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startWifiScan, count: 1) - let firstReceivedData = firstReceivedMessages[0] + let firstReceivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.startWifiScan) { + mockDevice.completeNextWifiScan(networks: initialNetworks) + } mockDevice.networks = updatedNetworks await mockDevice.waitForWifiScanStarts(atLeast: 2) - mockDevice.completeNextWifiScan(networks: updatedNetworks) - let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startWifiScan, count: 2) + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startWifiScan, count: 2) { + mockDevice.completeNextWifiScan(networks: updatedNetworks) + } let receivedData = receivedMessages[1] await mockDevice.waitForWifiScanStarts(atLeast: 3) @@ -1020,7 +1026,7 @@ struct ESPProvisionProviderTest { #expect(receivedData["provider"] as? String == Providers.espprovision) #expect(receivedData["action"] as? String == Actions.provisionDevice) #expect(receivedData["connected"] as? Bool == true) - #expect(callbackRecorder.messageCount() == 1) + #expect(callbackRecorder.messageCount(matchingAction: Actions.provisionDevice) == 1) #expect(mockDevice.receivedData.count == 3) @@ -1105,7 +1111,7 @@ struct ESPProvisionProviderTest { #expect(receivedData["provider"] as? String == Providers.espprovision) #expect(receivedData["action"] as? String == Actions.provisionDevice) #expect(receivedData["connected"] as? Bool == true) - #expect(callbackRecorder.messageCount() == 1) + #expect(callbackRecorder.messageCount(matchingAction: Actions.provisionDevice) == 1) try #require(mockDevice.receivedData.count == 5) @@ -1187,7 +1193,7 @@ struct ESPProvisionProviderTest { #expect(receivedData["connected"] as? Bool != nil) #expect(receivedData["connected"] as? Bool == false) #expect(receivedData["errorCode"] as? Int == ESPProviderErrorCode.timeoutError.rawValue) - #expect(callbackRecorder.messageCount() == 1) + #expect(callbackRecorder.messageCount(matchingAction: Actions.provisionDevice) == 1) #expect(mockDevice.receivedData.count == 3) From 77864977fe80d069e325a49f987be230afc1ca00 Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Mon, 4 May 2026 08:49:26 +0200 Subject: [PATCH 21/29] Some more try to avoid edge conditions in tests --- Tests/ESPProvisionProviderTest.swift | 99 ++++++++++++++++++---------- 1 file changed, 63 insertions(+), 36 deletions(-) diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index fb00833..66cd8c2 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -461,29 +461,41 @@ struct ESPProvisionProviderTest { @Test func connectToDevice() async throws { let espProvisionMock = ORESPProvisionManagerMock() + espProvisionMock.manualDeviceScans = true let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) + } + + provider.startDevicesScan() + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 1) + espProvisionMock.completeNextDeviceScan() + + let firstReceivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startBleScan, count: 1) + let device = (firstReceivedMessages[0]["devices"] as! [[String:Any]]).first! + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 2) #expect(provider.bleScanning) - let receivedMessages = await waitForMessages(provider: provider, expectingActions: [Actions.stopBleScan, Actions.connectToDevice]) { + let receivedMessages = await callbackRecorder.waitForMessages(matchingActions: [Actions.startBleScan, Actions.stopBleScan, Actions.connectToDevice]) { provider.connectTo(deviceId: device["id"] as! String) } #expect(provider.bleScanning == false) #expect(espProvisionMock.stopESPDevicesSearchCallCount == 1) - #expect(receivedMessages.count == 2) + #expect(receivedMessages.count == 3) - var receivedData = receivedMessages[0] + var receivedData = receivedMessages[1] #expect(receivedData["provider"] as? String == Providers.espprovision) #expect(receivedData["action"] as? String == Actions.stopBleScan) - receivedData = receivedMessages[1] + receivedData = receivedMessages[2] #expect(receivedData["provider"] as? String == Providers.espprovision) #expect(receivedData["action"] as? String == Actions.connectToDevice) #expect(receivedData["id"] as? String == device["id"] as? String) @@ -492,23 +504,29 @@ struct ESPProvisionProviderTest { @Test func connectToDeviceFailsForInvalidId() async throws { let espProvisionMock = ORESPProvisionManagerMock() + espProvisionMock.manualDeviceScans = true let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - _ = await getDevice(provider: provider) - #expect(provider.bleScanning) - let callbackRecorder = CallbackRecorder() provider.sendDataCallback = { data in callbackRecorder.record(data) } - let receivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.connectToDevice) { + provider.startDevicesScan() + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 1) + espProvisionMock.completeNextDeviceScan() + _ = await callbackRecorder.waitForMessages(matchingAction: Actions.startBleScan, count: 1) + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 2) + #expect(provider.bleScanning) + + let receivedMessages = await callbackRecorder.waitForMessages(matchingActions: [Actions.startBleScan, Actions.stopBleScan, Actions.connectToDevice]) { provider.connectTo(deviceId: "INVALID_ID") } + let receivedData = receivedMessages[2] #expect(espProvisionMock.stopESPDevicesSearchCallCount == 1) #expect(provider.bleScanning == false) @@ -535,7 +553,7 @@ struct ESPProvisionProviderTest { _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - _ = await getDevice(provider: provider) + _ = await discoverDeviceAndStopScan(provider: provider) provider.stopDevicesScan() @@ -560,7 +578,7 @@ struct ESPProvisionProviderTest { _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let device = await discoverDeviceAndStopScan(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) defer { provider.stopWifiScan() } @@ -612,7 +630,7 @@ struct ESPProvisionProviderTest { _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let device = await discoverDeviceAndStopScan(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) defer { provider.stopWifiScan() } @@ -677,7 +695,7 @@ struct ESPProvisionProviderTest { _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let device = await discoverDeviceAndStopScan(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) @@ -716,7 +734,7 @@ struct ESPProvisionProviderTest { _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let device = await discoverDeviceAndStopScan(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) @@ -755,7 +773,7 @@ struct ESPProvisionProviderTest { _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let device = await discoverDeviceAndStopScan(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) @@ -791,7 +809,7 @@ struct ESPProvisionProviderTest { _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let device = await discoverDeviceAndStopScan(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) @@ -823,7 +841,7 @@ struct ESPProvisionProviderTest { _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let device = await discoverDeviceAndStopScan(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) let callbackRecorder = CallbackRecorder() @@ -892,7 +910,7 @@ struct ESPProvisionProviderTest { _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let device = await discoverDeviceAndStopScan(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) let callbackRecorder = CallbackRecorder() @@ -945,7 +963,7 @@ struct ESPProvisionProviderTest { _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - _ = await getDevice(provider: provider) + _ = await discoverDeviceAndStopScan(provider: provider) provider.stopDevicesScan() @@ -986,7 +1004,7 @@ struct ESPProvisionProviderTest { _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let device = await discoverDeviceAndStopScan(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) @@ -1065,7 +1083,7 @@ struct ESPProvisionProviderTest { _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let device = await discoverDeviceAndStopScan(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) @@ -1149,7 +1167,7 @@ struct ESPProvisionProviderTest { _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let device = await discoverDeviceAndStopScan(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) @@ -1214,7 +1232,7 @@ struct ESPProvisionProviderTest { _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - _ = await getDevice(provider: provider) + _ = await discoverDeviceAndStopScan(provider: provider) let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.provisionDevice) { provider.provisionDevice(userToken: "OAUTH_TOKEN") @@ -1242,7 +1260,7 @@ struct ESPProvisionProviderTest { _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let device = await discoverDeviceAndStopScan(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) @@ -1271,7 +1289,7 @@ struct ESPProvisionProviderTest { _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - _ = await getDevice(provider: provider) + _ = await discoverDeviceAndStopScan(provider: provider) provider.stopDevicesScan() @@ -1302,25 +1320,35 @@ struct ESPProvisionProviderTest { return (receivedData["success"] as! Bool) } - private func getDevice(provider: ESPProvisionProvider) async -> [String: Any] { + private func discoverDeviceAndStopScan(provider: ESPProvisionProvider) async -> [String: Any] { let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.startBleScan) { provider.startDevicesScan() } + provider.stopDevicesScan() return (receivedData["devices"] as! [[String:Any]]).first! } private func connectToDevice(provider: ESPProvisionProvider, deviceId: String) async throws { - let receivedMessages = await waitForMessages(provider: provider, expectingActions: [Actions.stopBleScan, Actions.connectToDevice]) { - provider.connectTo(deviceId: deviceId) - } + if provider.bleScanning { + let receivedMessages = await waitForMessages(provider: provider, expectingActions: [Actions.stopBleScan, Actions.connectToDevice]) { + provider.connectTo(deviceId: deviceId) + } - #expect(receivedMessages.count == 2) + #expect(receivedMessages.count == 2) - let receivedData = receivedMessages[0] - #expect(receivedData["provider"] as? String == Providers.espprovision) - #expect(receivedData["action"] as? String == Actions.stopBleScan) - #expect(receivedData.count == 2) + let receivedData = receivedMessages[0] + #expect(receivedData["provider"] as? String == Providers.espprovision) + #expect(receivedData["action"] as? String == Actions.stopBleScan) + #expect(receivedData.count == 2) + } else { + let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.connectToDevice) { + provider.connectTo(deviceId: deviceId) + } + + #expect(receivedData["provider"] as? String == Providers.espprovision) + #expect(receivedData["action"] as? String == Actions.connectToDevice) + } } private func waitForMessage(provider: ESPProvisionProvider, expectingAction action: String, afterCalling trigger: @escaping () -> Void) async -> [String:Any] { @@ -1329,8 +1357,7 @@ struct ESPProvisionProviderTest { callbackRecorder.record(data) } - let receivedMessages = await callbackRecorder.waitForMessages(matchingActions: [action], after: trigger) - return receivedMessages[0] + return await callbackRecorder.waitForFirstMessage(matchingAction: action, after: trigger) } private func waitForMessages(provider: ESPProvisionProvider, expectingActions actions: [String], afterCalling trigger: @escaping () -> Void) async -> [[String:Any]] { From 1e99426d47f90308dadf6c751619d4f4a902f8a2 Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Mon, 4 May 2026 09:11:07 +0200 Subject: [PATCH 22/29] Some more try to avoid edge conditions in tests --- Tests/ESPProvisionProviderTest.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index 66cd8c2..14a5453 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -599,10 +599,9 @@ struct ESPProvisionProviderTest { mockDevice.completeNextWifiScan() await mockDevice.waitForWifiScanStarts(atLeast: 3) mockDevice.completeNextWifiScan() - await mockDevice.waitForWifiScanCompletions(atLeast: 3) + await mockDevice.waitForWifiScanStarts(atLeast: 4) #expect(mockDevice.scanWifiListCallCount >= 3) - #expect(provider.wifiScanning) #expect(receivedData["provider"] as? String == Providers.espprovision) #expect(receivedData["action"] as? String == Actions.startWifiScan) @@ -655,10 +654,9 @@ struct ESPProvisionProviderTest { await mockDevice.waitForWifiScanStarts(atLeast: 3) mockDevice.completeNextWifiScan(networks: updatedNetworks) - await mockDevice.waitForWifiScanCompletions(atLeast: 3) + await mockDevice.waitForWifiScanStarts(atLeast: 4) #expect(mockDevice.scanWifiListCallCount >= 3) - #expect(provider.wifiScanning) #expect(firstReceivedData["provider"] as? String == Providers.espprovision) #expect(firstReceivedData["action"] as? String == Actions.startWifiScan) From 6b303248c3ea0c2c04b664a8fbd95f89185a1cfd Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Mon, 4 May 2026 09:49:02 +0200 Subject: [PATCH 23/29] Some more try to avoid edge conditions in tests --- Tests/ESPProvisionProviderTest.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index 14a5453..f5a0da5 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -403,8 +403,9 @@ struct ESPProvisionProviderTest { @Test func multipleSearchDevices() async throws { let espProvisionMock = ORESPProvisionManagerMock() espProvisionMock.manualDeviceScans = true + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) From 59782596199c8066b2c8588ea996514654897db8 Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Mon, 4 May 2026 13:30:57 +0200 Subject: [PATCH 24/29] Some more try to avoid edge conditions in tests --- Tests/ESPProvisionProviderTest.swift | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index f5a0da5..d28b0d7 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -573,8 +573,9 @@ struct ESPProvisionProviderTest { let mockDevice = ORESPDeviceMock() mockDevice.manualWifiScans = true espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -624,8 +625,9 @@ struct ESPProvisionProviderTest { let updatedNetworks = [ESPWifiNetwork(ssid: "SSID-1", rssi: -60)] mockDevice.networks = initialNetworks espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -766,8 +768,9 @@ struct ESPProvisionProviderTest { let mockDevice = ORESPDeviceMock() mockDevice.manualWifiScans = true espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -834,8 +837,9 @@ struct ESPProvisionProviderTest { let mockDevice = ORESPDeviceMock() mockDevice.manualWifiScans = true espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -903,8 +907,9 @@ struct ESPProvisionProviderTest { mockDevice.manualWifiScans = true mockDevice.provisionError = provisionError espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) From 31d2cf88b1117558b5b03e97aa18104c10f0a76e Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Mon, 4 May 2026 15:39:48 +0200 Subject: [PATCH 25/29] Some more try to avoid edge conditions in tests --- Tests/ESPProvisionProviderTest.swift | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index d28b0d7..80e3be0 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -161,8 +161,10 @@ struct ESPProvisionProviderTest { @Test func searchDeviceSuccess() async throws { let espProvisionMock = ORESPProvisionManagerMock() + espProvisionMock.manualDeviceScans = true + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -173,11 +175,18 @@ struct ESPProvisionProviderTest { callbackRecorder.record(data) } - let receivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.startBleScan) { - provider.startDevicesScan() - } + provider.startDevicesScan() + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 1) + espProvisionMock.completeNextDeviceScan() + + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startBleScan, count: 1) + let receivedData = receivedMessages[0] - await espProvisionMock.waitForCompletedDeviceSearchRequests(atLeast: 3) + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 2) + espProvisionMock.completeNextDeviceScan() + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 3) + espProvisionMock.completeNextDeviceScan() + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 4) #expect(espProvisionMock.searchESPDevicesCallCount >= 3) #expect(callbackRecorder.messageCount() == 1) From 5aeab7cbf0f670db7a81b52a0601745f4ef4fb47 Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Mon, 4 May 2026 16:18:55 +0200 Subject: [PATCH 26/29] Some more try to avoid edge conditions in tests --- Tests/ESPProvisionProviderTest.swift | 51 +++++++++++++++++++--------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index 80e3be0..2413633 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -205,8 +205,9 @@ struct ESPProvisionProviderTest { @Test func searchDevicesMultipleBatches() async throws { let espProvisionMock = ORESPProvisionManagerMock() espProvisionMock.manualDeviceScans = true + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -260,8 +261,9 @@ struct ESPProvisionProviderTest { @Test func testDisableStopsDeviceSearch() async throws { let espProvisionMock = ORESPProvisionManagerMock() espProvisionMock.manualDeviceScans = true + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -291,8 +293,9 @@ struct ESPProvisionProviderTest { @Test func testStopDeviceSearch() async throws { let espProvisionMock = ORESPProvisionManagerMock() espProvisionMock.manualDeviceScans = true + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -322,8 +325,9 @@ struct ESPProvisionProviderTest { @Test func testStopDeviceSearchNotStarted() async throws { let espProvisionMock = ORESPProvisionManagerMock() + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -379,8 +383,9 @@ struct ESPProvisionProviderTest { let espProvisionMock = ORESPProvisionManagerMock() espProvisionMock.manualDeviceScans = true espProvisionMock.mockDevices = [] + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 120, searchDeviceMaxIterations: 5) + let provider = ESPProvisionProvider(searchDeviceTimeout: 120, searchDeviceMaxIterations: 5, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -472,8 +477,9 @@ struct ESPProvisionProviderTest { @Test func connectToDevice() async throws { let espProvisionMock = ORESPProvisionManagerMock() espProvisionMock.manualDeviceScans = true + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -515,8 +521,9 @@ struct ESPProvisionProviderTest { @Test func connectToDeviceFailsForInvalidId() async throws { let espProvisionMock = ORESPProvisionManagerMock() espProvisionMock.manualDeviceScans = true + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -557,8 +564,9 @@ struct ESPProvisionProviderTest { @Test func startWifiScanNotConnected() async throws { let espProvisionMock = ORESPProvisionManagerMock() + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -738,8 +746,9 @@ struct ESPProvisionProviderTest { mockDevice.manualWifiScans = true mockDevice.networks = [] espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 120, searchWifiMaxIterations: 5) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 120, searchWifiMaxIterations: 5, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -814,8 +823,9 @@ struct ESPProvisionProviderTest { let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -970,8 +980,9 @@ struct ESPProvisionProviderTest { let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -996,6 +1007,7 @@ struct ESPProvisionProviderTest { let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() mockDevice.manualSendDataResponses = true + let timeSource = TestTimeSource() var expectedDeviceInfo = Response.DeviceInfo() expectedDeviceInfo.deviceID = "123456789ABC" @@ -1012,7 +1024,8 @@ struct ESPProvisionProviderTest { let deviceProvisionAPIMock = DeviceProvisionAPIMock() let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, - deviceProvisionAPI: deviceProvisionAPIMock) + deviceProvisionAPI: deviceProvisionAPIMock, + timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -1072,6 +1085,7 @@ struct ESPProvisionProviderTest { let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() mockDevice.manualSendDataResponses = true + let timeSource = TestTimeSource() var expectedDeviceInfo = Response.DeviceInfo() expectedDeviceInfo.deviceID = "123456789ABC" @@ -1091,7 +1105,8 @@ struct ESPProvisionProviderTest { let deviceProvisionAPIMock = DeviceProvisionAPIMock() let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, - deviceProvisionAPI: deviceProvisionAPIMock) + deviceProvisionAPI: deviceProvisionAPIMock, + timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -1239,8 +1254,9 @@ struct ESPProvisionProviderTest { let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -1262,13 +1278,15 @@ struct ESPProvisionProviderTest { let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() mockDevice.manualSendDataResponses = true + let timeSource = TestTimeSource() espProvisionMock.mockDevices = [mockDevice] let deviceProvisionAPIMock = DeviceProvisionAPIMock() let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, - deviceProvisionAPI: deviceProvisionAPIMock) + deviceProvisionAPI: deviceProvisionAPIMock, + timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -1296,8 +1314,9 @@ struct ESPProvisionProviderTest { @Test func exitProvisioningNotConnected() async throws { let espProvisionMock = ORESPProvisionManagerMock() + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) From 0d92caf11b1aecf7c6c50d0d3fb1bab5773cd1a7 Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Fri, 15 May 2026 11:25:18 +0200 Subject: [PATCH 27/29] Properly handle communication failures during provisioning --- .../ESPProvision/DeviceProvision.swift | 11 ++- Tests/ESPProvisionProviderTest.swift | 81 +++++++++++++++++++ 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift b/ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift index cc91fa8..dd568f5 100644 --- a/ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift +++ b/ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift @@ -66,7 +66,6 @@ class DeviceProvision { try await deviceConnection!.sendOpenRemoteConfig(mqttBrokerUrl: mqttURL, mqttUser: userName, mqttPassword: password, assetId: assetId) var status = BackendConnectionStatus.connecting - // TODO: what about other status values ? Is status connecting while it connects ? or disconnected ? -> test with real device let startTime = timeSource.now while status != .connected { if timeSource.now - startTime > backendConnectionTimeout { @@ -75,13 +74,13 @@ class DeviceProvision { } status = try await deviceConnection!.getBackendConnectionStatus() Self.logger.info("ModuleOne reported connection status \(String(describing: status))") + if status == .failed { + sendProvisionDeviceStatus(connected: false, error: .communicationError, errorMessage: "Backend connection failed") + return + } } - // TODO: review, if we're out of the loop, this is always true - - if status == .connected { - sendProvisionDeviceStatus(connected: true) - } + sendProvisionDeviceStatus(connected: true) } catch let error as ESPProviderError { sendProvisionDeviceStatus(connected: false, error: error.errorCode, errorMessage: error.errorMessage) } catch let error as RandomPasswordGeneratorError { diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index 2413633..a4c4d8e 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -1250,6 +1250,87 @@ struct ESPProvisionProviderTest { #expect(deviceProvisionAPIMock.receivedToken == "OAUTH_TOKEN") } + @Test func provisionDeviceFailureBackendConnectionFailed() async throws { + let espProvisionMock = ORESPProvisionManagerMock() + let mockDevice = ORESPDeviceMock() + mockDevice.manualSendDataResponses = true + + var expectedDeviceInfo = Response.DeviceInfo() + expectedDeviceInfo.deviceID = "123456789ABC" + expectedDeviceInfo.modelName = "My Battery" + + var expectedOpenRemoteConfig = Response.OpenRemoteConfig() + expectedOpenRemoteConfig.status = .success + + var expectedBackendConnectionStatusFailure = Response.BackendConnectionStatus() + expectedBackendConnectionStatusFailure.status = .failed + + espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() + + let deviceProvisionAPIMock = DeviceProvisionAPIMock() + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, + searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, + deviceProvisionAPI: deviceProvisionAPIMock, + timeSource: timeSource) + _ = provider.initialize() + _ = await enable(provider: provider) + provider.setProvisionManager(espProvisionMock) + + let device = await discoverDeviceAndStopScan(provider: provider) + + try await connectToDevice(provider: provider, deviceId: device["id"] as! String) + + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) + } + + provider.provisionDevice(userToken: "OAUTH_TOKEN") + + var request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 0) + #expect(request.id == "0") + #expect(request.body == .deviceInfo(Request.DeviceInfo())) + mockDevice.completeNextSendDataRequest(data: ORConfigChannelTest.responseData(body: .deviceInfo(expectedDeviceInfo))) + + request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 1) + #expect(request.id == "1") + if case let .openRemoteConfig(openRemoteConfig) = request.body { + #expect(openRemoteConfig.realm == "master") + #expect(openRemoteConfig.mqttBrokerURL == "mqtts://localhost:8883") + #expect(openRemoteConfig.user == expectedDeviceInfo.deviceID.lowercased(with: Locale(identifier: "en"))) + #expect(openRemoteConfig.mqttPassword == deviceProvisionAPIMock.receivedPassword) + #expect(openRemoteConfig.assetID == "AssetID") + + } else { + Issue.record("Received an unexpected response: \(request)") + } + mockDevice.completeNextSendDataRequest(data: ORConfigChannelTest.responseData(id: "1", body: .openRemoteConfig(expectedOpenRemoteConfig))) + + request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 2) + #expect(request.id == "2") + #expect(request.body == .backendConnectionStatus(Request.BackendConnectionStatus())) + mockDevice.completeNextSendDataRequest(data: ORConfigChannelTest.responseData(id: "2", body: .backendConnectionStatus(expectedBackendConnectionStatusFailure))) + + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.provisionDevice, count: 1) + let receivedData = receivedMessages[0] + + #expect(receivedData["provider"] as? String == Providers.espprovision) + #expect(receivedData["action"] as? String == Actions.provisionDevice) + #expect(receivedData["connected"] as? Bool == false) + #expect(receivedData["errorCode"] as? Int == ESPProviderErrorCode.communicationError.rawValue) + #expect(receivedData["errorMessage"] as? String == "Backend connection failed") + #expect(callbackRecorder.messageCount(matchingAction: Actions.provisionDevice) == 1) + + #expect(mockDevice.receivedData.count == 3) + + #expect(deviceProvisionAPIMock.provisionCallCount == 1) + #expect(deviceProvisionAPIMock.receivedModelName == expectedDeviceInfo.modelName) + #expect(deviceProvisionAPIMock.receivedDeviceId == expectedDeviceInfo.deviceID) + #expect(deviceProvisionAPIMock.receivedPassword != nil) + #expect(deviceProvisionAPIMock.receivedToken == "OAUTH_TOKEN") + } + @Test func provisionDeviceNotConnected() async throws { let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() From d888e2309cb39a593eddfdc3568dfcec987d38b7 Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Fri, 15 May 2026 11:32:30 +0200 Subject: [PATCH 28/29] Fix bug that re-setting LoopDetector instance mid-scan could produce false timeoutError --- .../ESPProvision/DeviceRegistry.swift | 4 +- .../ESPProvision/LoopDetector.swift | 4 +- .../ESPProvision/WifiProvisioner.swift | 4 +- Tests/ESPProvisionProviderTest.swift | 83 +++++++++++++++++++ 4 files changed, 89 insertions(+), 6 deletions(-) diff --git a/ORLib/ConsoleProviders/ESPProvision/DeviceRegistry.swift b/ORLib/ConsoleProviders/ESPProvision/DeviceRegistry.swift index 0443cc5..3137a23 100644 --- a/ORLib/ConsoleProviders/ESPProvision/DeviceRegistry.swift +++ b/ORLib/ConsoleProviders/ESPProvision/DeviceRegistry.swift @@ -71,7 +71,7 @@ class DeviceRegistry { loopDetector.timeout } set { - self.loopDetector = LoopDetector(timeout: newValue, maxIterations: searchDeviceMaxIterations, timeSource: timeSource) + self.loopDetector.timeout = newValue } } @@ -80,7 +80,7 @@ class DeviceRegistry { loopDetector.maxIterations } set { - self.loopDetector = LoopDetector(timeout: searchDeviceTimeout, maxIterations: newValue, timeSource: timeSource) + self.loopDetector.maxIterations = newValue } } diff --git a/ORLib/ConsoleProviders/ESPProvision/LoopDetector.swift b/ORLib/ConsoleProviders/ESPProvision/LoopDetector.swift index dee2c95..c6aa132 100644 --- a/ORLib/ConsoleProviders/ESPProvision/LoopDetector.swift +++ b/ORLib/ConsoleProviders/ESPProvision/LoopDetector.swift @@ -20,8 +20,8 @@ import Foundation class LoopDetector { - let timeout: TimeInterval - let maxIterations: Int + var timeout: TimeInterval + var maxIterations: Int private let timeSource: any TimeSource private var startTime: TimeInterval? diff --git a/ORLib/ConsoleProviders/ESPProvision/WifiProvisioner.swift b/ORLib/ConsoleProviders/ESPProvision/WifiProvisioner.swift index 0c4bc0f..26c4c7a 100644 --- a/ORLib/ConsoleProviders/ESPProvision/WifiProvisioner.swift +++ b/ORLib/ConsoleProviders/ESPProvision/WifiProvisioner.swift @@ -37,7 +37,7 @@ class WifiProvisioner { loopDetector.timeout } set { - self.loopDetector = LoopDetector(timeout: newValue, maxIterations: searchWifiMaxIterations, timeSource: timeSource) + self.loopDetector.timeout = newValue } } @@ -46,7 +46,7 @@ class WifiProvisioner { loopDetector.maxIterations } set { - self.loopDetector = LoopDetector(timeout: searchWifiTimeout, maxIterations: newValue, timeSource: timeSource) + self.loopDetector.maxIterations = newValue } } diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index a4c4d8e..d6d7e09 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -414,6 +414,37 @@ struct ESPProvisionProviderTest { #expect(receivedData["errorCode"] as? Int == ESPProviderErrorCode.timeoutError.rawValue) } + @Test func searchDeviceSettingsChangeDuringScanDoesNotTimeout() async throws { + let espProvisionMock = ORESPProvisionManagerMock() + espProvisionMock.manualDeviceScans = true + espProvisionMock.mockDevices = [] + let timeSource = TestTimeSource() + + let callbackRecorder = CallbackRecorder() + let callbackChannel = CallbackChannel(sendDataCallback: { data in + callbackRecorder.record(data) + }, provider: Providers.espprovision) + + let deviceRegistry = DeviceRegistry(searchDeviceTimeout: 120, + searchDeviceMaxIterations: Int.max, + timeSource: timeSource) + deviceRegistry.callbackChannel = callbackChannel + deviceRegistry.provisionManager = espProvisionMock + defer { deviceRegistry.stopDevicesScan(sendMessage: false) } + + deviceRegistry.startDevicesScan() + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 1) + + deviceRegistry.searchDeviceTimeout = 60 + deviceRegistry.searchDeviceMaxIterations = Int.max + espProvisionMock.completeNextDeviceScan(with: []) + + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 2) + + #expect(deviceRegistry.bleScanning) + #expect(callbackRecorder.messageCount(matchingAction: Actions.stopBleScan) == 0) + } + @Test func multipleSearchDevices() async throws { let espProvisionMock = ORESPProvisionManagerMock() espProvisionMock.manualDeviceScans = true @@ -781,6 +812,58 @@ struct ESPProvisionProviderTest { #expect(receivedData["errorCode"] as? Int == ESPProviderErrorCode.timeoutError.rawValue) } + @Test func wifiScanSettingsChangeDuringScanDoesNotTimeout() async throws { + let espProvisionMock = ORESPProvisionManagerMock() + espProvisionMock.manualDeviceScans = true + let mockDevice = ORESPDeviceMock() + mockDevice.manualWifiScans = true + espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() + + let callbackRecorder = CallbackRecorder() + let callbackChannel = CallbackChannel(sendDataCallback: { data in + callbackRecorder.record(data) + }, provider: Providers.espprovision) + + let deviceRegistry = DeviceRegistry(searchDeviceTimeout: 1, + searchDeviceMaxIterations: Int.max, + timeSource: timeSource) + deviceRegistry.callbackChannel = callbackChannel + deviceRegistry.provisionManager = espProvisionMock + defer { deviceRegistry.stopDevicesScan(sendMessage: false) } + + deviceRegistry.startDevicesScan() + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 1) + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startBleScan, count: 1) { + espProvisionMock.completeNextDeviceScan() + } + deviceRegistry.stopDevicesScan(sendMessage: false) + + let devices = receivedMessages[0]["devices"] as! [[String:Any]] + let deviceId = devices[0]["id"] as! String + let deviceConnection = DeviceConnection(deviceRegistry: deviceRegistry, callbackChannel: callbackChannel) + deviceConnection.connectTo(deviceId: deviceId) + + let wifiProvisioner = WifiProvisioner(deviceConnection: deviceConnection, + callbackChannel: callbackChannel, + searchWifiTimeout: 120, + searchWifiMaxIterations: Int.max, + timeSource: timeSource) + defer { wifiProvisioner.stopWifiScan(sendMessage: false) } + + wifiProvisioner.startWifiScan() + await mockDevice.waitForWifiScanStarts(atLeast: 1) + + wifiProvisioner.searchWifiTimeout = 60 + wifiProvisioner.searchWifiMaxIterations = Int.max + mockDevice.completeNextWifiScan(networks: []) + + await mockDevice.waitForWifiScanStarts(atLeast: 2) + + #expect(wifiProvisioner.wifiScanning) + #expect(callbackRecorder.messageCount(matchingAction: Actions.stopWifiScan) == 0) + } + @Test func testStopWifiScan() async throws { let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() From 56f256eeebaff4d417daa9d4d92206b1f0d18544 Mon Sep 17 00:00:00 2001 From: Eric Bariaux <375613+ebariaux@users.noreply.github.com> Date: Fri, 15 May 2026 14:42:21 +0200 Subject: [PATCH 29/29] Implement timeouts within test with explicit messages and don't rely solely on test executor timeout mechanism --- Tests/ESPProvisionProviderTest.swift | 44 +++++++++ Tests/ORESPDeviceMock.swift | 133 ++++++++++++++++++++++---- Tests/ORESPProvisionManagerMock.swift | 88 +++++++++++++++-- 3 files changed, 239 insertions(+), 26 deletions(-) diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index d6d7e09..3ab0961 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -25,6 +25,8 @@ import Testing @testable import ORLib private final class CallbackRecorder { + private static let waitTimeoutNanoseconds: UInt64 = 5_000_000_000 + private enum WaitCondition { case action(name: String, count: Int) case orderedActions([String]) @@ -39,6 +41,7 @@ private final class CallbackRecorder { private var messages = [[String:Any]]() private var waitCondition: WaitCondition? private var continuation: CheckedContinuation? + private var waiterId: UUID? func record(_ data: [String:Any]) { var continuationToResume: CheckedContinuation? @@ -91,6 +94,8 @@ private final class CallbackRecorder { private func wait(until waitCondition: WaitCondition, after trigger: (() -> Void)? = nil) async -> WaitResult { await withCheckedContinuation { continuation in + let waiterId = UUID() + lock.lock() if let matchedMessages = matchedMessages(for: waitCondition, in: messages) { let waitResult = WaitResult(matchedMessages: matchedMessages, allMessagesAtMatchTime: messages) @@ -100,12 +105,42 @@ private final class CallbackRecorder { } self.waitCondition = waitCondition self.continuation = continuation + self.waiterId = waiterId lock.unlock() + Task { [weak self] in + await self?.timeoutWait(waiterId: waiterId, waitCondition: waitCondition) + } trigger?() } } + private func timeoutWait(waiterId: UUID, waitCondition: WaitCondition) async { + try? await Task.sleep(nanoseconds: Self.waitTimeoutNanoseconds) + + var continuationToResume: CheckedContinuation? + var waitResult: WaitResult? + var observedActions = [String]() + + lock.lock() + if self.waiterId == waiterId { + let allMessagesAtMatchTime = messages + continuationToResume = continuation + waitResult = WaitResult(matchedMessages: matchedMessages(for: waitCondition, in: messages) ?? [], + allMessagesAtMatchTime: allMessagesAtMatchTime) + observedActions = allMessagesAtMatchTime.compactMap { $0["action"] as? String } + self.waitCondition = nil + self.continuation = nil + self.waiterId = nil + } + lock.unlock() + + if let continuationToResume, let waitResult { + Issue.record("Timed out waiting for \(description(for: waitCondition)); observed actions \(observedActions)") + continuationToResume.resume(returning: waitResult) + } + } + private func matchedMessages(for waitCondition: WaitCondition, in recordedMessages: [[String:Any]]) -> [[String:Any]]? { switch waitCondition { case let .action(name, count): @@ -120,6 +155,15 @@ private final class CallbackRecorder { } } + private func description(for waitCondition: WaitCondition) -> String { + switch waitCondition { + case let .action(name, count): + return "\(count) message(s) matching action \(name)" + case let .orderedActions(actions): + return "ordered actions \(actions)" + } + } + private func orderedMessages(matchingActions actions: [String], in recordedMessages: [[String:Any]]) -> [[String:Any]]? { var matchingMessages = [[String:Any]]() var actionIndex = 0 diff --git a/Tests/ORESPDeviceMock.swift b/Tests/ORESPDeviceMock.swift index 3b6d2d3..a94d61d 100644 --- a/Tests/ORESPDeviceMock.swift +++ b/Tests/ORESPDeviceMock.swift @@ -38,13 +38,20 @@ struct MockResponse { } private final class ManualWifiScanController { + private static let waitTimeoutNanoseconds: UInt64 = 5_000_000_000 + typealias CompletionHandler = ([ESPWifiNetwork]?, ESPWiFiScanError?) -> Void + private struct ScanWaiter { + let id: UUID + let targetCount: Int + let continuation: CheckedContinuation + } private let lock = NSLock() private var manualMode = false private var pendingScans = [CompletionHandler]() private var enqueuedScanCount = 0 - private var scanWaiters = [(targetCount: Int, continuation: CheckedContinuation)]() + private var scanWaiters = [ScanWaiter]() func setManualMode(_ manualMode: Bool) { lock.lock() @@ -59,18 +66,18 @@ private final class ManualWifiScanController { } func enqueuePendingScan(_ completionHandler: @escaping CompletionHandler) { - var waitersToResume = [CheckedContinuation]() + var waitersToResume = [ScanWaiter]() lock.lock() pendingScans.append(completionHandler) enqueuedScanCount += 1 let readyWaiters = scanWaiters.filter { enqueuedScanCount >= $0.targetCount } scanWaiters.removeAll { enqueuedScanCount >= $0.targetCount } - waitersToResume = readyWaiters.map(\.continuation) + waitersToResume = readyWaiters lock.unlock() - for continuation in waitersToResume { - continuation.resume() + for waiter in waitersToResume { + waiter.continuation.resume() } } @@ -89,14 +96,38 @@ private final class ManualWifiScanController { } await withCheckedContinuation { continuation in + let waiterId = UUID() lock.lock() if enqueuedScanCount >= targetCount { lock.unlock() continuation.resume() return } - scanWaiters.append((targetCount, continuation)) + scanWaiters.append(ScanWaiter(id: waiterId, targetCount: targetCount, continuation: continuation)) lock.unlock() + + Task { [weak self] in + await self?.timeoutScanWaiter(id: waiterId, targetCount: targetCount) + } + } + } + + private func timeoutScanWaiter(id: UUID, targetCount: Int) async { + try? await Task.sleep(nanoseconds: Self.waitTimeoutNanoseconds) + + var waiterToResume: ScanWaiter? + var observedCount = 0 + + lock.lock() + if let waiterIndex = scanWaiters.firstIndex(where: { $0.id == id }) { + waiterToResume = scanWaiters.remove(at: waiterIndex) + observedCount = enqueuedScanCount + } + lock.unlock() + + if let waiterToResume { + Issue.record("Timed out waiting for at least \(targetCount) Wi-Fi scan start(s); observed \(observedCount)") + waiterToResume.continuation.resume() } } @@ -108,16 +139,23 @@ private final class ManualWifiScanController { } private final class SendDataController { + private static let waitTimeoutNanoseconds: UInt64 = 5_000_000_000 + typealias CompletionHandler = (Data?, ESPSessionError?) -> Void private struct PendingRequest { let completionHandler: CompletionHandler } + private struct PendingRequestWaiter { + let id: UUID + let targetCount: Int + let continuation: CheckedContinuation + } private let lock = NSLock() private var manualMode = false private var pendingRequests = [PendingRequest]() - private var pendingRequestWaiters = [(targetCount: Int, continuation: CheckedContinuation)]() + private var pendingRequestWaiters = [PendingRequestWaiter]() private var mockResponses = [MockResponse]() private var mockResponsesIndex: [MockResponse].Index? = nil @@ -147,18 +185,18 @@ private final class SendDataController { } func enqueuePendingRequest(_ completionHandler: @escaping CompletionHandler) { - var waitersToResume = [CheckedContinuation]() + var waitersToResume = [PendingRequestWaiter]() lock.lock() pendingRequests.append(PendingRequest(completionHandler: completionHandler)) let pendingRequestCount = pendingRequests.count let readyWaiters = pendingRequestWaiters.filter { pendingRequestCount >= $0.targetCount } pendingRequestWaiters.removeAll { pendingRequestCount >= $0.targetCount } - waitersToResume = readyWaiters.map(\.continuation) + waitersToResume = readyWaiters lock.unlock() - for continuation in waitersToResume { - continuation.resume() + for waiter in waitersToResume { + waiter.continuation.resume() } } @@ -168,14 +206,40 @@ private final class SendDataController { } await withCheckedContinuation { continuation in + let waiterId = UUID() lock.lock() if pendingRequests.count >= targetCount { lock.unlock() continuation.resume() return } - pendingRequestWaiters.append((targetCount, continuation)) + pendingRequestWaiters.append(PendingRequestWaiter(id: waiterId, + targetCount: targetCount, + continuation: continuation)) lock.unlock() + + Task { [weak self] in + await self?.timeoutPendingRequestWaiter(id: waiterId, targetCount: targetCount) + } + } + } + + private func timeoutPendingRequestWaiter(id: UUID, targetCount: Int) async { + try? await Task.sleep(nanoseconds: Self.waitTimeoutNanoseconds) + + var waiterToResume: PendingRequestWaiter? + var observedCount = 0 + + lock.lock() + if let waiterIndex = pendingRequestWaiters.firstIndex(where: { $0.id == id }) { + waiterToResume = pendingRequestWaiters.remove(at: waiterIndex) + observedCount = pendingRequests.count + } + lock.unlock() + + if let waiterToResume { + Issue.record("Timed out waiting for at least \(targetCount) pending sendData request(s); observed \(observedCount)") + waiterToResume.continuation.resume() } } @@ -215,10 +279,17 @@ private final class SendDataController { } private actor WifiScanCompletionTracker { + private static let waitTimeoutNanoseconds: UInt64 = 5_000_000_000 + private struct ScanWaiter { + let id: UUID + let targetCount: Int + let continuation: CheckedContinuation + } + private var startedScanCount = 0 private var completedScanCount = 0 - private var startWaiters = [(targetCount: Int, continuation: CheckedContinuation)]() - private var waiters = [(targetCount: Int, continuation: CheckedContinuation)]() + private var startWaiters = [ScanWaiter]() + private var waiters = [ScanWaiter]() func markScanStarted() { startedScanCount += 1 @@ -248,7 +319,11 @@ private actor WifiScanCompletionTracker { } await withCheckedContinuation { continuation in - startWaiters.append((targetCount, continuation)) + let waiterId = UUID() + startWaiters.append(ScanWaiter(id: waiterId, targetCount: targetCount, continuation: continuation)) + Task { + await self.timeoutStartedScanWaiter(id: waiterId, targetCount: targetCount) + } } } @@ -258,9 +333,35 @@ private actor WifiScanCompletionTracker { } await withCheckedContinuation { continuation in - waiters.append((targetCount, continuation)) + let waiterId = UUID() + waiters.append(ScanWaiter(id: waiterId, targetCount: targetCount, continuation: continuation)) + Task { + await self.timeoutCompletedScanWaiter(id: waiterId, targetCount: targetCount) + } } } + + private func timeoutStartedScanWaiter(id: UUID, targetCount: Int) async { + try? await Task.sleep(nanoseconds: Self.waitTimeoutNanoseconds) + guard let waiterIndex = startWaiters.firstIndex(where: { $0.id == id }) else { + return + } + + let waiter = startWaiters.remove(at: waiterIndex) + Issue.record("Timed out waiting for at least \(targetCount) Wi-Fi scan start(s); observed \(startedScanCount)") + waiter.continuation.resume() + } + + private func timeoutCompletedScanWaiter(id: UUID, targetCount: Int) async { + try? await Task.sleep(nanoseconds: Self.waitTimeoutNanoseconds) + guard let waiterIndex = waiters.firstIndex(where: { $0.id == id }) else { + return + } + + let waiter = waiters.remove(at: waiterIndex) + Issue.record("Timed out waiting for at least \(targetCount) Wi-Fi scan completion(s); observed \(completedScanCount)") + waiter.continuation.resume() + } } class ORESPDeviceMock: ORESPDevice { diff --git a/Tests/ORESPProvisionManagerMock.swift b/Tests/ORESPProvisionManagerMock.swift index 1146594..118720b 100644 --- a/Tests/ORESPProvisionManagerMock.swift +++ b/Tests/ORESPProvisionManagerMock.swift @@ -25,11 +25,18 @@ import Testing @testable import ORLib private final class ManualDeviceScanController { + private static let waitTimeoutNanoseconds: UInt64 = 5_000_000_000 + private struct RequestWaiter { + let id: UUID + let targetCount: Int + let continuation: CheckedContinuation + } + private let lock = NSLock() private var manualMode = false private var pendingRequests = [CheckedContinuation<[ORESPDevice], Error>]() private var enqueuedRequestCount = 0 - private var requestWaiters = [(targetCount: Int, continuation: CheckedContinuation)]() + private var requestWaiters = [RequestWaiter]() func setManualMode(_ manualMode: Bool) { lock.lock() @@ -44,18 +51,18 @@ private final class ManualDeviceScanController { } func enqueuePendingRequest(_ continuation: CheckedContinuation<[ORESPDevice], Error>) { - var waitersToResume = [CheckedContinuation]() + var waitersToResume = [RequestWaiter]() lock.lock() pendingRequests.append(continuation) enqueuedRequestCount += 1 let readyWaiters = requestWaiters.filter { enqueuedRequestCount >= $0.targetCount } requestWaiters.removeAll { enqueuedRequestCount >= $0.targetCount } - waitersToResume = readyWaiters.map(\.continuation) + waitersToResume = readyWaiters lock.unlock() - for continuation in waitersToResume { - continuation.resume() + for waiter in waitersToResume { + waiter.continuation.resume() } } @@ -82,14 +89,38 @@ private final class ManualDeviceScanController { } await withCheckedContinuation { continuation in + let waiterId = UUID() lock.lock() if enqueuedRequestCount >= targetCount { lock.unlock() continuation.resume() return } - requestWaiters.append((targetCount, continuation)) + requestWaiters.append(RequestWaiter(id: waiterId, targetCount: targetCount, continuation: continuation)) lock.unlock() + + Task { [weak self] in + await self?.timeoutRequestWaiter(id: waiterId, targetCount: targetCount) + } + } + } + + private func timeoutRequestWaiter(id: UUID, targetCount: Int) async { + try? await Task.sleep(nanoseconds: Self.waitTimeoutNanoseconds) + + var waiterToResume: RequestWaiter? + var observedCount = 0 + + lock.lock() + if let waiterIndex = requestWaiters.firstIndex(where: { $0.id == id }) { + waiterToResume = requestWaiters.remove(at: waiterIndex) + observedCount = enqueuedRequestCount + } + lock.unlock() + + if let waiterToResume { + Issue.record("Timed out waiting for at least \(targetCount) device search request(s); observed \(observedCount)") + waiterToResume.continuation.resume() } } @@ -102,10 +133,17 @@ private final class ManualDeviceScanController { final class ORESPProvisionManagerMock: ORESPProvisionManager { private actor DeviceScanCompletionTracker { + private static let waitTimeoutNanoseconds: UInt64 = 5_000_000_000 + private struct ScanWaiter { + let id: UUID + let targetCount: Int + let continuation: CheckedContinuation + } + private var startedScanCount = 0 private var completedScanCount = 0 - private var startWaiters = [(targetCount: Int, continuation: CheckedContinuation)]() - private var waiters = [(targetCount: Int, continuation: CheckedContinuation)]() + private var startWaiters = [ScanWaiter]() + private var waiters = [ScanWaiter]() func markScanStarted() { startedScanCount += 1 @@ -135,7 +173,11 @@ final class ORESPProvisionManagerMock: ORESPProvisionManager { } await withCheckedContinuation { continuation in - startWaiters.append((targetCount, continuation)) + let waiterId = UUID() + startWaiters.append(ScanWaiter(id: waiterId, targetCount: targetCount, continuation: continuation)) + Task { + await self.timeoutStartedScanWaiter(id: waiterId, targetCount: targetCount) + } } } @@ -145,8 +187,34 @@ final class ORESPProvisionManagerMock: ORESPProvisionManager { } await withCheckedContinuation { continuation in - waiters.append((targetCount, continuation)) + let waiterId = UUID() + waiters.append(ScanWaiter(id: waiterId, targetCount: targetCount, continuation: continuation)) + Task { + await self.timeoutCompletedScanWaiter(id: waiterId, targetCount: targetCount) + } + } + } + + private func timeoutStartedScanWaiter(id: UUID, targetCount: Int) async { + try? await Task.sleep(nanoseconds: Self.waitTimeoutNanoseconds) + guard let waiterIndex = startWaiters.firstIndex(where: { $0.id == id }) else { + return } + + let waiter = startWaiters.remove(at: waiterIndex) + Issue.record("Timed out waiting for at least \(targetCount) device scan start(s); observed \(startedScanCount)") + waiter.continuation.resume() + } + + private func timeoutCompletedScanWaiter(id: UUID, targetCount: Int) async { + try? await Task.sleep(nanoseconds: Self.waitTimeoutNanoseconds) + guard let waiterIndex = waiters.firstIndex(where: { $0.id == id }) else { + return + } + + let waiter = waiters.remove(at: waiterIndex) + Issue.record("Timed out waiting for at least \(targetCount) device scan completion(s); observed \(completedScanCount)") + waiter.continuation.resume() } }