Skip to content

Commit 9f26837

Browse files
committed
[WIP] Add an experimental .serialized(.globally) trait.
Known issue: suites don't report they've ended until after all tests have run.
1 parent 26ac6a3 commit 9f26837

File tree

5 files changed

+158
-31
lines changed

5 files changed

+158
-31
lines changed

Diff for: Sources/Testing/Running/Runner.Plan.swift

+81-3
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,24 @@ extension Runner {
7373
}
7474
}
7575

76+
/// Stages of a test run.
77+
///
78+
/// This enumeration conforms to `CaseIterable`, so callers can iterate over
79+
/// all stages by looping over `Stage.allCases`. `Stage.allCases.first` and
80+
/// `Stage.allCases.last` are respectively the first and last stages to run.
81+
enum Stage: Sendable, CaseIterable {
82+
/// Tests that might run in parallel (globally or locally) are being run.
83+
case parallelizationAllowed
84+
85+
/// Tests that are globally serialized are being run.
86+
case globallySerialized
87+
88+
/// The default stage to run tests in.
89+
static var `default`: Self {
90+
.parallelizationAllowed
91+
}
92+
}
93+
7694
/// A type describing a step in a runner plan.
7795
///
7896
/// An instance of this type contains a test and the corresponding action an
@@ -83,6 +101,49 @@ extension Runner {
83101

84102
/// The action to perform with ``test``.
85103
public var action: Action
104+
105+
/// The stage at which this step should be performed.
106+
var stage: Stage = .default
107+
108+
/// Whether or not this step may perform work over multiple stages of a
109+
/// test run.
110+
var isMultistaged: Bool {
111+
test.isSuite
112+
}
113+
114+
/// Whether or not this step performs its first work in the given test
115+
/// run stage.
116+
///
117+
/// - Parameters:
118+
/// - stage: The stage of interest.
119+
///
120+
/// - Returns: Whether or not `stage` is the first stage in which this
121+
/// step performs some work.
122+
func starts(in stage: Stage) -> Bool {
123+
let firstStage = if isMultistaged {
124+
Stage.allCases.first
125+
} else {
126+
self.stage
127+
}
128+
return stage == firstStage
129+
}
130+
131+
/// Whether or not this step performs its final work in the given test
132+
/// run stage.
133+
///
134+
/// - Parameters:
135+
/// - stage: The stage of interest.
136+
///
137+
/// - Returns: Whether or not `stage` is the last stage in which this
138+
/// step performs some work.
139+
func ends(in stage: Stage) -> Bool {
140+
let lastStage = if isMultistaged {
141+
Stage.allCases.last
142+
} else {
143+
self.stage
144+
}
145+
return stage == lastStage
146+
}
86147
}
87148

88149
/// The graph of the steps in the runner plan.
@@ -318,9 +379,26 @@ extension Runner.Plan {
318379
(action, recursivelyApply: action.isRecursive)
319380
}
320381

321-
// Zip the tests and actions together and return them.
322-
return zip(testGraph, actionGraph).mapValues { _, pair in
323-
pair.0.map { Step(test: $0, action: pair.1) }
382+
// Figure out what stage each test should operate in.
383+
let stageGraph: Graph<String, Runner.Plan.Stage> = testGraph.mapValues { _, test in
384+
switch test?.isGloballySerialized {
385+
case nil:
386+
.default
387+
case .some(false):
388+
.parallelizationAllowed
389+
case .some(true):
390+
.globallySerialized
391+
}
392+
}
393+
394+
// Zip the tests, actions, and stages together and return them.
395+
return zip(zip(testGraph, actionGraph), stageGraph).mapValues { _, tuple in
396+
let test = tuple.0.0
397+
let action = tuple.0.1
398+
let stage = tuple.1
399+
return test.map { test in
400+
Step(test: test, action: action, stage: stage)
401+
}
324402
}
325403
}
326404

Diff for: Sources/Testing/Running/Runner.swift

