diff --git a/Sources/Testing/Issues/Confirmation.swift b/Sources/Testing/Issues/Confirmation.swift index 28621e404..e3950b5ff 100644 --- a/Sources/Testing/Issues/Confirmation.swift +++ b/Sources/Testing/Issues/Confirmation.swift @@ -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: @@ -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) } } @@ -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( _ comment: Comment? = nil, expectedCount: Int = 1, @@ -161,6 +172,12 @@ public func confirmation( /// /// 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( _ comment: Comment? = nil, expectedCount: some RangeExpression & Sequence & Sendable, @@ -231,3 +248,130 @@ public func confirmation( ) 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( + _ comment: Comment? = nil, + within timeLimit: Duration, + expectedCount: PartialRangeFrom = 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.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 +} + diff --git a/Sources/Testing/Traits/TimeLimitTrait.swift b/Sources/Testing/Traits/TimeLimitTrait.swift index fe1d7f787..0854dc901 100644 --- a/Sources/Testing/Traits/TimeLimitTrait.swift +++ b/Sources/Testing/Traits/TimeLimitTrait.swift @@ -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. /// @@ -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) @@ -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 diff --git a/Tests/TestingTests/ConfirmationTests.swift b/Tests/TestingTests/ConfirmationTests.swift index 7fe824d71..3394d6348 100644 --- a/Tests/TestingTests/ConfirmationTests.swift +++ b/Tests/TestingTests/ConfirmationTests.swift @@ -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 { diff --git a/Tests/TestingTests/Traits/TimeLimitTraitTests.swift b/Tests/TestingTests/Traits/TimeLimitTraitTests.swift index 8978e86fd..2bcf148da 100644 --- a/Tests/TestingTests/Traits/TimeLimitTraitTests.swift +++ b/Tests/TestingTests/Traits/TimeLimitTraitTests.swift @@ -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"),