Skip to content

Commit c105aa1

Browse files
authored
Make Configuration.TestFilter a pure-data type (#358)
This modifies the structure of `Configuration.TestFilter` so that it's a pure data type and is easier to become serializable. ### Motivation: For easier integration with tools it would be helpful for the stored properties of `Configuration` to be serializable, or be trivially convertible to/from serializable types at least. `Configuration`'s nested `TestFilter` type currently includes a closure for filtering tests and is therefore cannot be serialized in its current form. This type doesn't really need to _store_ a closure, however; it only needs to store information about how to perform filtering (e.g. data about criteria to match against), and later that can be interpreted when applying filtering. ### Modifications: - Change the `Configuration.TestFilter.Kind` enum so that its cases only store data, and no closures. - Move the filtering logic derived from the pure data `Kind` type above to a new `Configuration.TestFilter.Operation` enum. ### Checklist: - [x] Code and documentation should follow the style of the [Style Guide](https://github.com/apple/swift-testing/blob/main/Documentation/StyleGuide.md). - [x] If public symbols are renamed or modified, DocC references should be updated. Resolves rdar://126627664
1 parent 0a1ba7b commit c105aa1

File tree

3 files changed

+134
-61
lines changed

3 files changed

+134
-61
lines changed

Sources/Testing/EntryPoints/EntryPoint.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,8 +298,7 @@ public func configurationForEntryPoint(from args: __CommandLineArguments_v0) thr
298298
throw _EntryPointError.featureUnavailable("The `\(label)' option is not supported on this OS version.")
299299
}
300300
return try regexes.lazy
301-
.map { try Regex($0) }
302-
.map { Configuration.TestFilter(membership: membership, matching: $0) }
301+
.map { try Configuration.TestFilter(membership: membership, matching: $0) }
303302
.reduce(into: .unfiltered) { $0.combine(with: $1, using: .or) }
304303
}
305304
filters.append(try testFilter(forRegularExpressions: args.filter, label: "--filter", membership: .including))

Sources/Testing/Running/Configuration.TestFilter.swift