+22-25
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,13 @@ extension Runner {
113113
///
114114
/// - Parameters:
115115
/// - sequence: The sequence to enumerate.
116+
/// - stage: The stage at which the runner is operating.
116117
/// - body: The function to invoke.
117118
///
118119
/// - Throws: Whatever is thrown by `body`.
119120
private static func _forEach<E>(
120121
in sequence: some Sequence<E>,
122+
stage: Plan.Stage,
121123
_ body: @Sendable @escaping (E) async throws -> Void
122124
) async throws where E: Sendable {
123125
try await withThrowingTaskGroup(of: Void.self) { taskGroup in
@@ -128,7 +130,7 @@ extension Runner {
128130
}
129131

130132
// If not parallelizing, wait after each task.
131-
if !_configuration.isParallelizationEnabled {
133+
if stage == .globallySerialized || !_configuration.isParallelizationEnabled {
132134
try await taskGroup.waitForAll()
133135
}
134136
}
@@ -139,6 +141,7 @@ extension Runner {
139141
///
140142
/// - Parameters:
141143
/// - stepGraph: The subgraph whose root value, a step, is to be run.
144+
/// - stage: The stage at which the runner is operating.
142145
///
143146
/// - Throws: Whatever is thrown from the test body. Thrown errors are
144147
/// normally reported as test failures.
@@ -153,63 +156,53 @@ extension Runner {
153156
/// ## See Also
154157
///
155158
/// - ``Runner/run()``
156-
private static func _runStep(atRootOf stepGraph: Graph<String, Plan.Step?>) async throws {
159+
private static func _runStep(atRootOf stepGraph: Graph<String, Plan.Step?>, stage: Plan.Stage) async throws {
157160
// Exit early if the task has already been cancelled.
158161
try Task.checkCancellation()
159162

160-
// Whether to send a `.testEnded` event at the end of running this step.
161-
// Some steps' actions may not require a final event to be sent — for
162-
// example, a skip event only sends `.testSkipped`.
163-
let shouldSendTestEnded: Bool
164-
165163
let configuration = _configuration
166164

167165
// Determine what action to take for this step.
168-
if let step = stepGraph.value {
166+
if let step = stepGraph.value, step.starts(in: stage) {
169167
Event.post(.planStepStarted(step), for: (step.test, nil), configuration: configuration)
170168

171169
// Determine what kind of event to send for this step based on its action.
172170
switch step.action {
173171
case .run:
174172
Event.post(.testStarted, for: (step.test, nil), configuration: configuration)
175-
shouldSendTestEnded = true
176173
case let .skip(skipInfo):
177174
Event.post(.testSkipped(skipInfo), for: (step.test, nil), configuration: configuration)
178-
shouldSendTestEnded = false
179175
case let .recordIssue(issue):
180176
Event.post(.issueRecorded(issue), for: (step.test, nil), configuration: configuration)
181-
shouldSendTestEnded = false
182177
}
183-
} else {
184-
shouldSendTestEnded = false
185178
}
186179
defer {
187-
if let step = stepGraph.value {
188-
if shouldSendTestEnded {
180+
if let step = stepGraph.value, step.ends(in: stage) {
181+
if case .run = step.action {
189182
Event.post(.testEnded, for: (step.test, nil), configuration: configuration)
190183
}
191184
Event.post(.planStepEnded(step), for: (step.test, nil), configuration: configuration)
192185
}
193186
}
194187

195-
if let step = stepGraph.value, case .run = step.action {
188+
if let step = stepGraph.value, case .run = step.action, step.stage == stage {
196189
await Test.withCurrent(step.test) {
197190
_ = await Issue.withErrorRecording(at: step.test.sourceLocation, configuration: configuration) {
198191
try await _applyScopingTraits(for: step.test, testCase: nil) {
199192
// Run the test function at this step (if one is present.)
200193
if let testCases = step.test.testCases {
201-
try await _runTestCases(testCases, within: step)
194+
try await _runTestCases(testCases, within: step, stage: stage)
202195
}
203196

204197
// Run the children of this test (i.e. the tests in this suite.)
205-
try await _runChildren(of: stepGraph)
198+
try await _runChildren(of: stepGraph, stage: stage)
206199
}
207200
}
208201
}
209202
} else {
210203
// There is no test at this node in the graph, so just skip down to the
211204
// child nodes.
212-
try await _runChildren(of: stepGraph)
205+
try await _runChildren(of: stepGraph, stage: stage)
213206
}
214207
}
215208

@@ -234,10 +227,11 @@ extension Runner {
234227
/// - Parameters:
235228
/// - stepGraph: The subgraph whose root value, a step, will be used to
236229
/// find children to run.
230+
/// - stage: The stage at which the runner is operating.
237231
///
238232
/// - Throws: Whatever is thrown from the test body. Thrown errors are
239233
/// normally reported as test failures.
240-
private static func _runChildren(of stepGraph: Graph<String, Plan.Step?>) async throws {
234+
private static func _runChildren(of stepGraph: Graph<String, Plan.Step?>, stage: Plan.Stage) async throws {
241235
let childGraphs = if _configuration.isParallelizationEnabled {
242236
// Explicitly shuffle the steps to help detect accidental dependencies
243237
// between tests due to their ordering.
@@ -267,8 +261,8 @@ extension Runner {
267261
}
268262

269263
// Run the child nodes.
270-
try await _forEach(in: childGraphs) { _, childGraph in
271-
try await _runStep(atRootOf: childGraph)
264+
try await _forEach(in: childGraphs, stage: stage) { _, childGraph in
265+
try await _runStep(atRootOf: childGraph, stage: stage)
272266
}
273267
}
274268

@@ -277,20 +271,21 @@ extension Runner {
277271
/// - Parameters:
278272
/// - testCases: The test cases to be run.
279273
/// - step: The runner plan step associated with this test case.
274+
/// - stage: The stage at which the runner is operating.
280275
///
281276
/// - Throws: Whatever is thrown from a test case's body. Thrown errors are
282277
/// normally reported as test failures.
283278
///
284279
/// If parallelization is supported and enabled, the generated test cases will
285280
/// be run in parallel using a task group.
286-
private static func _runTestCases(_ testCases: some Sequence<Test.Case>, within step: Plan.Step) async throws {
281+
private static func _runTestCases(_ testCases: some Sequence<Test.Case>, within step: Plan.Step, stage: Plan.Stage) async throws {
287282
// Apply the configuration's test case filter.
288283
let testCaseFilter = _configuration.testCaseFilter
289284
let testCases = testCases.lazy.filter { testCase in
290285
testCaseFilter(testCase, step.test)
291286
}
292287

293-
try await _forEach(in: testCases) { testCase in
288+
try await _forEach(in: testCases, stage: stage) { testCase in
294289
try await _runTestCase(testCase, within: step)
295290
}
296291
}
@@ -384,7 +379,9 @@ extension Runner {
384379

385380
await withTaskGroup(of: Void.self) { [runner] taskGroup in
386381
_ = taskGroup.addTaskUnlessCancelled {
387-
try? await _runStep(atRootOf: runner.plan.stepGraph)
382+
for stage in Plan.Stage.allCases {
383+
try? await _runStep(atRootOf: runner.plan.stepGraph, stage: stage)
384+
}
388385
}
389386
await taskGroup.waitForAll()
390387
}

Diff for: Sources/Testing/Testing.docc/Traits.md

+1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ behavior of test functions.
3636

3737
- <doc:Parallelization>
3838
- ``Trait/serialized``
39+
<!-- - ``Trait/serialized(_:)`` -->
3940

4041
### Annotating tests
4142

Diff for: Sources/Testing/Testing.docc/Traits/Trait.md

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ See https://swift.org/CONTRIBUTORS.txt for Swift project authors
2727
### Running tests serially or in parallel
2828

2929
- ``Trait/serialized``
30+
<!-- - ``Trait/serialized(_:)`` -->
3031

3132
### Categorizing tests
3233

Diff for: Sources/Testing/Traits/ParallelizationTrait.swift

+53-3
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,30 @@
2424
/// globally disabled (by, for example, passing `--no-parallel` to the
2525
/// `swift test` command.)
2626
///
27-
/// To add this trait to a test, use ``Trait/serialized``.
28-
public struct ParallelizationTrait: TestTrait, SuiteTrait {}
27+
/// To add this trait to a test, use ``Trait/serialized`` or
28+
/// ``Trait/serialized(_:)``.
29+
public struct ParallelizationTrait: TestTrait, SuiteTrait {
30+
/// Scopes in which suites and test functions can be serialized using the
31+
/// ``serialized(_:)`` trait.
32+
@_spi(Experimental)
33+
public enum Scope: Sendable {
34+
/// Parallelization is applied locally.
35+
///
36+
/// TODO: More blurb.
37+
case locally
38+
39+
/// Parallelization is applied globally.
40+
///
41+
/// TODO: More blurb.
42+
case globally
43+
}
44+
45+
var scope: Scope
46+
47+
public var isRecursive: Bool {
48+
scope == .globally
49+
}
50+
}
2951

3052
// MARK: - TestScoping
3153

@@ -45,10 +67,38 @@ extension ParallelizationTrait: TestScoping {
4567
extension Trait where Self == ParallelizationTrait {
4668
/// A trait that serializes the test to which it is applied.
4769
///
70+
/// This value is equivalent to ``serialized(_:)`` with the argument
71+
/// ``ParallelizationTrait/Scope/locally``.
72+
///
4873
/// ## See Also
4974
///
5075
/// - ``ParallelizationTrait``
5176
public static var serialized: Self {
52-
Self()
77+
Self(scope: .locally)
78+
}
79+
80+
/// A trait that serializes the test to which it is applied.
81+
///
82+
/// - Parameters:
83+
/// - scope: The scope in which parallelization is enforced.
84+
///
85+
/// ## See Also
86+
///
87+
/// - ``ParallelizationTrait``
88+
@_spi(Experimental)
89+
public static func serialized(_ scope: ParallelizationTrait.Scope) -> Self {
90+
Self(scope: scope)
91+
}
92+
}
93+
94+
// MARK: -
95+
96+
extension Test {
97+
/// Whether or not this test has been globally serialized.
98+
var isGloballySerialized: Bool {
99+
traits.lazy
100+
.compactMap { $0 as? ParallelizationTrait }
101+
.map(\.scope)
102+
.contains(.globally)
53103
}
54104
}

0 commit comments

Comments
 (0)