Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Add an overload of confirmation() with a timeout #789

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
146 changes: 145 additions & 1 deletion Sources/Testing/Issues/Confirmation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ public struct Confirmation: Sendable {
/// callers may be tempted to use it in ways that result in data races.
fileprivate var count = Locked(rawValue: 0)

/// An optional handler to call every time the count of this instance is
/// incremented.
fileprivate var countHandler: (@Sendable (_ oldCount: Int, _ newCount: Int) -> Void)?

/// Confirm this confirmation.
///
/// - Parameters:
Expand All @@ -25,7 +29,8 @@ public struct Confirmation: Sendable {
/// directly.
public func confirm(count: Int = 1) {
precondition(count > 0)
self.count.add(count)
let newCount = self.count.add(count)
countHandler?(newCount - count, newCount)
}
}

Expand Down Expand Up @@ -92,6 +97,12 @@ extension Confirmation {
///
/// When the closure returns, the testing library checks if the confirmation's
/// preconditions have been met, and records an issue if they have not.
///
/// @Comment {
/// If the work performed by `body` may continue after it returns, use
/// ``confirmation(_:within:expectedCount:isolation:sourceLocation:_:)``
/// instead.
/// }
public func confirmation<R>(
_ comment: Comment? = nil,
expectedCount: Int = 1,
Expand Down Expand Up @@ -161,6 +172,12 @@ public func confirmation<R>(
///
/// If an exact count is expected, use
/// ``confirmation(_:expectedCount:isolation:sourceLocation:_:)-5mqz2`` instead.
///
/// @Comment {
/// If the work performed by `body` may continue after it returns, use
/// ``confirmation(_:within:expectedCount:isolation:sourceLocation:_:)``
/// instead.
/// }
public func confirmation<R>(
_ comment: Comment? = nil,
expectedCount: some RangeExpression<Int> & Sequence<Int> & Sendable,
Expand Down Expand Up @@ -231,3 +248,130 @@ public func confirmation<R>(
) async rethrows -> R {
fatalError("Unsupported")
}

// MARK: - Confirmations with time limits

/// Confirm that some event occurs during the invocation of a function and
/// within a time limit.
///
/// - Parameters:
/// - comment: An optional comment to apply to any issues generated by this
/// function.
/// - timeLimit: How long to wait after calling `body` before failing
/// (including any time spent in `body` itself.)
/// - expectedCount: A range of integers indicating the number of times the
/// expected event should occur when `body` is invoked.
/// - isolation: The actor to which `body` is isolated, if any.
/// - sourceLocation: The source location to which any recorded issues should
/// be attributed.
/// - body: The function to invoke.
///
/// - Returns: Whatever is returned by `body`.
///
/// - Throws: Whatever is thrown by `body`.
///
/// Use confirmations to check that an event occurs while a test is running in
/// complex scenarios where `#expect()` and `#require()` are insufficient. For
/// example, a confirmation may be useful when an expected event occurs:
///
/// - In a context that cannot be awaited by the calling function such as an
/// event handler or delegate callback;
/// - More than once, or never; or
/// - As a callback that is invoked as part of a larger operation.
///
/// To use a confirmation, pass a closure containing the work to be performed.
/// The testing library will then pass an instance of ``Confirmation`` to the
/// closure. Every time the event in question occurs, the closure should call
/// the confirmation:
///
/// ```swift
/// let n = 10
/// await confirmation(
/// "Baked \(n) buns within 30 minutes",
/// within: .seconds(30 * 60), // 30 minutes or it's free
/// expectedCount: n
/// ) { bunBaked in
/// foodTruck.eventHandler = { event in
/// if event == .baked(.cinnamonBun) {
/// bunBaked()
/// }
/// }
/// await foodTruck.bake(.cinnamonBun, count: n)
/// }
/// ```
///
/// When the closure returns, the testing library checks if the confirmation's
/// preconditions have been met. If they have not and the call to the closure
/// took less time than `timeLimit`, the testing library continues waiting for
/// up to `timeLimit` and records an issue if the confirmation's preconditions
/// have not been met by that time.
///
/// Because the confirmation passed to `body` may escape and continue running
/// after `body` has returned, the confirmation may be confirmed more times than
/// the lower bound of `expectedCount`. You cannot, therefore, specify an upper
/// bound for this range.
///
/// If `body` will complete all work needed to confirm the confirmation before
/// it returns, you can use ``confirmation(_:expectedCount:isolation:sourceLocation:_:)-5mqz2``
/// or ``confirmation(_:expectedCount:isolation:sourceLocation:_:)-l3il``
/// instead of this function.
@_spi(Experimental)
#if !SWT_NO_UNSTRUCTURED_TASKS
@available(_clockAPI, *)
#else
@available(*, unavailable, message: "Confirmations with time limits are not available on this platform.")
#endif
public func confirmation<R>(
_ comment: Comment? = nil,
within timeLimit: Duration,
expectedCount: PartialRangeFrom<Int> = 1...,
isolation: isolated (any Actor)? = #isolation,
sourceLocation: SourceLocation = #_sourceLocation,
_ body: (Confirmation) async throws -> R
) async rethrows -> R {
#if !SWT_NO_UNSTRUCTURED_TASKS
return try await confirmation(comment, expectedCount: expectedCount, sourceLocation: sourceLocation) { confirmation in
let start = Test.Clock.Instant.now

// Configure the confirmation to end a locally-created async stream. We can
// then use the stream as a sort of continuation except that it can be
// "resumed" more than once and does not require our code to run in an
// unstructured child task.
let (stream, continuation) = AsyncStream<Never>.makeStream()
var confirmation = confirmation
confirmation.countHandler = { oldCount, newCount in
if !expectedCount.contains(oldCount) && expectedCount.contains(newCount) {
continuation.finish()
}
}

// Run the caller-supplied body closure.
let result = try await body(confirmation)

// If the confirmation hasn't been fully confirmed yet, or if `body` overran
// the time limit, wait for whatever time remains (zero in the latter case.)
let remainingTimeLimit = timeLimit - start.duration(to: .now)
if !expectedCount.contains(confirmation.count.rawValue) || remainingTimeLimit < .zero {
await withTimeLimit(remainingTimeLimit) {
// If the confirmation has already been fully confirmed, this "loop"
// will exit immediately.
for await _ in stream {}
} timeoutHandler: {
// We ran out of time, so record the issue.
let issue = Issue(
kind: .timeLimitExceeded(timeLimitComponents: timeLimit.components),
comments: [],
sourceContext: .init(backtrace: .current(), sourceLocation: sourceLocation)
)
issue.record()

// Don't let the other closure wait any longer.
continuation.finish()
}
}

return result
}
#endif
}

29 changes: 14 additions & 15 deletions Sources/Testing/Traits/TimeLimitTrait.swift
Original file line number Diff line number Diff line change
Expand Up @@ -236,18 +236,6 @@ extension Test {

// MARK: -

/// An error that is reported when a test times out.
///
/// This type is not part of the public interface of the testing library.
struct TimeoutError: Error, CustomStringConvertible {
/// The time limit exceeded by the test that timed out.
var timeLimit: TimeValue

var description: String {
"Timed out after \(timeLimit) seconds."
}
}

#if !SWT_NO_UNSTRUCTURED_TASKS
/// Invoke a function with a timeout.
///
Expand All @@ -268,13 +256,24 @@ func withTimeLimit(
_ timeLimit: Duration,
_ body: @escaping @Sendable () async throws -> Void,
timeoutHandler: @escaping @Sendable () -> Void
) async throws {
) async rethrows {
// Early exit if the time limit has already been met (this simplifies callers
// that need to divide up a time limit across multiple operations.)
if timeLimit <= .zero {
timeoutHandler()
return
}

try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
do {
try await Test.Clock.sleep(for: timeLimit)
} catch {
return
}
// If sleep() returns instead of throwing a CancellationError, that means
// the timeout was reached before this task could be cancelled, so call
// the timeout handler.
try await Test.Clock.sleep(for: timeLimit)
timeoutHandler()
}
group.addTask(operation: body)
Expand Down Expand Up @@ -309,7 +308,7 @@ func withTimeLimit(
configuration: Configuration,
_ body: @escaping @Sendable () async throws -> Void,
timeoutHandler: @escaping @Sendable (_ timeLimit: (seconds: Int64, attoseconds: Int64)) -> Void
) async throws {
) async rethrows {
if #available(_clockAPI, *),
let timeLimit = test.adjustedTimeLimit(configuration: configuration) {
#if SWT_NO_UNSTRUCTURED_TASKS
Expand Down
115 changes: 115 additions & 0 deletions Tests/TestingTests/ConfirmationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,121 @@ struct ConfirmationTests {
}
#endif

#if !SWT_NO_UNSTRUCTURED_TASKS
@available(_clockAPI, *)
@Test("Confirmation times out")
func timesOut() async {
await confirmation("Timed out") { timedOut in
await confirmation("Miscounted", expectedCount: 0) { confirmationMiscounted in
var configuration = Configuration()
configuration.eventHandler = { event, _ in
if case let .issueRecorded(issue) = event.kind {
switch issue.kind {
case .timeLimitExceeded:
timedOut()
case .confirmationMiscounted:
confirmationMiscounted()
default:
break
}
}
}
await Test {
await confirmation(within: .milliseconds(10)) { confirmation in
try? await Test.Clock.sleep(for: .milliseconds(15))
confirmation()
}
}.run(configuration: configuration)
}
}
}

@available(_clockAPI, *)
@Test("Confirmation times out regardless of confirming when 0 duration")
func timesOutWithZeroDuration() async {
await confirmation("Timed out") { timedOut in
await confirmation("Miscounted", expectedCount: 0) { confirmationMiscounted in
var configuration = Configuration()
configuration.eventHandler = { event, _ in
if case let .issueRecorded(issue) = event.kind {
switch issue.kind {
case .timeLimitExceeded:
timedOut()
case .confirmationMiscounted:
confirmationMiscounted()
default:
break
}
}
}
await Test {
await confirmation(within: .zero) { confirmation in
confirmation()
}
}.run(configuration: configuration)
}
}
}

@available(_clockAPI, *)
@Test("Confirmation does not take up the full run time when confirmed")
func doesNotTimeOutWhenConfirmed() async {
let duration = await Test.Clock().measure {
await confirmation("Timed out", expectedCount: 0) { timedOut in
await confirmation("Miscounted", expectedCount: 0) { confirmationMiscounted in
var configuration = Configuration()
configuration.eventHandler = { event, _ in
if case let .issueRecorded(issue) = event.kind {
switch issue.kind {
case .timeLimitExceeded:
timedOut()
case .confirmationMiscounted:
confirmationMiscounted()
default:
break
}
}
}
await Test {
await confirmation(within: .seconds(120)) { confirmation in
_ = Task {
try await Test.Clock.sleep(for: .milliseconds(50))
confirmation()
}
}
}.run(configuration: configuration)
}
}
}
#expect(duration < .seconds(30))
}

@available(_clockAPI, *)
@Test("Confirmation records a timeout AND miscount when not confirmed")
func timesOutAndMiscounts() async {
await confirmation("Timed out") { timedOut in
await confirmation("Miscounted") { confirmationMiscounted in
var configuration = Configuration()
configuration.eventHandler = { event, _ in
if case let .issueRecorded(issue) = event.kind {
switch issue.kind {
case .timeLimitExceeded:
timedOut()
case .confirmationMiscounted:
confirmationMiscounted()
default:
break
}
}
}
await Test {
await confirmation(within: .zero) { _ in }
}.run(configuration: configuration)
}
}
}
#endif

@Test("Main actor isolation")
@MainActor
func mainActorIsolated() async {
Expand Down
6 changes: 0 additions & 6 deletions Tests/TestingTests/Traits/TimeLimitTraitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -218,12 +218,6 @@ struct TimeLimitTraitTests {
}
}

@Test("TimeoutError.description property")
func timeoutErrorDescription() async throws {
let timeLimit = TimeValue((0, 0))
#expect(String(describing: TimeoutError(timeLimit: timeLimit)).contains("0.000"))
}

@Test("Issue.Kind.timeLimitExceeded.description property",
arguments: [
(123, 0, "123.000"),
Expand Down