Created
August 26, 2025 19:28
-
-
Save minsOne/91b0538ba95df9dffd0dd6306f9f3f04 to your computer and use it in GitHub Desktop.
SwiftAsyncTestPolling.swift - AsyncStream
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| public struct EventuallyTimeoutError: Error, CustomStringConvertible { | |
| public let label: String | |
| public let message: String | |
| public var description: String { label.isEmpty ? message : "[\(label)] \(message)" } | |
| public init(label: String = "", message: String) { | |
| self.label = label | |
| self.message = message | |
| } | |
| } | |
| func makePollingStream<T: Sendable>( | |
| current: @escaping @Sendable () async -> T, | |
| until: @escaping @Sendable (T) -> Bool, | |
| interval: Duration = .milliseconds(20), | |
| timeout: Duration = .seconds(2), | |
| isSame: (@Sendable (T, T) -> Bool)? = nil | |
| ) -> AsyncStream<T> { | |
| let (stream, continuation) = AsyncStream<T>.makeStream(of: T.self) | |
| let clock = ContinuousClock() | |
| Task { | |
| let start = clock.now | |
| var last: T? = nil | |
| var now = await current() | |
| continuation.yield(now) | |
| last = now | |
| if until(now) { | |
| continuation.finish() | |
| return | |
| } | |
| while clock.now - start < timeout { | |
| try? await clock.sleep(for: interval) | |
| now = await current() | |
| if let cmp = isSame, let prev = last { | |
| if cmp(now, prev) == false { | |
| continuation.yield(now) | |
| last = now | |
| } | |
| } else { | |
| continuation.yield(now) | |
| last = now | |
| } | |
| if until(now) { break } | |
| } | |
| continuation.finish() | |
| } | |
| return stream | |
| } | |
| public func assertEventually<T: Sendable>( | |
| _ label: String = "", | |
| current: @escaping @Sendable () async -> T, | |
| until: @escaping @Sendable (T) -> Bool, | |
| timeout: TimeInterval = 2.0, | |
| interval: TimeInterval = 0.02, | |
| isSame: (@Sendable (T, T) -> Bool)? = nil, | |
| describe: @escaping (T) -> String = { "\($0)" } | |
| ) async throws { | |
| let stream = makePollingStream( | |
| current: current, | |
| until: until, | |
| interval: .seconds(interval), | |
| timeout: .seconds(timeout), | |
| isSame: isSame | |
| ) | |
| var last: T? | |
| for await v in stream { | |
| last = v | |
| if until(v) { return } | |
| } | |
| throw EventuallyTimeoutError( | |
| label: label, | |
| message: "Timed out. last=\(last.map(describe) ?? "nil")" | |
| ) | |
| } | |
| public func assertEventuallyEqual<T: Equatable & Sendable>( | |
| _ expected: T, | |
| _ current: @escaping @Sendable () async -> T, | |
| label: String = "", | |
| timeout: TimeInterval = 2.0, | |
| interval: TimeInterval = 0.02, | |
| describe: @escaping (T) -> String = { "\($0)" } | |
| ) async throws { | |
| try await assertEventually( | |
| label, | |
| current: current, | |
| until: { $0 == expected }, | |
| timeout: timeout, | |
| interval: interval, | |
| isSame: { $0 == $1 }, | |
| describe: { "value=\(describe($0)), expected=\(expected)" } | |
| ) | |
| } | |
| public struct Check<T: Sendable & Equatable>: Sendable { | |
| let name: String | |
| let expected: T | |
| let current: @Sendable () async -> T | |
| let describe: @Sendable (T) -> String | |
| public init( | |
| name: String, | |
| expected: T, | |
| current: @escaping @Sendable () async -> T, | |
| describe: @escaping @Sendable (T) -> String = { "\($0)" } | |
| ) { | |
| self.name = name | |
| self.current = current | |
| self.expected = expected | |
| self.describe = describe | |
| } | |
| public func isSame(_ lhs: T, _ rhs: T) -> Bool { | |
| lhs == rhs | |
| } | |
| public func debugExpectedDescription() -> String { | |
| "expected=\(describe(expected))" | |
| } | |
| } | |
| struct ItemResult<T: Sendable>: Sendable { | |
| let name: String | |
| let last: T? | |
| let isSatisfied: Bool | |
| } | |
| public func assertEventuallyAllEqual<T: Sendable>( | |
| _ checks: [Check<T>], | |
| label: String = "", | |
| timeout: TimeInterval = 2.0, | |
| interval: TimeInterval = 0.02, | |
| ) async throws { | |
| let results: [ItemResult<T>] = try await withThrowingTaskGroup(of: ItemResult<T>.self) { group in | |
| for check in checks { | |
| group.addTask { | |
| let stream = makePollingStream( | |
| current: check.current, | |
| until: { check.expected == $0 }, | |
| interval: .seconds(interval), | |
| timeout: .seconds(timeout), | |
| isSame: check.isSame(_:_:) | |
| ) | |
| var last: T? | |
| for await value in stream { | |
| last = value | |
| if check.expected == value { | |
| return .init(name: check.name, last: value, isSatisfied: true) | |
| } | |
| } | |
| return .init(name: check.name, last: last, isSatisfied: false) | |
| } | |
| } | |
| var out: [ItemResult<T>] = [] | |
| for try await result in group { | |
| out.append(result) | |
| } | |
| return out | |
| } | |
| if results.allSatisfy(\.isSatisfied) { return } | |
| let lines = results.map { result -> String in | |
| let check = checks.first { $0.name == result.name } | |
| guard let check else { | |
| return "• \(result.name): (unknown check)" | |
| } | |
| return result.isSatisfied | |
| ? "• \(result.name): satisfied (last=\(result.last.map(check.describe) ?? "nil"))" | |
| : "• \(result.name): ✗ timeout (last=\(result.last.map(check.describe) ?? "nil"), \(check.debugExpectedDescription()))" | |
| }.joined(separator: "\n") | |
| throw EventuallyTimeoutError( | |
| label: label, | |
| message: "Timed out waiting for all checks.\n" + lines | |
| ) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment