From 4bfc127ef1c3fad03e2e0ff6c54b9d235c94cdaf Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 4 Oct 2026 00:27:54 +0700 Subject: [PATCH] test: wait for the event in tests that raced the main actor or a timer SSHTunnelErrorTests.cancellationDismissesPrompt waited for a main-actor job with 20 Task.yield() calls off the main actor, which never wait for the main thread. It fails the Unit tests job on main. It now waits for the dismissal itself, bounded by BoundedCall. Two sidebar filter tests slept 300 ms against the 150 ms debounce. They now wait for the value the debounce publishes. --- .../Core/SSH/SSHTunnelErrorTests.swift | 16 +++++--- .../ViewModels/SidebarViewModelTests.swift | 41 ++++++++++++++++--- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/TableProTests/Core/SSH/SSHTunnelErrorTests.swift b/TableProTests/Core/SSH/SSHTunnelErrorTests.swift index 8d65517164..88fe0a4b6e 100644 --- a/TableProTests/Core/SSH/SSHTunnelErrorTests.swift +++ b/TableProTests/Core/SSH/SSHTunnelErrorTests.swift @@ -141,22 +141,28 @@ struct SSHTunnelErrorTests { #expect(interrupted.withLock { $0 }) } + /// The dismissal is a main-actor job and this test runs off the main actor, so it waits for + /// the dismissal itself. Yielding here never waited for the main actor, and every yield could + /// pass before the main thread took its turn. @Test("Cancelling SSH authentication dismisses its active prompt") func cancellationDismissesPrompt() async throws { let deadline = ConnectionDeadline(configuredSeconds: 30) let endpoint = ConnectionTimeoutEndpoint.tunnel("jump.example:22") let attempt = SSHConnectionAttempt(deadline: deadline, endpoint: endpoint) - let dismissed = OSAllocatedUnfairLock(initialState: false) + let (dismissals, dismissal) = AsyncStream.makeStream() let promptId = try attempt.registerPrompt(for: endpoint) { - dismissed.withLock { $0 = true } + dismissal.yield() + dismissal.finish() } attempt.cancel() - for _ in 0..<20 where !dismissed.withLock({ $0 }) { - await Task.yield() + + let dismissed = await BoundedCall.result { + for await _ in dismissals { return true } + return false } - #expect(dismissed.withLock { $0 }) + #expect(dismissed == true) #expect(throws: CancellationError.self) { try attempt.check(for: endpoint) } diff --git a/TableProTests/ViewModels/SidebarViewModelTests.swift b/TableProTests/ViewModels/SidebarViewModelTests.swift index 47f8471a1b..f3b49f511f 100644 --- a/TableProTests/ViewModels/SidebarViewModelTests.swift +++ b/TableProTests/ViewModels/SidebarViewModelTests.swift @@ -6,6 +6,7 @@ // import AppKit +import Combine import Foundation import SwiftUI import Testing @@ -521,6 +522,34 @@ struct SidebarViewModelMultiSectionTests { } } +/// The debounce runs on real time, so a test waits for the value it publishes and not for a +/// sleep of its own to outlast it. `$filterQuery` replays its current value to a new subscriber, +/// which `dropFirst` discards. +@MainActor +private final class DebouncedFilterQueries { + private(set) var published: [String] = [] + private let values: AsyncStream + private var subscription: AnyCancellable? + + init(of viewModel: SidebarViewModel) { + let (values, continuation) = AsyncStream.makeStream() + self.values = values + subscription = viewModel.$filterQuery.dropFirst().sink { [weak self] value in + self?.published.append(value) + continuation.yield(value) + } + } + + func first() async -> String? { + let stream = values + let arrived = await BoundedCall.result { () -> String? in + for await value in stream { return value } + return nil + } + return arrived ?? nil + } +} + struct SidebarViewModelSearchDebounceTests { @Test("filterQuery updates immediately on first non-empty input") @MainActor @@ -563,12 +592,11 @@ struct SidebarViewModelSearchDebounceTests { func filterQueryCatchesUpAfterDebounce() async { let vm = makeViewModel() vm.searchText = "user" + let debounced = DebouncedFilterQueries(of: vm) vm.searchText = "users" - try? await Task.sleep(nanoseconds: 300_000_000) - await Task.yield() - + #expect(await debounced.first() == "users") #expect(vm.filterQuery == "users") } @@ -578,6 +606,8 @@ struct SidebarViewModelSearchDebounceTests { let vm = makeViewModel() vm.searchText = "u" + let debounced = DebouncedFilterQueries(of: vm) + vm.searchText = "us" vm.searchText = "use" vm.searchText = "user" @@ -585,9 +615,8 @@ struct SidebarViewModelSearchDebounceTests { #expect(vm.filterQuery == "u") - try? await Task.sleep(nanoseconds: 300_000_000) - await Task.yield() - + #expect(await debounced.first() == "user") + #expect(debounced.published == ["user"]) #expect(vm.filterQuery == "user") }