Skip to content

Conversation

@younata
Copy link

@younata younata commented May 15, 2025

This implements polling confirmations, as described in ST-NNNN.

Motivation:

Being able to monitor changes in the background is something of immense value. Swift Testing already provides an API for this in the confirmation api. However, I've found the confirmation to be hard to work with at times - it requires you to set up a callback for when something changes, which is not always possible or even the right thing to do. Polling provides a very general approach to monitoring all kinds of changes.

Modifications:

This adds a new set of macros for handling polling. A new public enum for the 2 separate polling behaviors, and new types to actually implement polling. All under the experimental spi.

Checklist:

  • Code and documentation should follow the style of the Style Guide.
  • If public symbols are renamed or modified, DocC references should be updated.

@grynspan grynspan added enhancement New feature or request public-api Affects public API api-proposal API proposal PRs (documentation only) issue-handling Related to Issue handling within the testing library macros 🔭 Related to Swift macros such as @Test or #expect labels May 18, 2025
@younata younata requested a review from jerryjrchen as a code owner August 19, 2025 21:22
@younata younata changed the title Polling expectations (under Experimental spi) Polling confirmations (under Experimental spi) Aug 19, 2025
@younata younata force-pushed the polling-expectations branch from 74262d2 to 3469f50 Compare September 8, 2025 22:10
@younata younata force-pushed the polling-expectations branch from 3469f50 to d2f979a Compare September 22, 2025 03:56
Copy link
Contributor

@plemarquand plemarquand left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall looking really great. Just one comment and a bunch of documentation nits.

Preliminary implementation of polling expectations

Make Polling into a function along the lines of Confirmation
Additionally, make PollingBehavior an implementation detail of polling, instead of exposed publicly
Removes any timeouts involved for polling, as they become increasingly unreliable
as the system runs more and more tests

Take in configuration arguments, add polling interval

Add traits for configuring polling

Use consistent naming between confirmAlwaysPasses and the related configuration trait
Stop unnecessarily waiting after the last polling attempt has finished.
Allow for subsequent polling configuration traits which specified nil for a value to fall back to earlier polling configuration traits before falling back to the default.

Add requirePassesEventually and requireAlwaysPasses
These two mirror their confirm counterparts, only throwing an error (instead of recording an issue) when they fail.

Rewrite confirmPassesEventually when returning an optional to remove the PollingRecorder actor.
Now, this uses a separate method for evaluating polling to remove that actor.

Clean up the duplicate Poller.evaluate/Poller.poll methods
Removed the duplicate poll method, and made evaluate-returning-bool into a wrapper for evaluate-returning-optional

Configure polling confirmations as timeout & polling interval
This is less direct, but much more intuitive for test authors.
Also add exit tests confirming that these values are non-negative

Rename to actually use the confirmation name
Follow more english-sentence-like guidance for function naming
Simplify the polling confirmation API down to just 2 public functions, 1 enum, and 1 error type.
Always throw an error when polling fails, get rid of the separate issue recording.

Use a single polling confirmation configuration trait
Instead of mulitple traits per stop condition, just have a single trait
per stop condition.
Mark defaultPollingConfiguration as private
Add polling files to CMakeLists
Move Polling and Confirmations.swift to a new Confirmations directory
Rename PollingFailureReason to PallingFailedError.Reason
@younata younata force-pushed the polling-expectations branch from 3ff21e5 to 454cca8 Compare October 20, 2025 19:30
@jerryjrchen
Copy link
Contributor

The link in the description links to the old path for the proposal doc. I think it should be updated to point to https://github.com/younata/swift-evolution/blob/younata/testing-polling-expectations/proposals/testing/NNNN-polling-confirmations.md instead

