Skip to content

Commit d2f979a

Browse files
committed
Include a failure reason to help the console figure out why a polling confirmation failed
1 parent 7e0385c commit d2f979a

File tree

2 files changed

+109
-43
lines changed

2 files changed

+109
-43
lines changed

Sources/Testing/Issues/Issue.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,16 @@ public struct Issue: Sendable {
4040

4141
/// An issue due to a polling confirmation having failed.
4242
///
43+
/// - Parameters:
44+
/// - reason: The ``PollingFailureReason`` behind why the polling
45+
/// confirmation failed.
46+
///
4347
/// This issue can occur when calling ``confirmation(_:until:within:pollingEvery:isolation:sourceLocation:_:)-455gr``
4448
/// or
4549
/// ``confirmation(_:until:within:pollingEvery:isolation:sourceLocation:_:)-5tnlk``
4650
/// whenever the polling fails, as described in ``PollingStopCondition``.
4751
@_spi(Experimental)
48-
case pollingConfirmationFailed
52+
case pollingConfirmationFailed(reason: PollingFailureReason)
4953

5054
/// An issue due to an `Error` being thrown by a test function and caught by
5155
/// the testing library.

Sources/Testing/Polling/Polling.swift

Lines changed: 104 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,43 @@ internal let defaultPollingConfiguration = (
1515
pollingInterval: Duration.milliseconds(1)
1616
)
1717

18+
/// A type defining when to stop polling.
19+
/// This also determines what happens if the duration elapses during polling.
20+
@_spi(Experimental)
21+
public enum PollingStopCondition: Sendable, Equatable, Codable {
22+
/// Evaluates the expression until the first time it returns true.
23+
/// If it does not pass once by the time the timeout is reached, then a
24+
/// failure will be reported.
25+
case firstPass
26+
27+
/// Evaluates the expression until the first time it returns false.
28+
/// If the expression returns false, then a failure will be reported.
29+
/// If the expression only returns true before the timeout is reached, then
30+
/// no failure will be reported.
31+
/// If the expression does not finish evaluating before the timeout is
32+
/// reached, then a failure will be reported.
33+
case stopsPassing
34+
}
35+
36+
/// A type describing why polling failed
37+
@_spi(Experimental)
38+
public enum PollingFailureReason: Sendable, Codable {
39+
/// The polling failed because it was cancelled using `Task.cancel`.
40+
case cancelled
41+
42+
/// The polling failed because the stop condition failed.
43+
case stopConditionFailed(PollingStopCondition)
44+
}
45+
1846
/// A type describing an error thrown when polling fails.
1947
@_spi(Experimental)
2048
public struct PollingFailedError: Error, Sendable, Codable {
2149
/// A user-specified comment describing this confirmation
2250
public var comment: Comment?
2351

52+
/// Why polling failed, either cancelled, or because the stop condition failed.
53+
public var reason: PollingFailureReason
54+
2455
/// A ``SourceContext`` indicating where and how this confirmation was called
2556
@_spi(ForToolsIntegrationOnly)
2657
public var sourceContext: SourceContext
@@ -30,13 +61,16 @@ public struct PollingFailedError: Error, Sendable, Codable {
3061
/// - Parameters:
3162
/// - comment: A user-specified comment describing this confirmation.
3263
/// Defaults to `nil`.
64+
/// - reason: The reason why polling failed.
3365
/// - sourceContext: A ``SourceContext`` indicating where and how this
3466
/// confirmation was called.
35-
public init(
67+
init(
3668
comment: Comment? = nil,
37-
sourceContext: SourceContext
69+
reason: PollingFailureReason,
70+
sourceContext: SourceContext,
3871
) {
3972
self.comment = comment
73+
self.reason = reason
4074
self.sourceContext = sourceContext
4175
}
4276
}
@@ -46,29 +80,14 @@ extension PollingFailedError: CustomIssueRepresentable {
4680
if let comment {
4781
issue.comments.append(comment)
4882
}
49-
issue.kind = .pollingConfirmationFailed
83+
issue.kind = .pollingConfirmationFailed(
84+
reason: reason
85+
)
5086
issue.sourceContext = sourceContext
5187
return issue
5288
}
5389
}
5490

55-
/// A type defining when to stop polling early.
56-
/// This also determines what happens if the duration elapses during polling.
57-
public enum PollingStopCondition: Sendable, Equatable {
58-
/// Evaluates the expression until the first time it returns true.
59-
/// If it does not pass once by the time the timeout is reached, then a
60-
/// failure will be reported.
61-
case firstPass
62-
63-
/// Evaluates the expression until the first time it returns false.
64-
/// If the expression returns false, then a failure will be reported.
65-
/// If the expression only returns true before the timeout is reached, then
66-
/// no failure will be reported.
67-
/// If the expression does not finish evaluating before the timeout is
68-
/// reached, then a failure will be reported.
69-
case stopsPassing
70-
}
71-
7291
/// Poll expression within the duration based on the given stop condition
7392
///
7493
/// - Parameters:
@@ -229,23 +248,46 @@ private func getValueFromTrait<TraitKind, Value>(
229248
}
230249

231250
extension PollingStopCondition {
251+
/// The result of processing polling.
252+
enum PollingProcessResult<R> {
253+
/// Continue to poll.
254+
case continuePolling
255+
/// Polling succeeded.
256+
case succeeded(R)
257+
/// Polling failed.
258+
case failed
259+
}
232260
/// Process the result of a polled expression and decide whether to continue
233261
/// polling.
234262
///
235263
/// - Parameters:
236264
/// - expressionResult: The result of the polled expression.
265+
/// - wasLastPollingAttempt: If this was the last time we're attempting to
266+
/// poll.
237267
///
238-
/// - Returns: A poll result (if polling should stop), or nil (if polling
239-
/// should continue).
240-
@available(_clockAPI, *)
241-
fileprivate func shouldStopPolling(
242-
expressionResult result: Bool
243-
) -> Bool {
268+
/// - Returns: A process result. Whether to continue polling, stop because
269+
/// polling failed, or stop because polling succeeded.
270+
fileprivate func process<R>(
271+
expressionResult result: R?,
272+
wasLastPollingAttempt: Bool
273+
) -> PollingProcessResult<R> {
244274
switch self {
245275
case .firstPass:
246-
return result
276+
if let result {
277+
return .succeeded(result)
278+
} else {
279+
return .continuePolling
280+
}
247281
case .stopsPassing:
248-
return !result
282+
if let result {
283+
if wasLastPollingAttempt {
284+
return .succeeded(result)
285+
} else {
286+
return .continuePolling
287+
}
288+
} else {
289+
return .failed
290+
}
249291
}
250292
}
251293

@@ -344,11 +386,30 @@ private struct Poller {
344386

345387
let iterations = max(Int(duration.seconds() / interval.seconds()), 1)
346388

347-
if let value = await poll(iterations: iterations, expression: body) {
389+
let failureReason: PollingFailureReason
390+
switch await poll(iterations: iterations, expression: body) {
391+
case let .succeeded(value):
348392
return value
349-
} else {
350-
throw PollingFailedError(comment: comment, sourceContext: sourceContext)
393+
case .cancelled:
394+
failureReason = .cancelled
395+
case .failed:
396+
failureReason = .stopConditionFailed(stopCondition)
351397
}
398+
throw PollingFailedError(
399+
comment: comment,
400+
reason: failureReason,
401+
sourceContext: sourceContext
402+
)
403+
}
404+
405+
/// The result of polling.
406+
private enum PollingResult<R> {
407+
/// Polling was cancelled using `Task.Cancel`. This is treated as a failure.
408+
case cancelled
409+
/// The stop condition failed.
410+
case failed
411+
/// The stop condition passed.
412+
case succeeded(R)
352413
}
353414

354415
/// This function contains the logic for continuously polling an expression,
@@ -363,26 +424,27 @@ private struct Poller {
363424
iterations: Int,
364425
isolation: isolated (any Actor)? = #isolation,
365426
expression: @escaping () async -> sending R?
366-
) async -> R? {
367-
var lastResult: R?
427+
) async -> PollingResult<R> {
368428
for iteration in 0..<iterations {
369-
lastResult = await expression()
370-
if stopCondition.shouldStopPolling(expressionResult: lastResult != nil) {
371-
return lastResult
372-
}
373-
if iteration == (iterations - 1) {
374-
// don't bother sleeping if it's the last iteration.
375-
break
429+
switch stopCondition.process(
430+
expressionResult: await expression(),
431+
wasLastPollingAttempt: iteration == (iterations - 1)
432+
) {
433+
case .continuePolling: break
434+
case let .succeeded(value):
435+
return .succeeded(value)
436+
case .failed:
437+
return .failed
376438
}
377439
do {
378440
try await Task.sleep(for: interval)
379441
} catch {
380442
// `Task.sleep` should only throw an error if it's cancelled
381443
// during the sleep period.
382-
return nil
444+
return .cancelled
383445
}
384446
}
385-
return lastResult
447+
return .failed
386448
}
387449
}
388450

0 commit comments

Comments
 (0)