From ce284e678af9fa4e31ecd9d31e3ace6b1eb80c4e Mon Sep 17 00:00:00 2001 From: Moustachauve <2206577+Moustachauve@users.noreply.github.com> Date: Sat, 4 Apr 2026 19:33:23 -0400 Subject: [PATCH 1/3] Fix power toggle gesture conflict on iOS 16 --- wled/View/DeviceListItemView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/wled/View/DeviceListItemView.swift b/wled/View/DeviceListItemView.swift index 57fef3b..e661ea6 100644 --- a/wled/View/DeviceListItemView.swift +++ b/wled/View/DeviceListItemView.swift @@ -26,6 +26,11 @@ struct DeviceListItemView: View { Toggle("Turn On/Off", isOn: isOnBinding) .labelsHidden() .frame(alignment: .trailing) + // On iOS 16, tapping the Toggle also triggers the parent row's .onTapGesture. + // This empty handler "consumes" the SwiftUI tap at the child level, + // while the underlying UISwitch still receives the UIKit event. + // This can be removed once the minimum deployment target is iOS 17+. + .onTapGesture { } } Slider( From acb47fe7b0524cab5e3254671be714f086e8163e Mon Sep 17 00:00:00 2001 From: Moustachauve <2206577+Moustachauve@users.noreply.github.com> Date: Sat, 4 Apr 2026 19:58:56 -0400 Subject: [PATCH 2/3] Fix flaky background disconnect test and improve CI output - Make backgroundDisconnectDelay injectable so tests use milliseconds instead of multi-second wall-clock sleeps - Pipe xcodebuild through xcbeautify for readable CI logs - Generate JUnit XML report for test results --- .github/workflows/check.yml | 8 +++++++- wled/ViewModel/DeviceWebsocketListViewModel.swift | 7 ++++++- wledTests/DeviceWebsocketListViewModelTests.swift | 8 +++++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index d0ffce2..686a5d7 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -19,15 +19,21 @@ jobs: - name: Checkout code uses: actions/checkout@v6 + - name: Install xcbeautify + run: brew install xcbeautify + - name: Build and Test run: | + set -o pipefail xcodebuild test \ -project wled.xcodeproj \ -scheme wled \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \ -skipPackagePluginValidation \ -skipMacroValidation \ - CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO + -resultBundlePath TestResults.xcresult \ + CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO \ + 2>&1 | xcbeautify --report junit --report-path . --junit-report-filename junit.xml lint: name: Run Lint diff --git a/wled/ViewModel/DeviceWebsocketListViewModel.swift b/wled/ViewModel/DeviceWebsocketListViewModel.swift index 94aff3c..a5b4d4e 100644 --- a/wled/ViewModel/DeviceWebsocketListViewModel.swift +++ b/wled/ViewModel/DeviceWebsocketListViewModel.swift @@ -49,6 +49,10 @@ class DeviceWebsocketListViewModel: NSObject, ObservableObject, NSFetchedResults private var activeClients: [String: ClientWrapper] = [:] private var isPaused = false private var backgroundTask: Task? + + /// Delay before disconnecting websockets after entering background. + /// Exposed as `internal` so tests can override with a shorter value. + var backgroundDisconnectDelay: Duration = .seconds(2) /// Amount of time after a device becomes offline before it is considered offline. private let offlineGracePeriod: TimeInterval = 60 @@ -265,9 +269,10 @@ class DeviceWebsocketListViewModel: NSObject, ObservableObject, NSFetchedResults func onPause() { print("[ListVM] onPause: Scheduling disconnect.") backgroundTask?.cancel() + let delay = backgroundDisconnectDelay backgroundTask = Task { @MainActor [weak self] in do { - try await Task.sleep(for: .seconds(2)) + try await Task.sleep(for: delay) } catch { // Task was cancelled (user came back quickly) return diff --git a/wledTests/DeviceWebsocketListViewModelTests.swift b/wledTests/DeviceWebsocketListViewModelTests.swift index 054b725..84a1f8c 100644 --- a/wledTests/DeviceWebsocketListViewModelTests.swift +++ b/wledTests/DeviceWebsocketListViewModelTests.swift @@ -81,6 +81,7 @@ struct DeviceWebsocketListViewModelTests { try context.save() let viewModel = DeviceWebsocketListViewModel(context: context) + viewModel.backgroundDisconnectDelay = .milliseconds(500) let mockClient = ManualMockWebsocketClient(device: device) viewModel.makeClient = { _ in mockClient } @@ -91,11 +92,11 @@ struct DeviceWebsocketListViewModelTests { // Simulate quick background + resume (app switcher peek) viewModel.onPause() - try await Task.sleep(for: .milliseconds(500)) // Well under the 2s delay + try await Task.sleep(for: .milliseconds(100)) // Well under the 500ms delay viewModel.onResume() // Device should still be connected — disconnect was cancelled - try await Task.sleep(for: .seconds(1)) + try await Task.sleep(for: .milliseconds(600)) #expect(mockClient.deviceState.websocketStatus == .connected) #expect(viewModel.onlineDevices.count == 1) } @@ -105,6 +106,7 @@ struct DeviceWebsocketListViewModelTests { try context.save() let viewModel = DeviceWebsocketListViewModel(context: context) + viewModel.backgroundDisconnectDelay = .milliseconds(100) let mockClient = ManualMockWebsocketClient(device: device) viewModel.makeClient = { _ in mockClient } @@ -115,7 +117,7 @@ struct DeviceWebsocketListViewModelTests { // Simulate going to background and staying there viewModel.onPause() - try await Task.sleep(for: .seconds(4)) // Wait well past the 2s delay (generous for CI) + try await Task.sleep(for: .milliseconds(500)) // Wait well past the 100ms delay // Device should now be disconnected #expect(mockClient.deviceState.websocketStatus == .disconnected) From 8a105b1d6a3325b5362a131ba1727e172dd76e38 Mon Sep 17 00:00:00 2001 From: Moustachauve <2206577+Moustachauve@users.noreply.github.com> Date: Sat, 4 Apr 2026 19:58:56 -0400 Subject: [PATCH 3/3] Fix flaky background disconnect test and improve CI output - Make backgroundDisconnectDelay injectable so tests use milliseconds instead of multi-second wall-clock sleeps - Pipe xcodebuild through xcbeautify for readable CI logs - Generate JUnit XML report for test results --- .github/workflows/check.yml | 8 +++++++- wled/ViewModel/DeviceWebsocketListViewModel.swift | 7 ++++++- wledTests/DeviceWebsocketListViewModelTests.swift | 10 +++++++--- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index d0ffce2..686a5d7 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -19,15 +19,21 @@ jobs: - name: Checkout code uses: actions/checkout@v6 + - name: Install xcbeautify + run: brew install xcbeautify + - name: Build and Test run: | + set -o pipefail xcodebuild test \ -project wled.xcodeproj \ -scheme wled \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \ -skipPackagePluginValidation \ -skipMacroValidation \ - CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO + -resultBundlePath TestResults.xcresult \ + CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO \ + 2>&1 | xcbeautify --report junit --report-path . --junit-report-filename junit.xml lint: name: Run Lint diff --git a/wled/ViewModel/DeviceWebsocketListViewModel.swift b/wled/ViewModel/DeviceWebsocketListViewModel.swift index 94aff3c..a5b4d4e 100644 --- a/wled/ViewModel/DeviceWebsocketListViewModel.swift +++ b/wled/ViewModel/DeviceWebsocketListViewModel.swift @@ -49,6 +49,10 @@ class DeviceWebsocketListViewModel: NSObject, ObservableObject, NSFetchedResults private var activeClients: [String: ClientWrapper] = [:] private var isPaused = false private var backgroundTask: Task? + + /// Delay before disconnecting websockets after entering background. + /// Exposed as `internal` so tests can override with a shorter value. + var backgroundDisconnectDelay: Duration = .seconds(2) /// Amount of time after a device becomes offline before it is considered offline. private let offlineGracePeriod: TimeInterval = 60 @@ -265,9 +269,10 @@ class DeviceWebsocketListViewModel: NSObject, ObservableObject, NSFetchedResults func onPause() { print("[ListVM] onPause: Scheduling disconnect.") backgroundTask?.cancel() + let delay = backgroundDisconnectDelay backgroundTask = Task { @MainActor [weak self] in do { - try await Task.sleep(for: .seconds(2)) + try await Task.sleep(for: delay) } catch { // Task was cancelled (user came back quickly) return diff --git a/wledTests/DeviceWebsocketListViewModelTests.swift b/wledTests/DeviceWebsocketListViewModelTests.swift index 054b725..df7d472 100644 --- a/wledTests/DeviceWebsocketListViewModelTests.swift +++ b/wledTests/DeviceWebsocketListViewModelTests.swift @@ -81,6 +81,9 @@ struct DeviceWebsocketListViewModelTests { try context.save() let viewModel = DeviceWebsocketListViewModel(context: context) + // Use a very long delay so the disconnect can never fire during this test, + // even on a slow CI runner. We only care that onResume() cancels it. + viewModel.backgroundDisconnectDelay = .seconds(60) let mockClient = ManualMockWebsocketClient(device: device) viewModel.makeClient = { _ in mockClient } @@ -91,11 +94,11 @@ struct DeviceWebsocketListViewModelTests { // Simulate quick background + resume (app switcher peek) viewModel.onPause() - try await Task.sleep(for: .milliseconds(500)) // Well under the 2s delay + try await Task.sleep(for: .milliseconds(200)) viewModel.onResume() // Device should still be connected — disconnect was cancelled - try await Task.sleep(for: .seconds(1)) + try await Task.sleep(for: .milliseconds(500)) #expect(mockClient.deviceState.websocketStatus == .connected) #expect(viewModel.onlineDevices.count == 1) } @@ -105,6 +108,7 @@ struct DeviceWebsocketListViewModelTests { try context.save() let viewModel = DeviceWebsocketListViewModel(context: context) + viewModel.backgroundDisconnectDelay = .milliseconds(100) let mockClient = ManualMockWebsocketClient(device: device) viewModel.makeClient = { _ in mockClient } @@ -115,7 +119,7 @@ struct DeviceWebsocketListViewModelTests { // Simulate going to background and staying there viewModel.onPause() - try await Task.sleep(for: .seconds(4)) // Wait well past the 2s delay (generous for CI) + try await Task.sleep(for: .milliseconds(500)) // Wait well past the 100ms delay // Device should now be disconnected #expect(mockClient.deviceState.websocketStatus == .disconnected)