Skip to content

Polling expectations (under Experimental spi) #1115

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
7 changes: 7 additions & 0 deletions Sources/Testing/Issues/Issue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ public struct Issue: Sendable {
/// confirmed too few or too many times.
indirect case confirmationMiscounted(actual: Int, expected: any RangeExpression & Sendable)

@_spi(Experimental)
case confirmationPollingFailed

/// An issue due to an `Error` being thrown by a test function and caught by
/// the testing library.
///
Expand Down Expand Up @@ -286,6 +289,8 @@ extension Issue.Kind: CustomStringConvertible {
}
}
return "Confirmation was confirmed \(actual.counting("time")), but expected to be confirmed \(String(describingForTest: expected)) time(s)"
case .confirmationPollingFailed:
return "Confirmation polling failed"
case let .errorCaught(error):
return "Caught error: \(error)"
case let .timeLimitExceeded(timeLimitComponents: timeLimitComponents):
Expand Down Expand Up @@ -465,6 +470,8 @@ extension Issue.Kind {
.expectationFailed(Expectation.Snapshot(snapshotting: expectation))
case .confirmationMiscounted:
.unconditional
case .confirmationPollingFailed:
.unconditional
case let .errorCaught(error), let .valueAttachmentFailed(error):
.errorCaught(ErrorSnapshot(snapshotting: error))
case let .timeLimitExceeded(timeLimitComponents: timeLimitComponents):
Expand Down
362 changes: 362 additions & 0 deletions Sources/Testing/Polling/Polling.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,362 @@
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2025 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
//

/// Confirm that some expression eventually returns true
///
/// - Parameters:
/// - comment: An optional comment to apply to any issues generated by this
/// function.
/// - isolation: The actor to which `body` is isolated, if any.
/// - sourceLocation: The source location to whych any recorded issues should
/// be attributed.
/// - body: The function to invoke.
///
/// Use polling confirmations to check that an event while a test is running in
/// complex scenarios where other forms of confirmation are insufficient. For
/// example, waiting on some state to change that cannot be easily confirmed
/// through other forms of `confirmation`.
@_spi(Experimental)
@available(macOS 13, iOS 17, watchOS 9, tvOS 17, visionOS 1, *)
public func confirmPassesEventually(
_ comment: Comment? = nil,
maxPollingIterations: Int = 1000,
pollingInterval: Duration = .milliseconds(1),
isolation: isolated (any Actor)? = #isolation,
sourceLocation: SourceLocation = #_sourceLocation,
_ body: @escaping () async throws -> Bool
) async {
let poller = Poller(
pollingBehavior: .passesOnce,
pollingIterations: maxPollingIterations,
pollingInterval: pollingInterval,
comment: comment,
sourceLocation: sourceLocation
)
await poller.evaluate(isolation: isolation) {
do {
return try await body()
} catch {
return false
}
}
}

/// A type describing an error thrown when polling fails to return a non-nil
/// value
@_spi(Experimental)
public struct PollingFailedError: Error {}

/// Confirm that some expression eventually returns a non-nil value
///
/// - Parameters:
/// - comment: An optional comment to apply to any issues generated by this
/// function.
/// - isolation: The actor to which `body` is isolated, if any.
/// - sourceLocation: The source location to whych any recorded issues should
/// be attributed.
/// - body: The function to invoke.
///
/// - Returns: The first non-nil value returned by `body`.
///
/// - Throws: A `PollingFailedError` will be thrown if `body` never returns a
/// non-optional value
///
/// Use polling confirmations to check that an event while a test is running in
/// complex scenarios where other forms of confirmation are insufficient. For
/// example, waiting on some state to change that cannot be easily confirmed
/// through other forms of `confirmation`.
@_spi(Experimental)
@available(macOS 13, iOS 17, watchOS 9, tvOS 17, visionOS 1, *)
@discardableResult
public func confirmPassesEventually<R>(
_ comment: Comment? = nil,
maxPollingIterations: Int = 1000,
pollingInterval: Duration = .milliseconds(1),
isolation: isolated (any Actor)? = #isolation,
sourceLocation: SourceLocation = #_sourceLocation,
_ body: @escaping () async throws -> R?
) async throws -> R where R: Sendable {
let recorder = PollingRecorder<R>()
let poller = Poller(
pollingBehavior: .passesOnce,
pollingIterations: maxPollingIterations,
pollingInterval: pollingInterval,
comment: comment,
sourceLocation: sourceLocation
)
await poller.evaluate(isolation: isolation) {
do {
return try await recorder.record(value: body())
} catch {
return false
}
}

if let value = await recorder.lastValue {
return value
}
throw PollingFailedError()
}

/// Confirm that some expression always returns true
///
/// - Parameters:
/// - comment: An optional comment to apply to any issues generated by this
/// function.
/// - isolation: The actor to which `body` is isolated, if any.
/// - sourceLocation: The source location to whych any recorded issues should
/// be attributed.
/// - body: The function to invoke.
///
/// Use polling confirmations to check that an event while a test is running in
/// complex scenarios where other forms of confirmation are insufficient. For
/// example, confirming that some state does not change.
@_spi(Experimental)
@available(macOS 13, iOS 17, watchOS 9, tvOS 17, visionOS 1, *)
public func confirmAlwaysPasses(
_ comment: Comment? = nil,
maxPollingIterations: Int = 1000,
pollingInterval: Duration = .milliseconds(1),
isolation: isolated (any Actor)? = #isolation,
sourceLocation: SourceLocation = #_sourceLocation,
_ body: @escaping () async throws -> Bool
) async {
let poller = Poller(
pollingBehavior: .passesAlways,
pollingIterations: maxPollingIterations,
pollingInterval: pollingInterval,
comment: comment,
sourceLocation: sourceLocation
)
await poller.evaluate(isolation: isolation) {
do {
return try await body()
} catch {
return false
}
}
}

/// Confirm that some expression always returns a non-optional value
///
/// - Parameters:
/// - comment: An optional comment to apply to any issues generated by this
/// function.
/// - isolation: The actor to which `body` is isolated, if any.
/// - sourceLocation: The source location to whych any recorded issues should
/// be attributed.
/// - body: The function to invoke.
///
/// - Returns: The value from the last time `body` was invoked.
///
/// - Throws: A `PollingFailedError` will be thrown if `body` ever returns a
/// non-optional value
///
/// Use polling confirmations to check that an event while a test is running in
/// complex scenarios where other forms of confirmation are insufficient. For
/// example, confirming that some state does not change.
@_spi(Experimental)
@available(macOS 13, iOS 17, watchOS 9, tvOS 17, visionOS 1, *)
public func confirmAlwaysPasses<R>(
_ comment: Comment? = nil,
maxPollingIterations: Int = 1000,
pollingInterval: Duration = .milliseconds(1),
isolation: isolated (any Actor)? = #isolation,
sourceLocation: SourceLocation = #_sourceLocation,
_ body: @escaping () async throws -> R?
) async {
let poller = Poller(
pollingBehavior: .passesAlways,
pollingIterations: maxPollingIterations,
pollingInterval: pollingInterval,
comment: comment,
sourceLocation: sourceLocation
)
await poller.evaluate(isolation: isolation) {
do {
return try await body() != nil
} catch {
return false
}
}
}

/// A type to record the last value returned by a closure returning an optional
/// This is only used in the `confirm` polling functions evaluating an optional.
private actor PollingRecorder<R: Sendable> {
var lastValue: R?

/// Record a new value to be returned
func record(value: R) {
self.lastValue = value
}

func record(value: R?) -> Bool {
if let value {
self.lastValue = value
return true
} else {
return false
}
}
}

/// A type for managing polling
@available(macOS 13, iOS 17, watchOS 9, tvOS 17, visionOS 1, *)
private struct Poller {
enum PollingBehavior {
/// Continuously evaluate the expression until the first time it returns
/// true.
/// If it does not pass once by the time the timeout is reached, then a
/// failure will be reported.
case passesOnce

/// Continuously evaluate the expression until the first time it returns
/// false.
/// If the expression returns false, then a failure will be reported.
/// If the expression only returns true before the timeout is reached, then
/// no failure will be reported.
/// If the expression does not finish evaluating before the timeout is
/// reached, then a failure will be reported.
case passesAlways

/// Process the result of a polled expression and decide whether to continue polling.
///
/// - Parameters:
/// - expressionResult: The result of the polled expression
///
/// - Returns: A poll result (if polling should stop), or nil (if polling should continue)
func processFinishedExpression(
expressionResult result: Bool
) -> PollResult? {
switch self {
case .passesOnce:
if result {
return .finished
} else {
return nil
}
case .passesAlways:
if !result {
return .failed
} else {
return nil
}
}
}
}

/// The result of polling expressions
enum PollResult {
/// The polling ran for the total number of iterations
case ranToCompletion
/// The expression exited early, and we will report a success status.
case finished
/// The expression returned false under PollingBehavior.passesAlways
case failed
/// The polling was cancelled before polling could finish
case cancelled

/// Process the poll result into an issue
///
/// - Parameters:
/// - comment: The comment to record as part of the issue
/// - sourceContext: The source context for the issue
/// - pollingBehavior: The polling behavior used.
/// - Returns: An issue if one should be recorded, otherwise nil.
func issue(
comment: Comment?,
sourceContext: SourceContext,
pollingBehavior: PollingBehavior
) -> Issue? {
let issueKind: Issue.Kind
switch self {
case .finished, .cancelled:
return nil
case .ranToCompletion:
if case .passesAlways = pollingBehavior {
return nil
}
issueKind = .confirmationPollingFailed
case .failed:
issueKind = .confirmationPollingFailed
}
return Issue(
kind: issueKind,
comments: Array(comment),
sourceContext: sourceContext
)
}
}

/// The polling behavior (poll until the expression first passes, or poll
/// while the expression continues to pass)
let pollingBehavior: PollingBehavior

// How many times to poll
let pollingIterations: Int
// Minimum waiting period between polling
let pollingInterval: Duration

/// A comment from the test author associated with the polling
let comment: Comment?

/// The source location that asked for polling.
let sourceLocation: SourceLocation

/// Evaluate polling, and process the result, raising an issue if necessary.
///
/// - Parameters:
/// - body: The expression to poll
/// - Side effects: If polling fails (see `PollingBehavior`), then this will
/// record an issue.
func evaluate(
isolation: isolated (any Actor)?,
_ body: @escaping () async -> Bool
) async {
let result = await poll(
expression: body
)
result.issue(
comment: comment,
sourceContext: .init(backtrace: .current(), sourceLocation: sourceLocation),
pollingBehavior: pollingBehavior
)?.record()
}

/// This function contains the logic for continuously polling an expression,
/// as well as processing the results of that expression
///
/// - Parameters:
/// - expression: An expression to continuously evaluate
/// - behavior: The polling behavior to use
/// - timeout: How long to poll for unitl the timeout triggers.
/// - Returns: The result of this polling.
private func poll(
isolation: isolated (any Actor)? = #isolation,
expression: @escaping () async -> Bool
) async -> PollResult {
for _ in 0..<pollingIterations {
if let result = await pollingBehavior.processFinishedExpression(
expressionResult: expression()
) {
return result
}
do {
try await Task.sleep(for: pollingInterval)
} catch {
// `Task.sleep` should only throw an error if it's cancelled
// during the sleep period.
return .cancelled
}
}
return .ranToCompletion
}
}
Loading