diff --git a/cmux-next-ios/CmuxMobileKit/Tests/CmuxMobileChiefTests/ChiefTests.swift b/cmux-next-ios/CmuxMobileKit/Tests/CmuxMobileChiefTests/ChiefTests.swift index ad91722eb88a..554283d04c3a 100644 --- a/cmux-next-ios/CmuxMobileKit/Tests/CmuxMobileChiefTests/ChiefTests.swift +++ b/cmux-next-ios/CmuxMobileKit/Tests/CmuxMobileChiefTests/ChiefTests.swift @@ -3,7 +3,6 @@ import CmuxMobileAgentChat import CmuxMobileModel import CmuxMobileTransport import Foundation -import Observation import Testing @testable import CmuxMobileChief @@ -315,11 +314,7 @@ final class FakeDaemon: @unchecked Sendable { let sent = try await backend.send(ConversationOutgoingDraft(clientMessageID: "c-1", text: "run the tests", replyToID: nil, attachmentIDs: [])) #expect(sent.seq == 1) #expect(sent.clientMessageID == "c-1") - // Chief's read cursor follows the reply at once and can be applied first: read then. - switch sent.delivery { - case .delivered, .read: break - default: Issue.record("expected delivered or read, got \(sent.delivery)") - } + #expect(sent.delivery == .delivered) var sawTyping = false var sawRead = false @@ -359,66 +354,18 @@ func within(_ limit: Duration, _ work: @escaping @Sendable () async } } -/// Waits on the main actor until `condition` holds; past the watchdog, or when the -/// test is cancelled, it records an issue and throws instead of spinning forever. -@MainActor func waitUntil(_ what: String, within watchdog: Duration = waitWatchdog, +/// Waits on the main actor until `condition` holds; past `limit` it records +/// an issue and throws instead of spinning forever. +@MainActor func waitUntil(_ what: String, within limit: Duration = .seconds(20), sourceLocation: SourceLocation = #_sourceLocation, _ condition: @MainActor () -> Bool) async throws { - guard await eventually(watchdog: watchdog, condition) else { - Issue.record("timed out waiting for \(what)", sourceLocation: sourceLocation) - throw CancellationError() - } -} - -/// How long a wait may take before it gives up: a watchdog against a hang, not a -/// time the work is expected to take. In the full target a synchronous UIKit suite -/// held the main thread 11.6 s on a cold simulator, and work waited on here (main-actor -/// tasks) waits behind such a hold however fast it is, so a short limit failed tests -/// that had nothing wrong with them. -let waitWatchdog: Duration = .seconds(60) - -/// Whether `condition` came to hold before `watchdog` passed or the task was cancelled. -/// It waits on the condition's own state rather than on time: it wakes when observable -/// state the condition read changes (Observation), and checks again every `tick` for -/// state that isn't observable, such as a test double's log of requests. -@MainActor func eventually(watchdog: Duration = waitWatchdog, tick: Duration = .milliseconds(20), - _ condition: @MainActor () -> Bool) async -> Bool { - let deadline = ContinuousClock.now.advanced(by: watchdog) - while true { - let wake = Wake() - let met = withObservationTracking { condition() } onChange: { - Task { @MainActor in wake.fire() } - } - if met { return true } - if Task.isCancelled || ContinuousClock.now >= deadline { return false } - // Never past the deadline, however long the tick. - let nap = min(tick, deadline - ContinuousClock.now) - let ticker = Task { @MainActor in - try? await Task.sleep(for: nap) - wake.fire() - } - await withTaskCancellationHandler { - await withCheckedContinuation { wake.wait($0) } - } onCancel: { - Task { @MainActor in wake.fire() } + let deadline = ContinuousClock.now.advanced(by: limit) + while !condition() { + if ContinuousClock.now >= deadline { + Issue.record("timed out waiting for \(what)", sourceLocation: sourceLocation) + throw CancellationError() } - ticker.cancel() - } -} - -/// One wake-up of ``eventually``: the first of a change, a tick or a cancel resumes it. -@MainActor private final class Wake { - private var fired = false - private var continuation: CheckedContinuation? - - func wait(_ continuation: CheckedContinuation) { - if fired { continuation.resume() } else { self.continuation = continuation } - } - - func fire() { - fired = true - continuation?.resume() - continuation = nil + await Task.yield() } } diff --git a/cmux-next-ios/CmuxMobileKit/Tests/CmuxMobileChiefTests/MacHomeTests.swift b/cmux-next-ios/CmuxMobileKit/Tests/CmuxMobileChiefTests/MacHomeTests.swift index fc6ba432f706..372d187d1651 100644 --- a/cmux-next-ios/CmuxMobileKit/Tests/CmuxMobileChiefTests/MacHomeTests.swift +++ b/cmux-next-ios/CmuxMobileKit/Tests/CmuxMobileChiefTests/MacHomeTests.swift @@ -1,6 +1,5 @@ import CmuxMobileTransport import Foundation -import Observation import Testing @testable import CmuxMobileChief @@ -155,38 +154,13 @@ final class FakeHomeOwner: @unchecked Sendable { } @MainActor -@Suite(.timeLimit(.minutes(2))) struct MacHomeFeedTests { - /// Waits until `condition` holds on the main actor, waking as the feed's observable - /// state changes (``eventually``). The suite's time limit is the run's watchdog. - func until(_ condition: @MainActor () -> Bool) async -> Bool { - await eventually(condition) - } - - /// A wait wakes on the change it watches, not on a timer: its only other wake-up is - /// at the watchdog (the tick is longer), so ending well before that is the change. - @Test func aWaitWakesOnTheChangeItWatches() async { - let flag = WaitFlag() - let start = ContinuousClock.now - Task { flag.done = true } - #expect(await eventually(watchdog: .seconds(60), tick: .seconds(120)) { flag.done }) - #expect(ContinuousClock.now - start < .seconds(30)) - } - - /// The watchdog holds whatever the tick: a condition that never holds ends at it. - @Test func theWatchdogEndsAWaitWhateverTheTick() async { - let start = ContinuousClock.now - #expect(await !eventually(watchdog: .milliseconds(100), tick: .seconds(30)) { false }) - #expect(ContinuousClock.now - start < .seconds(20)) - } - - /// A cancelled wait ends at once: before, its loop ignored cancellation, so a - /// timed-out wait kept its task group from finishing. - @Test func aCancelledWaitEnds() async { - let wait = Task { await eventually { false } } - wait.cancel() - #expect(await wait.value == false) - // And a condition that never holds ends at the watchdog. - #expect(await !eventually(watchdog: .milliseconds(50)) { false }) +@Suite struct MacHomeFeedTests { + /// Waits (bounded) until `condition` holds on the main actor. + func until(_ condition: @escaping @MainActor () -> Bool) async -> Bool { + await within(.seconds(5)) { + while await !condition() { await Task.yield() } + return true + } ?? false } @Test func readsTheMacsHomeListOverTheChiefChannel() async throws { @@ -228,8 +202,6 @@ final class FakeHomeOwner: @unchecked Sendable { feed.setPinned(true, "conv_ops") #expect(await until { MacHomeList(feed.rows ?? []).pinned.map(\.id) == ["conv_build", "conv_chief", "conv_ops"] }) - // The rows show the pin at once; the write goes out from its own task. - #expect(await until { !owner.sent("conversation-inbox-op").isEmpty }) let pin = try #require(owner.sent("conversation-inbox-op").last) #expect(pin["conversation"] as? String == "conv_ops") #expect((pin["op"] as? [String: Any])?["kind"] as? String == "inbox.pin") @@ -350,11 +322,6 @@ final class PushLink: MacLink, @unchecked Sendable { } } -/// Set by a main-actor task in the wait tests; observable, as the feed is. -@MainActor @Observable final class WaitFlag { - var done = false -} - final class PushChannel: LinkChannel, @unchecked Sendable { let id: UInt32 private let respond: @Sendable (Data) -> [Data]