Skip to content

Commit 151aae8

Browse files
committed
Add traits for configuring polling
1 parent 825739c commit 151aae8

File tree

4 files changed

+337
-60
lines changed

4 files changed

+337
-60
lines changed

Sources/Testing/Polling/Polling.swift

Lines changed: 105 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,29 @@
88
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
99
//
1010

11+
@available(macOS 13, iOS 17, watchOS 9, tvOS 17, visionOS 1, *)
12+
internal let defaultPollingConfiguration = (
13+
maxPollingIterations: 1000,
14+
pollingInterval: Duration.milliseconds(1)
15+
)
16+
1117
/// Confirm that some expression eventually returns true
1218
///
1319
/// - Parameters:
1420
/// - comment: An optional comment to apply to any issues generated by this
1521
/// function.
22+
/// - maxPollingIterations: The maximum amount of times to attempt polling.
23+
/// If nil, this uses whatever value is specified under the last
24+
/// ``ConfirmPassesEventuallyConfigurationTrait`` added to the test or suite.
25+
/// If no ``ConfirmPassesEventuallyConfigurationTrait`` has been added, then
26+
/// polling will be attempted 1000 times before recording an issue.
27+
/// `maxPollingIterations` must be greater than 0.
28+
/// - pollingInterval: The minimum amount of time to wait between polling attempts.
29+
/// If nil, this uses whatever value is specified under the last
30+
/// ``ConfirmPassesEventuallyConfigurationTrait`` added to the test or suite.
31+
/// If no ``ConfirmPassesEventuallyConfigurationTrait`` has been added, then
32+
/// polling will wait at least 1 millisecond between polling attempts.
33+
/// `pollingInterval` must be greater than 0.
1634
/// - isolation: The actor to which `body` is isolated, if any.
1735
/// - sourceLocation: The source location to whych any recorded issues should
1836
/// be attributed.
@@ -26,16 +44,24 @@
2644
@available(macOS 13, iOS 17, watchOS 9, tvOS 17, visionOS 1, *)
2745
public func confirmPassesEventually(
2846
_ comment: Comment? = nil,
29-
maxPollingIterations: Int = 1000,
30-
pollingInterval: Duration = .milliseconds(1),
47+
maxPollingIterations: Int? = nil,
48+
pollingInterval: Duration? = nil,
3149
isolation: isolated (any Actor)? = #isolation,
3250
sourceLocation: SourceLocation = #_sourceLocation,
3351
_ body: @escaping () async throws -> Bool
3452
) async {
3553
let poller = Poller(
3654
pollingBehavior: .passesOnce,
37-
pollingIterations: maxPollingIterations,
38-
pollingInterval: pollingInterval,
55+
pollingIterations: getValueFromPollingTrait(
56+
providedValue: maxPollingIterations,
57+
default: defaultPollingConfiguration.maxPollingIterations,
58+
\ConfirmPassesEventuallyConfigurationTrait.maxPollingIterations
59+
),
60+
pollingInterval: getValueFromPollingTrait(
61+
providedValue: pollingInterval,
62+
default: defaultPollingConfiguration.pollingInterval,
63+
\ConfirmPassesEventuallyConfigurationTrait.pollingInterval
64+
),
3965
comment: comment,
4066
sourceLocation: sourceLocation
4167
)
@@ -58,6 +84,18 @@ public struct PollingFailedError: Error {}
5884
/// - Parameters:
5985
/// - comment: An optional comment to apply to any issues generated by this
6086
/// function.
87+
/// - maxPollingIterations: The maximum amount of times to attempt polling.
88+
/// If nil, this uses whatever value is specified under the last
89+
/// ``ConfirmPassesEventuallyConfigurationTrait`` added to the test or suite.
90+
/// If no ``ConfirmPassesEventuallyConfigurationTrait`` has been added, then
91+
/// polling will be attempted 1000 times before recording an issue.
92+
/// `maxPollingIterations` must be greater than 0.
93+
/// - pollingInterval: The minimum amount of time to wait between polling attempts.
94+
/// If nil, this uses whatever value is specified under the last
95+
/// ``ConfirmPassesEventuallyConfigurationTrait`` added to the test or suite.
96+
/// If no ``ConfirmPassesEventuallyConfigurationTrait`` has been added, then
97+
/// polling will wait at least 1 millisecond between polling attempts.
98+
/// `pollingInterval` must be greater than 0.
6199
/// - isolation: The actor to which `body` is isolated, if any.
62100
/// - sourceLocation: The source location to whych any recorded issues should
63101
/// be attributed.
@@ -77,17 +115,25 @@ public struct PollingFailedError: Error {}
77115
@discardableResult
78116
public func confirmPassesEventually<R>(
79117
_ comment: Comment? = nil,
80-
maxPollingIterations: Int = 1000,
81-
pollingInterval: Duration = .milliseconds(1),
118+
maxPollingIterations: Int? = nil,
119+
pollingInterval: Duration? = nil,
82120
isolation: isolated (any Actor)? = #isolation,
83121
sourceLocation: SourceLocation = #_sourceLocation,
84122
_ body: @escaping () async throws -> R?
85123
) async throws -> R where R: Sendable {
86124
let recorder = PollingRecorder<R>()
87125
let poller = Poller(
88126
pollingBehavior: .passesOnce,
89-
pollingIterations: maxPollingIterations,
90-
pollingInterval: pollingInterval,
127+
pollingIterations: getValueFromPollingTrait(
128+
providedValue: maxPollingIterations,
129+
default: defaultPollingConfiguration.maxPollingIterations,
130+
\ConfirmPassesEventuallyConfigurationTrait.maxPollingIterations
131+
),
132+
pollingInterval: getValueFromPollingTrait(
133+
providedValue: pollingInterval,
134+
default: defaultPollingConfiguration.pollingInterval,
135+
\ConfirmPassesEventuallyConfigurationTrait.pollingInterval
136+
),
91137
comment: comment,
92138
sourceLocation: sourceLocation
93139
)
@@ -110,6 +156,18 @@ public func confirmPassesEventually<R>(
110156
/// - Parameters:
111157
/// - comment: An optional comment to apply to any issues generated by this
112158
/// function.
159+
/// - maxPollingIterations: The maximum amount of times to attempt polling.
160+
/// If nil, this uses whatever value is specified under the last
161+
/// ``ConfirmPassesAlwaysConfigurationTrait`` added to the test or suite.
162+
/// If no ``ConfirmPassesAlwaysConfigurationTrait`` has been added, then
163+
/// polling will be attempted 1000 times before recording an issue.
164+
/// `maxPollingIterations` must be greater than 0.
165+
/// - pollingInterval: The minimum amount of time to wait between polling attempts.
166+
/// If nil, this uses whatever value is specified under the last
167+
/// ``ConfirmPassesAlwaysConfigurationTrait`` added to the test or suite.
168+
/// If no ``ConfirmPassesAlwaysConfigurationTrait`` has been added, then
169+
/// polling will wait at least 1 millisecond between polling attempts.
170+
/// `pollingInterval` must be greater than 0.
113171
/// - isolation: The actor to which `body` is isolated, if any.
114172
/// - sourceLocation: The source location to whych any recorded issues should
115173
/// be attributed.
@@ -122,16 +180,24 @@ public func confirmPassesEventually<R>(
122180
@available(macOS 13, iOS 17, watchOS 9, tvOS 17, visionOS 1, *)
123181
public func confirmAlwaysPasses(
124182
_ comment: Comment? = nil,
125-
maxPollingIterations: Int = 1000,
126-
pollingInterval: Duration = .milliseconds(1),
183+
maxPollingIterations: Int? = nil,
184+
pollingInterval: Duration? = nil,
127185
isolation: isolated (any Actor)? = #isolation,
128186
sourceLocation: SourceLocation = #_sourceLocation,
129187
_ body: @escaping () async throws -> Bool
130188
) async {
131189
let poller = Poller(
132190
pollingBehavior: .passesAlways,
133-
pollingIterations: maxPollingIterations,
134-
pollingInterval: pollingInterval,
191+
pollingIterations: getValueFromPollingTrait(
192+
providedValue: maxPollingIterations,
193+
default: defaultPollingConfiguration.maxPollingIterations,
194+
\ConfirmPassesAlwaysConfigurationTrait.maxPollingIterations
195+
),
196+
pollingInterval: getValueFromPollingTrait(
197+
providedValue: pollingInterval,
198+
default: defaultPollingConfiguration.pollingInterval,
199+
\ConfirmPassesAlwaysConfigurationTrait.pollingInterval
200+
),
135201
comment: comment,
136202
sourceLocation: sourceLocation
137203
)
@@ -144,48 +210,34 @@ public func confirmAlwaysPasses(
144210
}
145211
}
146212

147-
/// Confirm that some expression always returns a non-optional value
213+
/// A helper function to de-duplicate the logic of grabbing configuration from
214+
/// either the passed-in value (if given), the hardcoded default, and the
215+
/// appropriate configuration trait.
148216
///
149-
/// - Parameters:
150-
/// - comment: An optional comment to apply to any issues generated by this
151-
/// function.
152-
/// - isolation: The actor to which `body` is isolated, if any.
153-
/// - sourceLocation: The source location to whych any recorded issues should
154-
/// be attributed.
155-
/// - body: The function to invoke.
217+
/// The provided value, if non-nil is returned. Otherwise, this looks for
218+
/// the last `TraitKind` specified, and if one exists, returns the value
219+
/// as determined by `keyPath`.
220+
/// If no configuration trait has been applied, then this returns the `default`.
156221
///
157-
/// - Returns: The value from the last time `body` was invoked.
158-
///
159-
/// - Throws: A `PollingFailedError` will be thrown if `body` ever returns a
160-
/// non-optional value
161-
///
162-
/// Use polling confirmations to check that an event while a test is running in
163-
/// complex scenarios where other forms of confirmation are insufficient. For
164-
/// example, confirming that some state does not change.
165-
@_spi(Experimental)
166-
@available(macOS 13, iOS 17, watchOS 9, tvOS 17, visionOS 1, *)
167-
public func confirmAlwaysPasses<R>(
168-
_ comment: Comment? = nil,
169-
maxPollingIterations: Int = 1000,
170-
pollingInterval: Duration = .milliseconds(1),
171-
isolation: isolated (any Actor)? = #isolation,
172-
sourceLocation: SourceLocation = #_sourceLocation,
173-
_ body: @escaping () async throws -> R?
174-
) async {
175-
let poller = Poller(
176-
pollingBehavior: .passesAlways,
177-
pollingIterations: maxPollingIterations,
178-
pollingInterval: pollingInterval,
179-
comment: comment,
180-
sourceLocation: sourceLocation
181-
)
182-
await poller.evaluate(isolation: isolation) {
183-
do {
184-
return try await body() != nil
185-
} catch {
186-
return false
187-
}
222+
/// - Parameters:
223+
/// - providedValue: The value provided by the test author when calling
224+
/// `confirmPassesEventually` or `confirmAlwaysPasses`.
225+
/// - default: The harded coded default value, as defined in
226+
/// `defaultPollingConfiguration`
227+
/// - keyPath: The keyPath mapping from `TraitKind` to the desired value type.
228+
private func getValueFromPollingTrait<TraitKind, Value>(
229+
providedValue: Value?,
230+
default: Value,
231+
_ keyPath: KeyPath<TraitKind, Value>
232+
) -> Value {
233+
if let providedValue { return providedValue }
234+
guard let test = Test.current else { return `default` }
235+
guard let trait = test.traits.compactMap({ $0 as? TraitKind }).last else {
236+
print("No traits of type \(TraitKind.self) found. Returning default.")
237+
print("Traits: \(test.traits)")
238+
return `default`
188239
}
240+
return trait[keyPath: keyPath]
189241
}
190242

191243
/// A type to record the last value returned by a closure returning an optional
@@ -321,6 +373,8 @@ private struct Poller {
321373
isolation: isolated (any Actor)?,
322374
_ body: @escaping () async -> Bool
323375
) async {
376+
precondition(pollingIterations > 0)
377+
precondition(pollingInterval > Duration.zero)
324378
let result = await poll(
325379
expression: body
326380
)
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
//
2+
// PollingConfiguration.swift
3+
// swift-testing
4+
//
5+
// Created by Rachel Brindle on 6/6/25.
6+
//
7+
8+
/// A trait to provide a default polling configuration to all usages of
9+
/// ``confirmPassesEventually`` within a test or suite.
10+
///
11+
/// To add this trait to a test, use the ``Trait/pollingConfirmationEventually``
12+
@_spi(Experimental)
13+
@available(macOS 13, iOS 17, watchOS 9, tvOS 17, visionOS 1, *)
14+
public struct ConfirmPassesEventuallyConfigurationTrait: TestTrait, SuiteTrait {
15+
public var maxPollingIterations: Int
16+
public var pollingInterval: Duration
17+
18+
public var isRecursive: Bool { true }
19+
20+
public init(maxPollingIterations: Int?, pollingInterval: Duration?) {
21+
self.maxPollingIterations = maxPollingIterations ?? defaultPollingConfiguration.maxPollingIterations
22+
self.pollingInterval = pollingInterval ?? defaultPollingConfiguration.pollingInterval
23+
}
24+
}
25+
26+
/// A trait to provide a default polling configuration to all usages of
27+
/// ``confirmPassesAlways`` within a test or suite.
28+
///
29+
/// To add this trait to a test, use the ``Trait/pollingConfirmationAlways``
30+
@_spi(Experimental)
31+
@available(macOS 13, iOS 17, watchOS 9, tvOS 17, visionOS 1, *)
32+
public struct ConfirmPassesAlwaysConfigurationTrait: TestTrait, SuiteTrait {
33+
public var maxPollingIterations: Int
34+
public var pollingInterval: Duration
35+
36+
public var isRecursive: Bool { true }
37+
38+
public init(maxPollingIterations: Int?, pollingInterval: Duration?) {
39+
self.maxPollingIterations = maxPollingIterations ?? defaultPollingConfiguration.maxPollingIterations
40+
self.pollingInterval = pollingInterval ?? defaultPollingConfiguration.pollingInterval
41+
}
42+
}
43+
44+
@_spi(Experimental)
45+
@available(macOS 13, iOS 17, watchOS 9, tvOS 17, visionOS 1, *)
46+
extension Trait where Self == ConfirmPassesEventuallyConfigurationTrait {
47+
/// Specifies defaults for ``confirmPassesEventually`` in the test or suite.
48+
///
49+
/// - Parameters:
50+
/// - maxPollingIterations: The maximum amount of times to attempt polling.
51+
/// If nil, polling will be attempted up to 1000 times.
52+
/// `maxPollingIterations` must be greater than 0.
53+
/// - pollingInterval: The minimum amount of time to wait between polling
54+
/// attempts.
55+
/// If nil, polling will wait at least 1 millisecond between polling attempts.
56+
/// `pollingInterval` must be greater than 0.
57+
public static func confirmPassesEventuallyDefaults(
58+
maxPollingIterations: Int? = nil,
59+
pollingInterval: Duration? = nil
60+
) -> Self {
61+
ConfirmPassesEventuallyConfigurationTrait(
62+
maxPollingIterations: maxPollingIterations,
63+
pollingInterval: pollingInterval
64+
)
65+
}
66+
}
67+
68+
@_spi(Experimental)
69+
@available(macOS 13, iOS 17, watchOS 9, tvOS 17, visionOS 1, *)
70+
extension Trait where Self == ConfirmPassesAlwaysConfigurationTrait {
71+
/// Specifies defaults for ``confirmPassesAlways`` in the test or suite.
72+
///
73+
/// - Parameters:
74+
/// - maxPollingIterations: The maximum amount of times to attempt polling.
75+
/// If nil, polling will be attempted up to 1000 times.
76+
/// `maxPollingIterations` must be greater than 0.
77+
/// - pollingInterval: The minimum amount of time to wait between polling
78+
/// attempts.
79+
/// If nil, polling will wait at least 1 millisecond between polling attempts.
80+
/// `pollingInterval` must be greater than 0.
81+
public static func confirmPassesAlwaysDefaults(
82+
maxPollingIterations: Int? = nil,
83+
pollingInterval: Duration? = nil
84+
) -> Self {
85+
ConfirmPassesAlwaysConfigurationTrait(
86+
maxPollingIterations: maxPollingIterations,
87+
pollingInterval: pollingInterval
88+
)
89+
}
90+
}

0 commit comments

Comments
 (0)