Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import CmuxMobileAgentChat
import CmuxMobileModel
import CmuxMobileTransport
import Foundation
import Observation
import Testing
@testable import CmuxMobileChief

Expand Down Expand Up @@ -314,7 +315,11 @@ 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")
#expect(sent.delivery == .delivered)
// 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)")
}

var sawTyping = false
var sawRead = false
Expand Down Expand Up @@ -354,18 +359,66 @@ func within<T: Sendable>(_ limit: Duration, _ work: @escaping @Sendable () async
}
}

/// 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),
/// 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,
sourceLocation: SourceLocation = #_sourceLocation,
_ condition: @MainActor () -> Bool) async throws {
let deadline = ContinuousClock.now.advanced(by: limit)
while !condition() {
if ContinuousClock.now >= deadline {
Issue.record("timed out waiting for \(what)", sourceLocation: sourceLocation)
throw CancellationError()
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() }
}
await Task.yield()
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<Void, Never>?

func wait(_ continuation: CheckedContinuation<Void, Never>) {
if fired { continuation.resume() } else { self.continuation = continuation }
}

func fire() {
fired = true
continuation?.resume()
continuation = nil
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import CmuxMobileTransport
import Foundation
import Observation
import Testing
@testable import CmuxMobileChief

Expand Down Expand Up @@ -154,13 +155,38 @@ final class FakeHomeOwner: @unchecked Sendable {
}

@MainActor
@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
@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 })
}

@Test func readsTheMacsHomeListOverTheChiefChannel() async throws {
Expand Down Expand Up @@ -202,6 +228,8 @@ 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")
Expand Down Expand Up @@ -322,6 +350,11 @@ 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]
Expand Down
Loading