Lines changed: 121 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,25 @@ extension Configuration {
4040
/// - Parameters:
4141
/// - testIDs: The set of test IDs to predicate tests against.
4242
/// - membership: How to interpret the result when predicating tests.
43-
case precomputed(_ testIDs: Test.ID.Selection, membership: Membership)
43+
case testIDs(_ testIDs: Set<Test.ID>, membership: Membership)
4444

45-
/// The test filter is an arbitrary predicate function.
45+
/// The test filter contains a set of tags to predicate tests against.
4646
///
4747
/// - Parameters:
48-
/// - predicate: The function to predicate tests against.
48+
/// - tags: The set of test tags to predicate tests against.
49+
/// - anyOf: Whether to require that tests have any (`true`) or all
50+
/// (`false`) of the specified tags.
4951
/// - membership: How to interpret the result when predicating tests.
50-
case function(_ predicate: @Sendable (borrowing Test) -> Bool, membership: Membership)
52+
case tags(_ tags: Set<Tag>, anyOf: Bool, membership: Membership)
5153

52-
/// The test filter is a combination of other test filters.
54+
/// The test filter contains a pattern to predicate test IDs against.
55+
///
56+
/// - Parameters:
57+
/// - pattern: The pattern to predicate test IDs against.
58+
/// - membership: How to interpret the result when predicating tests.
59+
case pattern(_ pattern: String, membership: Membership)
60+
61+
/// The test filter is a combination of other test filter kinds.
5362
///
5463
/// - Parameters:
5564
/// - lhs: The first test filter's kind.
@@ -59,7 +68,7 @@ extension Configuration {
5968
///
6069
/// The result of a test filter with this kind is the combination of the
6170
/// results of its subfilters using `op`.
62-
indirect case combined(_ lhs: Kind, _ rhs: Kind, _ op: CombinationOperator)
71+
indirect case combination(_ lhs: Self, _ rhs: Self, _ op: CombinationOperator)
6372
}
6473

6574
/// The kind of test filter.
@@ -100,8 +109,7 @@ extension Configuration.TestFilter {
100109
/// - Parameters:
101110
/// - testIDs: A set of test IDs to be filtered.
102111
public init(including testIDs: some Collection<Test.ID>) {
103-
let selection = Test.ID.Selection(testIDs: testIDs)
104-
self.init(_kind: .precomputed(selection, membership: .including))
112+
self.init(_kind: .testIDs(Set(testIDs), membership: .including))
105113
}
106114

107115
/// Initialize this instance to filter tests to those _not_ specified by a set
@@ -110,35 +118,29 @@ extension Configuration.TestFilter {
110118
/// - Parameters:
111119
/// - selection: A set of test IDs to be excluded.
112120
public init(excluding testIDs: some Collection<Test.ID>) {
113-
let selection = Test.ID.Selection(testIDs: testIDs)
114-
self.init(_kind: .precomputed(selection, membership: .excluding))
121+
self.init(_kind: .testIDs(Set(testIDs), membership: .excluding))
115122
}
116123

117-
/// Initialize this instance from an arbitrary function.
124+
/// Initialize this instance to represent a pattern expression matched against
125+
/// a test's ID.
118126
///
119127
/// - Parameters:
120128
/// - membership: How to interpret the result when predicating tests.
121-
/// - predicate: The function to predicate tests against.
122-
init(membership: Membership, matching predicate: @escaping @Sendable (borrowing Test) -> Bool) {
123-
self.init(_kind: .function(predicate, membership: membership))
124-
}
129+
/// - pattern: The pattern, expressed as a `Regex`-compatible regular
130+
/// expression, to match test IDs against.
131+
@available(_regexAPI, *)
132+
init(membership: Membership, matching pattern: String) throws {
133+
// Validate the regular expression by attempting to initialize a `Regex`
134+
// representing it, but do not preserve it. This type only represents
135+
// the pattern in the abstract, and is not responsible for actually
136+
// applying it to a test graph — that happens later during planning.
137+
//
138+
// Performing this validation here currently makes such errors easier to
139+
// surface when using the SwiftPM entry point. But longer-term, we should
140+
// make the planning phase throwing and propagate errors from there instead.
141+
_ = try Regex(pattern)
125142

126-
/// Initialize this instance to operate based on a set of tags.
127-
///
128-
/// - Parameters:
129-
/// - tags: The set of tags to either include or exclude.
130-
/// - anyOf: Whether tests must have _any_ of the tags in `tags` (as opposed
131-
/// to all of them.)
132-
/// - membership: How to interpret the result when predicating tests.
133-
init(tags: some Collection<Tag>, anyOf: Bool, membership: Membership) {
134-
let tags = Set(tags)
135-
self.init(membership: membership) { test in
136-
if anyOf {
137-
!test.tags.isDisjoint(with: tags) // .intersects()
138-
} else {
139-
test.tags.isSuperset(of: tags)
140-
}
141-
}
143+
self.init(_kind: .pattern(pattern, membership: membership))
142144
}
143145

144146
/// Initialize this instance to include tests with a given set of tags.
@@ -148,7 +150,7 @@ extension Configuration.TestFilter {
148150
///
149151
/// Matching tests have had _any_ of the tags in `tags` added to them.
150152
public init(includingAnyOf tags: some Collection<Tag>) {
151-
self.init(tags: tags, anyOf: true, membership: .including)
153+
self.init(_kind: .tags(Set(tags), anyOf: true, membership: .including))
152154
}
153155

154156
/// Initialize this instance to exclude tests with a given set of tags.
@@ -158,7 +160,7 @@ extension Configuration.TestFilter {
158160
///
159161
/// Matching tests have had _any_ of the tags in `tags` added to them.
160162
public init(excludingAnyOf tags: some Collection<Tag>) {
161-
self.init(tags: tags, anyOf: true, membership: .excluding)
163+
self.init(_kind: .tags(Set(tags), anyOf: true, membership: .excluding))
162164
}
163165

164166
/// Initialize this instance to include tests with a given set of tags.
@@ -168,7 +170,7 @@ extension Configuration.TestFilter {
168170
///
169171
/// Matching tests have had _all_ of the tags in `tags` added to them.
170172
public init(includingAllOf tags: some Collection<Tag>) {
171-
self.init(tags: tags, anyOf: false, membership: .including)
173+
self.init(_kind: .tags(Set(tags), anyOf: false, membership: .including))
172174
}
173175

174176
/// Initialize this instance to exclude tests with a given set of tags.
@@ -178,32 +180,92 @@ extension Configuration.TestFilter {
178180
///
179181
/// Matching tests have had _all_ of the tags in `tags` added to them.
180182
public init(excludingAllOf tags: some Collection<Tag>) {
181-
self.init(tags: tags, anyOf: false, membership: .excluding)
183+
self.init(_kind: .tags(Set(tags), anyOf: false, membership: .excluding))
182184
}
185+
}
183186

184-
/// Initialize this instance to represent a regular expression matched against
185-
/// a test's ID.
186-
///
187-
/// - Parameters:
188-
/// - membership: How to interpret the result when predicating tests.
189-
/// - regex: The regular expression to predicate test IDs against.
187+
// MARK: - Operations
188+
189+
extension Configuration.TestFilter {
190+
/// An enumeration which represents filtering logic to be applied to a test
191+
/// graph.
192+
fileprivate enum Operation: Sendable {
193+
/// A filter operation which has no effect.
194+
///
195+
/// All tests are allowed when this operation is applied.
196+
case unfiltered
197+
198+
/// A filter operation which accepts tests included in a precomputed
199+
/// selection of test IDs.
200+
///
201+
/// - Parameters:
202+
/// - testIDs: The set of test IDs to predicate tests against.
203+
/// - membership: How to interpret the result when predicating tests.
204+
case precomputed(_ testIDs: Test.ID.Selection, membership: Membership)
205+
206+
/// A filter operation which accepts tests which satisfy an arbitrary
207+
/// predicate function.
208+
///
209+
/// - Parameters:
210+
/// - predicate: The function to predicate tests against.
211+
/// - membership: How to interpret the result when predicating tests.
212+
case function(_ predicate: @Sendable (borrowing Test) -> Bool, membership: Membership)
213+
214+
/// A filter operation which is a combination of other operations.
215+
///
216+
/// - Parameters:
217+
/// - lhs: The first test filter operation.
218+
/// - rhs: The second test filter operation.
219+
/// - op: The operator to apply when combining the results of the two
220+
/// filter operations.
221+
///
222+
/// The result of applying this filter operation is the combination of
223+
/// applying the results of its sub-operations using `op`.
224+
indirect case combination(_ lhs: Self, _ rhs: Self, _ op: CombinationOperator)
225+
}
226+
}
227+
228+
extension Configuration.TestFilter.Kind {
229+
/// An operation which implements the filtering logic for this test filter
230+
/// kind.
190231
///
191-
/// The caller is responsible for ensuring that `regex` is safe to send across
192-
/// isolation boundaries. Regular expressions parsed from strings are
193-
/// generally sendable.
194-
@available(_regexAPI, *)
195-
init(membership: Membership, matching regex: Regex<AnyRegexOutput>) {
196-
let regex = UncheckedSendable(rawValue: regex)
197-
self.init(membership: membership) { test in
198-
let id = String(describing: test.id)
199-
return id.contains(regex.rawValue)
232+
/// - Throws: Any error encountered while generating an operation for this
233+
/// test filter kind. One example is the creation of a `Regex` from a
234+
/// `.pattern` kind: if the pattern is not a valid regular expression, an
235+
/// error will be thrown.
236+
var operation: Configuration.TestFilter.Operation {
237+
get throws {
238+
switch self {
239+
case .unfiltered:
240+
return .unfiltered
241+
case let .testIDs(testIDs, membership):
242+
return .precomputed(Test.ID.Selection(testIDs: testIDs), membership: membership)
243+
case let .tags(tags, anyOf, membership):
244+
return .function({ test in
245+
if anyOf {
246+
!test.tags.isDisjoint(with: tags) // .intersects()
247+
} else {
248+
test.tags.isSuperset(of: tags)
249+
}
250+
}, membership: membership)
251+
case let .pattern(pattern, membership):
252+
guard #available(_regexAPI, *) else {
253+
throw SystemError(description: "Filtering by regular expression matching is unavailable")
254+
}
255+
256+
let regex = UncheckedSendable(rawValue: try Regex(pattern))
257+
return .function({ test in
258+
let id = String(describing: test.id)
259+
return id.contains(regex.rawValue)
260+
}, membership: membership)
261+
case let .combination(lhs, rhs, op):
262+
return try .combination(lhs.operation, rhs.operation, op)
263+
}
200264
}
201265
}
202266
}
203267

204-
// MARK: - Operations
205-
206-
extension Configuration.TestFilter.Kind {
268+
extension Configuration.TestFilter.Operation {
207269
/// Apply this test filter to a test graph and remove tests that should not be
208270
/// included.
209271
///
@@ -246,7 +308,7 @@ extension Configuration.TestFilter.Kind {
246308
.map(\.id)
247309
let selection = Test.ID.Selection(testIDs: testIDs)
248310
return Self.precomputed(selection, membership: membership).apply(to: testGraph)
249-
case let .combined(lhs, rhs, op):
311+
case let .combination(lhs, rhs, op):
250312
return zip(
251313
lhs.apply(to: testGraph),
252314
rhs.apply(to: testGraph)
@@ -265,13 +327,13 @@ extension Configuration.TestFilter {
265327
/// - testGraph: The test graph to filter.
266328
///
267329
/// - Returns: A copy of `testGraph` with filtered tests replaced with `nil`.
268-
func apply(to testGraph: Graph<String, Test?>) -> Graph<String, Test?> {
269-
var result = _kind.apply(to: testGraph)
330+
func apply(to testGraph: Graph<String, Test?>) throws -> Graph<String, Test?> {
331+
var result = try _kind.operation.apply(to: testGraph)
270332

271333
// After performing the test function, run through one more time and remove
272334
// hidden tests. (Note that this property's value is not recursively set on
273335
// combined test filters. It is only consulted on the outermost call to
274-
// apply(to:), not in _apply(to:).
336+
// apply(to:), not in _apply(to:).)
275337
if !includeHiddenTests {
276338
result = result.mapValues { _, test in
277339
(test?.isHidden == true) ? nil : test
@@ -318,6 +380,7 @@ extension Configuration.TestFilter {
318380
}
319381
}
320382
}
383+
321384
/// Combine this test filter with another one.
322385
///
323386
/// - Parameters:
@@ -336,7 +399,7 @@ extension Configuration.TestFilter {
336399
case (_, .unfiltered):
337400
self
338401
default:
339-
Self(_kind: .combined(_kind, other._kind, op))
402+
Self(_kind: .combination(_kind, other._kind, op))
340403
}
341404
result.includeHiddenTests = includeHiddenTests
342405

Sources/Testing/Running/Runner.Plan.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,18 @@ extension Runner.Plan {
190190
// configuration. The action graph is not modified here: actions that lose
191191
// their corresponding tests are effectively filtered out by the call to
192192
// zip() near the end of the function.
193-
testGraph = configuration.testFilter.apply(to: testGraph)
193+
do {
194+
testGraph = try configuration.testFilter.apply(to: testGraph)
195+
} catch {
196+
// FIXME: Handle this more gracefully, either by propagating the error
197+
// (which will ultimately require `Runner.init(...)` to be throwing:
198+
// rdar://126631222) or by recording a single `Issue` representing the
199+
// planning failure.
200+
//
201+
// For now, ignore the error and include all tests. As of this writing,
202+
// the only scenario where this will throw is when using regex filtering,
203+
// and that is already guarded earlier in the SwiftPM entry point.
204+
}
194205

195206
// For each test value, determine the appropriate action for it.
196207
//

0 commit comments

Comments
 (0)