(I think I have edit permissions to make this description change but wasn't sure if that would be polite to do 😅 )

Copy link
Contributor

@jerryjrchen jerryjrchen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this looks great to me, and I mostly had a bunch of nitpicks about the docs. I'll caveat by saying I've only read through the proposal recently and skimmed the pitch forum post, so I hope I didn't rehash a bunch of previous questions :)

/// with a matching stopCondition.
/// If no such trait has been added, then polling will be attempted for
/// about 1 second before recording an issue.
/// `duration` must be greater than 0.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might also be worth mentioning that duration > interval based on the current implementation.

Although from a user perspective, should duration == interval be permissible? I would interpret that basically a single poll (so perhaps not very useful), but still a possible use case.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for bringing this up!

I'll update the documentation & add some tests mentioning this.

I don't see any real harm in disallowing the case where duration == interval. It is not very useful - at least not at the moment - but to your point, it is a possible use case someone might have.

///
/// - Parameters:
/// - comment: A user-specified comment describing this confirmation.
/// - stopCondition: When to stop polling.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As obvious as it might sound, I think it would help to explicitly note that nil maps to the false stop condition. That could either go here or maybe in the docs for PollingStopCondition. Based on my reading of the docs for firstPass, I assumed it would only be used with Bool and not with optional return types as well:

  /// Evaluates 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 firstPass

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. I'm going to update the docs for PollingStopCondition to be about pass/fail, and then have the documentation for both forms of confirmation specifically mention what is considered a pass/fail.

I'm also adding a test that explicitly documents the case where you return false when you pass in a closure returning Optional<Bool>.

// large. In which case, we should fall back to Int.max.

let failureReason: PollingFailedError.Reason
switch await poll(iterations: iterations, expression: body) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the isolation parameter for this method also get forwarded to poll?

I'm not too familiar with actor isolated parameters so it's totally possible I'm missing something here.

@_spi(Experimental)
public enum PollingStopCondition: Sendable, Equatable, Codable {
/// Evaluates the expression until the first time it returns true.
/// If it does not pass once by the time the timeout is reached, then a
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: would it make sense to say duration instead of timeout here? I don't see "timeout" appearing elsewhere in the polling confirmation API or documentation.

if let result {
return .succeeded(result)
} else {
return .continuePolling
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realised too late my note below is overly pedantic and I actually think this function is fairly clear as-is. I kinda already typed it out and figured I might as well share it though :)

I'd find it a bit clearer to check for the "timeout" scenario here compared to the call site in poll:

Suggested change
return .continuePolling
return wasLastPollingAttempt ? .failed : .continuePolling

Or alternatively an early check on the last polling attempt which would simplify the switch:

    guard !wasLastPollingAttempt else {
      return result.map { .succeeded($0) } ?? .failed
    }

    switch self {
    // ...

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I appreciate the feedback, and while pedantic, I do agree that makes it easier to read.

I think this is a good suggestion for clarity purposes. It does make the last line in poll redundant (return .failed after the for loop exits), but necessary to satisfy the compiler. I'll add a comment to the end of that method explaining this.

/// - Parameters:
/// - comment: A user-specified comment describing this confirmation.
/// - stopCondition: When to stop polling.
/// - duration: The expected length of time to continue polling for.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it also be worth mentioning that time to run the body isn't accounted for in duration? I can see someone unfamiliar with the full details of polling confirmations writing something like:

      try await confirmation(until: .firstPass, within: .seconds(10)) {
        tenSecondLongOperation()
      }

And then being surprised it runs much longer than 10 seconds total.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the awesome comprehensive tests!

await incrementor.increment() != 0
}
let count = await incrementor.count
#expect(await count == 10)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this await is unnecessary:

Suggested change
#expect(await count == 10)
#expect(count == 10)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to instead inline the await incrementor.count back into this and in the other places.

The reason this was separated out was to make debugging the test failure easier - the failure message when incrementor.count wasn't equal to 10 did not mention what the then-current value of incrementor was. But that's not necessary any longer.

#expect(await incrementor.count == 50)
}

#if !SWT_NO_EXIT_TESTS
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice use of exit tests!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api-proposal API proposal PRs (documentation only) enhancement New feature or request issue-handling Related to Issue handling within the testing library macros 🔭 Related to Swift macros such as @Test or #expect public-api Affects public API

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants