Skip to content

Commit 2f6657b

Browse files
committed
Sink iteration down to a per-test-case iteration
1 parent 176dbff commit 2f6657b

4 files changed

Lines changed: 162 additions & 122 deletions

File tree

Sources/Testing/ABI/Encoded/ABI.EncodedEvent.swift

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,14 @@ extension ABI {
118118
kind = .testStarted
119119
case .testCaseStarted:
120120
if eventContext.test?.isParameterized == false {
121-
return nil
121+
if let iteration = eventContext.iteration, iteration > 1 {
122+
kind = .testStarted
123+
} else {
124+
return nil
125+
}
126+
} else {
127+
kind = .testCaseStarted
122128
}
123-
kind = .testCaseStarted
124129
case let .issueRecorded(recordedIssue):
125130
kind = .issueRecorded
126131
issue = EncodedIssue(encoding: recordedIssue, in: eventContext)
@@ -129,9 +134,14 @@ extension ABI {
129134
self.attachment = EncodedAttachment(encoding: attachment)
130135
case .testCaseEnded:
131136
if eventContext.test?.isParameterized == false {
132-
return nil
137+
if let iteration = eventContext.iteration, iteration > 1 {
138+
kind = .testEnded
139+
} else {
140+
return nil
141+
}
142+
} else {
143+
kind = .testCaseEnded
133144
}
134-
kind = .testCaseEnded
135145
case .testCaseCancelled:
136146
kind = .testCaseCancelled
137147
case .testEnded:
@@ -164,6 +174,7 @@ extension ABI {
164174
let .testCancelled(skipInfo):
165175
_comments = Array(skipInfo.comment).map(\.rawValue)
166176
_sourceLocation = skipInfo.sourceLocation.map { EncodedSourceLocation(encoding: $0) }
177+
_iteration = eventContext.iteration
167178
default:
168179
break
169180
}

Sources/Testing/Running/Runner.swift

Lines changed: 74 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,6 @@ extension Runner {
8282
private struct _Context: Sendable {
8383
/// A serializer used to reduce parallelism among test cases.
8484
var testCaseSerializer: Serializer<Void>?
85-
86-
/// Which iteration of the test plan is being executed.
87-
var iteration: Int
8885
}
8986

9087
/// Apply the custom scope for any test scope providers of the traits
@@ -230,10 +227,10 @@ extension Runner {
230227
// Determine what kind of event to send for this step based on its action.
231228
switch step.action {
232229
case .run:
233-
Event.post(.testStarted, for: (step.test, nil), iteration: context.iteration, configuration: configuration)
230+
Event.post(.testStarted, for: (step.test, nil), iteration: 1, configuration: configuration)
234231
shouldSendTestEnded = true
235232
case let .skip(skipInfo):
236-
Event.post(.testSkipped(skipInfo), for: (step.test, nil), iteration: context.iteration, configuration: configuration)
233+
Event.post(.testSkipped(skipInfo), for: (step.test, nil), iteration: 1, configuration: configuration)
237234
shouldSendTestEnded = false
238235
case let .recordIssue(issue):
239236
// Scope posting the issue recorded event such that issue handling
@@ -257,7 +254,7 @@ extension Runner {
257254
defer {
258255
if let step = stepGraph.value {
259256
if shouldSendTestEnded {
260-
Event.post(.testEnded, for: (step.test, nil), iteration: context.iteration, configuration: configuration)
257+
Event.post(.testEnded, for: (step.test, nil), iteration: 1, configuration: configuration)
261258
}
262259
Event.post(.planStepEnded(step), for: (step.test, nil), configuration: configuration)
263260
}
@@ -405,35 +402,81 @@ extension Runner {
405402
/// This function sets ``Test/Case/current``, then invokes the test case's
406403
/// body closure.
407404
private static func _runTestCase(_ testCase: Test.Case, within step: Plan.Step, context: _Context) async {
408-
let configuration = _configuration
405+
await _applyRepetitionPolicy(test: step.test, testCase: testCase) { iteration in
406+
let configuration = _configuration
409407

410-
Event.post(.testCaseStarted, for: (step.test, testCase), iteration: context.iteration, configuration: configuration)
411-
defer {
412-
Event.post(.testCaseEnded, for: (step.test, testCase), iteration: context.iteration, configuration: configuration)
413-
}
408+
Event.post(.testCaseStarted, for: (step.test, testCase), iteration: iteration, configuration: configuration)
409+
defer {
410+
Event.post(.testCaseEnded, for: (step.test, testCase), iteration: iteration, configuration: configuration)
411+
}
414412

415-
await Test.Case.withCurrent(testCase) {
416-
let sourceLocation = step.test.sourceLocation
417-
await Issue.withErrorRecording(at: sourceLocation, configuration: configuration) {
418-
// Exit early if the task has already been cancelled.
419-
try Task.checkCancellation()
413+
await Test.Case.withCurrent(testCase) {
414+
let sourceLocation = step.test.sourceLocation
415+
await Issue.withErrorRecording(at: sourceLocation, configuration: configuration) {
416+
// Exit early if the task has already been cancelled.
417+
try Task.checkCancellation()
420418

421-
try await withTimeLimit(for: step.test, configuration: configuration) {
422-
try await _applyScopingTraits(for: step.test, testCase: testCase) {
423-
try await testCase.body()
419+
try await withTimeLimit(for: step.test, configuration: configuration) {
420+
try await _applyScopingTraits(for: step.test, testCase: testCase) {
421+
try await testCase.body()
422+
}
423+
} timeoutHandler: { timeLimit in
424+
let issue = Issue(
425+
kind: .timeLimitExceeded(timeLimit: timeLimit),
426+
comments: [],
427+
sourceContext: .init(backtrace: .current(), sourceLocation: sourceLocation)
428+
)
429+
issue.record(configuration: configuration)
424430
}
425-
} timeoutHandler: { timeLimit in
426-
let issue = Issue(
427-
kind: .timeLimitExceeded(timeLimit: timeLimit),
428-
comments: [],
429-
sourceContext: .init(backtrace: .current(), sourceLocation: sourceLocation)
430-
)
431-
issue.record(configuration: configuration)
432431
}
433432
}
434433
}
435434
}
436435

436+
/// Applies the repetition policy specified in the current configuration by running the provided test case
437+
/// repeatedly until the continuation condition is satisfied.
438+
///
439+
/// - Parameters:
440+
/// - test: The test being executed.
441+
/// - testCase: The test case being iterated.
442+
/// - body: The actual body of the function which must ultimately call into the test function.
443+
///
444+
/// - Note: This function updates ``Configuration/current`` before invoking the test body.
445+
private static func _applyRepetitionPolicy(
446+
test: Test,
447+
testCase: Test.Case,
448+
perform body: (Int) async -> Void
449+
) async {
450+
var config = _configuration
451+
452+
for iteration in 1...config.repetitionPolicy.maximumIterationCount {
453+
let issueRecorded = Atomic(false)
454+
config.eventHandler = { [eventHandler = config.eventHandler] event, context in
455+
if case let .issueRecorded(issue) = event.kind, !issue.isKnown {
456+
issueRecorded.store(true, ordering: .sequentiallyConsistent)
457+
}
458+
eventHandler(event, context)
459+
}
460+
461+
await Configuration.withCurrent(config) {
462+
await body(iteration)
463+
}
464+
465+
// Determine if the test plan should iterate again.
466+
let shouldContinue = switch config.repetitionPolicy.continuationCondition {
467+
case nil:
468+
true
469+
case .untilIssueRecorded:
470+
!issueRecorded.load(ordering: .sequentiallyConsistent)
471+
case .whileIssueRecorded:
472+
issueRecorded.load(ordering: .sequentiallyConsistent)
473+
}
474+
guard shouldContinue else {
475+
break
476+
}
477+
}
478+
}
479+
437480
/// Run the tests in this runner's plan.
438481
public func run() async {
439482
await Self._run(self)
@@ -454,21 +497,12 @@ extension Runner {
454497
#endif
455498
_ = Event.installFallbackEventHandler()
456499

457-
// Track whether or not any issues were recorded across the entire run.
458-
let issueRecorded = Atomic(false)
459-
runner.configuration.eventHandler = { [eventHandler = runner.configuration.eventHandler] event, context in
460-
if case let .issueRecorded(issue) = event.kind, !issue.isKnown {
461-
issueRecorded.store(true, ordering: .sequentiallyConsistent)
462-
}
463-
eventHandler(event, context)
464-
}
465-
466500
// Context to pass into the test run. We intentionally don't pass the Runner
467501
// itself (implicitly as `self` nor as an argument) because we don't want to
468502
// accidentally depend on e.g. the `configuration` property rather than the
469503
// current configuration.
470504
let context: _Context = {
471-
var context = _Context(iteration: 0)
505+
var context = _Context()
472506

473507
let maximumParallelizationWidth = runner.configuration.maximumParallelizationWidth
474508
if maximumParallelizationWidth > 1 && maximumParallelizationWidth < .max {
@@ -492,44 +526,11 @@ extension Runner {
492526
Event.post(.runEnded, for: (nil, nil), configuration: runner.configuration)
493527
}
494528

495-
let repetitionPolicy = runner.configuration.repetitionPolicy
496-
let iterationCount = repetitionPolicy.maximumIterationCount
497-
for iterationIndex in 0 ..< iterationCount {
498-
Event.post(.iterationStarted(iterationIndex), for: (nil, nil), configuration: runner.configuration)
499-
defer {
500-
Event.post(.iterationEnded(iterationIndex), for: (nil, nil), configuration: runner.configuration)
529+
await withTaskGroup { [runner] taskGroup in
530+
_ = taskGroup.addTaskUnlessCancelled(name: decorateTaskName("test run", withAction: nil)) {
531+
try? await _runStep(atRootOf: runner.plan.stepGraph, context: context)
501532
}
502-
503-
await withTaskGroup { [runner] taskGroup in
504-
var taskAction: String?
505-
if iterationCount > 1 {
506-
taskAction = "running iteration #\(iterationIndex + 1)"
507-
}
508-
_ = taskGroup.addTaskUnlessCancelled(name: decorateTaskName("test run", withAction: taskAction)) {
509-
var iterationContext = context
510-
// `iteration` is one-indexed, so offset that here.
511-
iterationContext.iteration = iterationIndex + 1
512-
try? await _runStep(atRootOf: runner.plan.stepGraph, context: iterationContext)
513-
}
514-
await taskGroup.waitForAll()
515-
}
516-
517-
// Determine if the test plan should iterate again. (The iteration count
518-
// is handled by the outer for-loop.)
519-
let shouldContinue = switch repetitionPolicy.continuationCondition {
520-
case nil:
521-
true
522-
case .untilIssueRecorded:
523-
!issueRecorded.load(ordering: .sequentiallyConsistent)
524-
case .whileIssueRecorded:
525-
issueRecorded.load(ordering: .sequentiallyConsistent)
526-
}
527-
guard shouldContinue else {
528-
break
529-
}
530-
531-
// Reset the run-wide "issue was recorded" flag for this iteration.
532-
issueRecorded.store(false, ordering: .sequentiallyConsistent)
533+
await taskGroup.waitForAll()
533534
}
534535
}
535536
}

Tests/TestingTests/EventIterationTests.swift

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ struct EventIterationTests {
4444

4545
// Verify all expected iterations were recorded
4646
let iteration = recordedIteration.load(ordering: .sequentiallyConsistent)
47-
#expect(iteration == expectedIterations, "Final observed iteration did not match expected number of iterations", sourceLocation: location)
47+
#expect(iteration == expectedIterations, sourceLocation: location)
4848
}
4949
}
5050

@@ -60,17 +60,6 @@ struct EventIterationTests {
6060
}
6161
}
6262

63-
@Test
64-
func `testStarted and testEnded events include iteration in context`() async {
65-
await verifyIterations(
66-
for: [.testStarted, .testEnded],
67-
repetitionPolicy: .once,
68-
expectedIterations: 1
69-
) { _ in
70-
// Do nothing, just pass
71-
}
72-
}
73-
7463
@Test
7564
func `testCaseStarted and testCaseEnded events include iteration in context`() async {
7665
await verifyIterations(
@@ -96,9 +85,7 @@ struct EventIterationTests {
9685
repetitionPolicy: policy,
9786
expectedIterations: expectedIterations
9887
) { iteration in
99-
if iteration < 3 {
100-
Issue.record("Failure")
101-
}
88+
#expect(iteration >= 3)
10289
}
10390
}
10491
}

0 commit comments

Comments
 (0)