Skip to content

Commit b56289a

Browse files
Add task names to structured tasks we create. (#1305)
This PR sets a task name for every child task created by Swift Testing. These names are useful for debugging. Example result: <img width="440" height="132" alt="Screenshot 2025-09-10 at 11 49 42 AM" src="https://github.com/user-attachments/assets/691d7e60-1fdc-4b31-bae7-baa99948123c" /> Resolves #1303. ### 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. --------- Co-authored-by: Stuart Montgomery <[email protected]>
1 parent 3894845 commit b56289a

File tree

8 files changed

+107
-30
lines changed

8 files changed

+107
-30
lines changed

Sources/Testing/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ add_library(Testing
7676
Support/Additions/CommandLineAdditions.swift
7777
Support/Additions/NumericAdditions.swift
7878
Support/Additions/ResultAdditions.swift
79+
Support/Additions/TaskAdditions.swift
7980
Support/Additions/WinSDKAdditions.swift
8081
Support/CartesianProduct.swift
8182
Support/CError.swift

Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,26 @@ private func _capitalizedTitle(for test: Test?) -> String {
187187
test?.isSuite == true ? "Suite" : "Test"
188188
}
189189

190+
extension Test {
191+
/// The name to use for this test in a human-readable context such as console
192+
/// output.
193+
///
194+
/// - Parameters:
195+
/// - verbosity: The verbosity with which to describe this test.
196+
///
197+
/// - Returns: The name of this test, suitable for display to the user.
198+
func humanReadableName(withVerbosity verbosity: Int = 0) -> String {
199+
switch displayName {
200+
case let .some(displayName) where verbosity > 0:
201+
#""\#(displayName)" (aka '\#(name)')"#
202+
case let .some(displayName):
203+
#""\#(displayName)""#
204+
default:
205+
name
206+
}
207+
}
208+
}
209+
190210
extension Test.Case {
191211
/// The arguments of this test case, formatted for presentation, prefixed by
192212
/// their corresponding parameter label when available.
@@ -256,19 +276,7 @@ extension Event.HumanReadableOutputRecorder {
256276
let test = eventContext.test
257277
let testCase = eventContext.testCase
258278
let keyPath = eventContext.keyPath
259-
let testName = if let test {
260-
if let displayName = test.displayName {
261-
if verbosity > 0 {
262-
"\"\(displayName)\" (aka '\(test.name)')"
263-
} else {
264-
"\"\(displayName)\""
265-
}
266-
} else {
267-
test.name
268-
}
269-
} else {
270-
"«unknown»"
271-
}
279+
let testName = test?.humanReadableName(withVerbosity: verbosity) ?? "«unknown»"
272280
let instant = event.instant
273281
let iterationCount = eventContext.configuration?.repetitionPolicy.maximumIterationCount
274282

Sources/Testing/ExitTests/ExitTest.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -955,22 +955,22 @@ extension ExitTest {
955955
capturedValuesWriteEnd.close()
956956

957957
// Await termination of the child process.
958-
taskGroup.addTask {
958+
taskGroup.addTask(name: decorateTaskName("exit test", withAction: "awaiting termination")) {
959959
let exitStatus = try await wait(for: processID)
960960
return { $0.exitStatus = exitStatus }
961961
}
962962

963963
// Read back the stdout and stderr streams.
964964
if let stdoutReadEnd {
965965
stdoutWriteEnd?.close()
966-
taskGroup.addTask {
966+
taskGroup.addTask(name: decorateTaskName("exit test", withAction: "reading stdout")) {
967967
let standardOutputContent = try Self._trimToBarrierValues(stdoutReadEnd.readToEnd())
968968
return { $0.standardOutputContent = standardOutputContent }
969969
}
970970
}
971971
if let stderrReadEnd {
972972
stderrWriteEnd?.close()
973-
taskGroup.addTask {
973+
taskGroup.addTask(name: decorateTaskName("exit test", withAction: "reading stderr")) {
974974
let standardErrorContent = try Self._trimToBarrierValues(stderrReadEnd.readToEnd())
975975
return { $0.standardErrorContent = standardErrorContent }
976976
}
@@ -979,7 +979,7 @@ extension ExitTest {
979979
// Read back all data written to the back channel by the child process
980980
// and process it as a (minimal) event stream.
981981
backChannelWriteEnd.close()
982-
taskGroup.addTask {
982+
taskGroup.addTask(name: decorateTaskName("exit test", withAction: "processing events")) {
983983
Self._processRecords(fromBackChannel: backChannelReadEnd)
984984
return nil
985985
}

Sources/Testing/Running/Runner.Plan.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,14 @@ extension Runner.Plan {
213213
// FIXME: Parallelize this work. Calling `prepare(...)` on all traits and
214214
// evaluating all test arguments should be safely parallelizable.
215215
(test, result) = await withTaskGroup(returning: (Test, Action).self) { [test] taskGroup in
216-
taskGroup.addTask {
216+
let testName = test.humanReadableName()
217+
let (taskName, taskAction) = if test.isSuite {
218+
("suite \(testName)", "evaluating traits")
219+
} else {
220+
// TODO: split the task group's single task into two serially-run subtasks
221+
("test \(testName)", "evaluating traits and test cases")
222+
}
223+
taskGroup.addTask(name: decorateTaskName(taskName, withAction: taskAction)) {
217224
var test = test
218225
var action = _runAction
219226

Sources/Testing/Running/Runner.swift

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,17 +149,21 @@ extension Runner {
149149
///
150150
/// - Parameters:
151151
/// - sequence: The sequence to enumerate.
152+
/// - taskNamer: A function to invoke for each element in `sequence`. The
153+
/// result of this function is used to name each child task.
152154
/// - body: The function to invoke.
153155
///
154156
/// - Throws: Whatever is thrown by `body`.
155157
private static func _forEach<E>(
156158
in sequence: some Sequence<E>,
157-
_ body: @Sendable @escaping (E) async throws -> Void
159+
namingTasksWith taskNamer: (borrowing E) -> (taskName: String, action: String?)?,
160+
_ body: @Sendable @escaping (borrowing E) async throws -> Void
158161
) async rethrows where E: Sendable {
159162
try await withThrowingTaskGroup { taskGroup in
160163
for element in sequence {
161164
// Each element gets its own subtask to run in.
162-
taskGroup.addTask {
165+
let taskName = taskNamer(element)
166+
taskGroup.addTask(name: decorateTaskName(taskName?.taskName, withAction: taskName?.action)) {
163167
try await body(element)
164168
}
165169

@@ -314,8 +318,19 @@ extension Runner {
314318
}
315319
}
316320

321+
// Figure out how to name child tasks.
322+
func taskNamer(_ childGraph: Graph<String, Plan.Step?>) -> (String, String?)? {
323+
childGraph.value.map { step in
324+
let testName = step.test.humanReadableName()
325+
if step.test.isSuite {
326+
return ("suite \(testName)", "running")
327+
}
328+
return ("test \(testName)", nil) // test cases have " - running" suffix
329+
}
330+
}
331+
317332
// Run the child nodes.
318-
try await _forEach(in: childGraphs) { _, childGraph in
333+
try await _forEach(in: childGraphs.lazy.map(\.value), namingTasksWith: taskNamer) { childGraph in
319334
try await _runStep(atRootOf: childGraph)
320335
}
321336
}
@@ -335,7 +350,15 @@ extension Runner {
335350
testCaseFilter(testCase, step.test)
336351
}
337352

338-
await _forEach(in: testCases) { testCase in
353+
// Figure out how to name child tasks.
354+
let testName = "test \(step.test.humanReadableName())"
355+
let taskNamer: (Int, Test.Case) -> (String, String?)? = if step.test.isParameterized {
356+
{ i, _ in (testName, "running test case #\(i + 1)") }
357+
} else {
358+
{ _, _ in (testName, "running") }
359+
}
360+
361+
await _forEach(in: testCases.enumerated(), namingTasksWith: taskNamer) { _, testCase in
339362
await _runTestCase(testCase, within: step)
340363
}
341364
}
@@ -418,14 +441,19 @@ extension Runner {
418441
}
419442

420443
let repetitionPolicy = runner.configuration.repetitionPolicy
421-
for iterationIndex in 0 ..< repetitionPolicy.maximumIterationCount {
444+
let iterationCount = repetitionPolicy.maximumIterationCount
445+
for iterationIndex in 0 ..< iterationCount {
422446
Event.post(.iterationStarted(iterationIndex), for: (nil, nil), configuration: runner.configuration)
423447
defer {
424448
Event.post(.iterationEnded(iterationIndex), for: (nil, nil), configuration: runner.configuration)
425449
}
426450

427451
await withTaskGroup { [runner] taskGroup in
428-
_ = taskGroup.addTaskUnlessCancelled {
452+
var taskAction: String?
453+
if iterationCount > 1 {
454+
taskAction = "running iteration #\(iterationIndex + 1)"
455+
}
456+
_ = taskGroup.addTaskUnlessCancelled(name: decorateTaskName("test run", withAction: taskAction)) {
429457
try? await _runStep(atRootOf: runner.plan.stepGraph)
430458
}
431459
await taskGroup.waitForAll()
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2025 Apple Inc. and the Swift project authors
5+
// Licensed under Apache License v2.0 with Runtime Library Exception
6+
//
7+
// See https://swift.org/LICENSE.txt for license information
8+
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
//
10+
11+
/// Make a (decorated) task name from the given undecorated task name.
12+
///
13+
/// - Parameters:
14+
/// - taskName: The undecorated task name to modify.
15+
///
16+
/// - Returns: A copy of `taskName` with a common prefix applied, or `nil` if
17+
/// `taskName` was `nil`.
18+
func decorateTaskName(_ taskName: String?, withAction action: String?) -> String? {
19+
let prefix = "[Swift Testing]"
20+
return taskName.map { taskName in
21+
#if DEBUG
22+
precondition(!taskName.hasPrefix(prefix), "Applied prefix '\(prefix)' to task name '\(taskName)' twice. Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new")
23+
#endif
24+
let action = action.map { " - \($0)" } ?? ""
25+
return "\(prefix) \(taskName)\(action)"
26+
}
27+
}

Sources/Testing/Test+Discovery.swift

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,10 @@ extension Test {
8888
if useNewMode {
8989
let generators = Generator.allTestContentRecords().lazy.compactMap { $0.load() }
9090
await withTaskGroup { taskGroup in
91-
for generator in generators {
92-
taskGroup.addTask { await generator.rawValue() }
91+
for (i, generator) in generators.enumerated() {
92+
taskGroup.addTask(name: decorateTaskName("test discovery", withAction: "loading test #\(i)")) {
93+
await generator.rawValue()
94+
}
9395
}
9496
result = await taskGroup.reduce(into: result) { $0.insert($1) }
9597
}
@@ -100,8 +102,10 @@ extension Test {
100102
if useLegacyMode && result.isEmpty {
101103
let generators = Generator.allTypeMetadataBasedTestContentRecords().lazy.compactMap { $0.load() }
102104
await withTaskGroup { taskGroup in
103-
for generator in generators {
104-
taskGroup.addTask { await generator.rawValue() }
105+
for (i, generator) in generators.enumerated() {
106+
taskGroup.addTask(name: decorateTaskName("type-based test discovery", withAction: "loading test #\(i)")) {
107+
await generator.rawValue()
108+
}
105109
}
106110
result = await taskGroup.reduce(into: result) { $0.insert($1) }
107111
}

Sources/Testing/Traits/TimeLimitTrait.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ struct TimeoutError: Error, CustomStringConvertible {
264264
///
265265
/// - Parameters:
266266
/// - timeLimit: The amount of time until the closure times out.
267+
/// - taskName: The name of the child task that runs `body`, if any.
267268
/// - body: The function to invoke.
268269
/// - timeoutHandler: A function to invoke if `body` times out.
269270
///
@@ -277,18 +278,19 @@ struct TimeoutError: Error, CustomStringConvertible {
277278
@available(_clockAPI, *)
278279
func withTimeLimit(
279280
_ timeLimit: Duration,
281+
taskName: String? = nil,
280282
_ body: @escaping @Sendable () async throws -> Void,
281283
timeoutHandler: @escaping @Sendable () -> Void
282284
) async throws {
283285
try await withThrowingTaskGroup { group in
284-
group.addTask {
286+
group.addTask(name: decorateTaskName(taskName, withAction: "waiting for timeout")) {
285287
// If sleep() returns instead of throwing a CancellationError, that means
286288
// the timeout was reached before this task could be cancelled, so call
287289
// the timeout handler.
288290
try await Test.Clock.sleep(for: timeLimit)
289291
timeoutHandler()
290292
}
291-
group.addTask(operation: body)
293+
group.addTask(name: decorateTaskName(taskName, withAction: "running"), operation: body)
292294

293295
defer {
294296
group.cancelAll()

0 commit comments

Comments
 (0)