Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make Configuration.TestFilter a pure-data type #358

Merged
merged 3 commits into from
May 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 1 addition & 2 deletions Sources/Testing/EntryPoints/EntryPoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -337,8 +337,7 @@ public func configurationForSwiftPMEntryPoint(from args: __CommandLineArguments_
throw _EntryPointError.featureUnavailable("The `\(label)' option is not supported on this OS version.")
}
return try regexes.lazy
.map { try Regex($0) }
.map { Configuration.TestFilter(membership: membership, matching: $0) }
.map { try Configuration.TestFilter(membership: membership, matching: $0) }
.reduce(into: .unfiltered) { $0.combine(with: $1, using: .or) }
}
filters.append(try testFilter(forRegularExpressions: args.filter, label: "--filter", membership: .including))
Expand Down
179 changes: 121 additions & 58 deletions Sources/Testing/Running/Configuration.TestFilter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,25 @@ extension Configuration {
/// - Parameters:
/// - testIDs: The set of test IDs to predicate tests against.
/// - membership: How to interpret the result when predicating tests.
case precomputed(_ testIDs: Test.ID.Selection, membership: Membership)
case testIDs(_ testIDs: Set<Test.ID>, membership: Membership)
stmontgomery marked this conversation as resolved.
Show resolved Hide resolved

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

/// The test filter is a combination of other test filters.
/// The test filter contains a pattern to predicate test IDs against.
///
/// - Parameters:
/// - pattern: The pattern to predicate test IDs against.
/// - membership: How to interpret the result when predicating tests.
case pattern(_ pattern: String, membership: Membership)

/// The test filter is a combination of other test filter kinds.
///
/// - Parameters:
/// - lhs: The first test filter's kind.
Expand All @@ -59,7 +68,7 @@ extension Configuration {
///
/// The result of a test filter with this kind is the combination of the
/// results of its subfilters using `op`.
indirect case combined(_ lhs: Kind, _ rhs: Kind, _ op: CombinationOperator)
indirect case combination(_ lhs: Self, _ rhs: Self, _ op: CombinationOperator)
}

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

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

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

/// Initialize this instance to operate based on a set of tags.
///
/// - Parameters:
/// - tags: The set of tags to either include or exclude.
/// - anyOf: Whether tests must have _any_ of the tags in `tags` (as opposed
/// to all of them.)
/// - membership: How to interpret the result when predicating tests.
init(tags: some Collection<Tag>, anyOf: Bool, membership: Membership) {
let tags = Set(tags)
self.init(membership: membership) { test in
if anyOf {
!test.tags.isDisjoint(with: tags) // .intersects()
} else {
test.tags.isSuperset(of: tags)
}
}
self.init(_kind: .pattern(pattern, membership: membership))
}

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

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

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

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

/// Initialize this instance to represent a regular expression matched against
/// a test's ID.
///
/// - Parameters:
/// - membership: How to interpret the result when predicating tests.
/// - regex: The regular expression to predicate test IDs against.
// MARK: - Operations

extension Configuration.TestFilter {
/// An enumeration which represents filtering logic to be applied to a test
/// graph.
fileprivate enum Operation: Sendable {
/// A filter operation which has no effect.
///
/// All tests are allowed when this operation is applied.
case unfiltered

/// A filter operation which accepts tests included in a precomputed
/// selection of test IDs.
///
/// - Parameters:
/// - testIDs: The set of test IDs to predicate tests against.
/// - membership: How to interpret the result when predicating tests.
case precomputed(_ testIDs: Test.ID.Selection, membership: Membership)

/// A filter operation which accepts tests which satisfy an arbitrary
/// predicate function.
///
/// - Parameters:
/// - predicate: The function to predicate tests against.
/// - membership: How to interpret the result when predicating tests.
case function(_ predicate: @Sendable (borrowing Test) -> Bool, membership: Membership)

/// A filter operation which is a combination of other operations.
///
/// - Parameters:
/// - lhs: The first test filter operation.
/// - rhs: The second test filter operation.
/// - op: The operator to apply when combining the results of the two
/// filter operations.
///
/// The result of applying this filter operation is the combination of
/// applying the results of its sub-operations using `op`.
indirect case combination(_ lhs: Self, _ rhs: Self, _ op: CombinationOperator)
}
}

extension Configuration.TestFilter.Kind {
/// An operation which implements the filtering logic for this test filter
/// kind.
///
/// The caller is responsible for ensuring that `regex` is safe to send across
/// isolation boundaries. Regular expressions parsed from strings are
/// generally sendable.
@available(_regexAPI, *)
init(membership: Membership, matching regex: Regex<AnyRegexOutput>) {
let regex = UncheckedSendable(rawValue: regex)
self.init(membership: membership) { test in
let id = String(describing: test.id)
return id.contains(regex.rawValue)
/// - Throws: Any error encountered while generating an operation for this
/// test filter kind. One example is the creation of a `Regex` from a
/// `.pattern` kind: if the pattern is not a valid regular expression, an
/// error will be thrown.
var operation: Configuration.TestFilter.Operation {
get throws {
switch self {
case .unfiltered:
return .unfiltered
case let .testIDs(testIDs, membership):
return .precomputed(Test.ID.Selection(testIDs: testIDs), membership: membership)
case let .tags(tags, anyOf, membership):
return .function({ test in
if anyOf {
!test.tags.isDisjoint(with: tags) // .intersects()
} else {
test.tags.isSuperset(of: tags)
}
}, membership: membership)
case let .pattern(pattern, membership):
guard #available(_regexAPI, *) else {
throw SystemError(description: "Filtering by regular expression matching is unavailable")
}

let regex = UncheckedSendable(rawValue: try Regex(pattern))
return .function({ test in
let id = String(describing: test.id)
return id.contains(regex.rawValue)
}, membership: membership)
case let .combination(lhs, rhs, op):
return try .combination(lhs.operation, rhs.operation, op)
}
}
}
}

// MARK: - Operations

extension Configuration.TestFilter.Kind {
extension Configuration.TestFilter.Operation {
/// Apply this test filter to a test graph and remove tests that should not be
/// included.
///
Expand Down Expand Up @@ -246,7 +308,7 @@ extension Configuration.TestFilter.Kind {
.map(\.id)
let selection = Test.ID.Selection(testIDs: testIDs)
return Self.precomputed(selection, membership: membership).apply(to: testGraph)
case let .combined(lhs, rhs, op):
case let .combination(lhs, rhs, op):
return zip(
lhs.apply(to: testGraph),
rhs.apply(to: testGraph)
Expand All @@ -265,13 +327,13 @@ extension Configuration.TestFilter {
/// - testGraph: The test graph to filter.
///
/// - Returns: A copy of `testGraph` with filtered tests replaced with `nil`.
func apply(to testGraph: Graph<String, Test?>) -> Graph<String, Test?> {
var result = _kind.apply(to: testGraph)
func apply(to testGraph: Graph<String, Test?>) throws -> Graph<String, Test?> {
var result = try _kind.operation.apply(to: testGraph)

// After performing the test function, run through one more time and remove
// hidden tests. (Note that this property's value is not recursively set on
// combined test filters. It is only consulted on the outermost call to
// apply(to:), not in _apply(to:).
// apply(to:), not in _apply(to:).)
if !includeHiddenTests {
result = result.mapValues { _, test in
(test?.isHidden == true) ? nil : test
Expand Down Expand Up @@ -318,6 +380,7 @@ extension Configuration.TestFilter {
}
}
}

/// Combine this test filter with another one.
///
/// - Parameters:
Expand All @@ -336,7 +399,7 @@ extension Configuration.TestFilter {
case (_, .unfiltered):
self
default:
Self(_kind: .combined(_kind, other._kind, op))
Self(_kind: .combination(_kind, other._kind, op))
}
result.includeHiddenTests = includeHiddenTests

Expand Down
13 changes: 12 additions & 1 deletion Sources/Testing/Running/Runner.Plan.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,18 @@ extension Runner.Plan {
// configuration. The action graph is not modified here: actions that lose
// their corresponding tests are effectively filtered out by the call to
// zip() near the end of the function.
testGraph = configuration.testFilter.apply(to: testGraph)
do {
testGraph = try configuration.testFilter.apply(to: testGraph)
} catch {
// FIXME: Handle this more gracefully, either by propagating the error
stmontgomery marked this conversation as resolved.
Show resolved Hide resolved
// (which will ultimately require `Runner.init(...)` to be throwing:
// rdar://126631222) or by recording a single `Issue` representing the
// planning failure.
//
// For now, ignore the error and include all tests. As of this writing,
// the only scenario where this will throw is when using regex filtering,
// and that is already guarded earlier in the SwiftPM entry point.
}

// For each test value, determine the appropriate action for it.
//
Expand Down