Skip to content

Commit 55ca2b7

Browse files
authored
Emit ABIv0 JSON objects for tests when listing them. (#511)
This PR changes the behavior of `swift test list` and its various synonyms to allow reporting the list of tests via the ABI-stable JSON mechanism described in #479. As it is not currently possible to directly call `swift test list --event-stream-output-path ... --event-stream-version 0`, it's a bit hard to test this code. However, it is possible to opt into this mode using `--configuration-path` and passing a path to a JSON file that includes `"listTests": true` (as noted by @allevato.) Support for these arguments with `swift test list` is tracked by swiftlang/swift-package-manager#7768. Resolves #506. Resolves rdar://130627856. ### 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.
1 parent 1b7dba9 commit 55ca2b7

File tree

8 files changed

+126
-64
lines changed

8 files changed

+126
-64
lines changed

Sources/Testing/ABI/EntryPoints/EntryPoint.swift

+42-34
Original file line numberDiff line numberDiff line change
@@ -31,58 +31,66 @@ func entryPoint(passing args: __CommandLineArguments_v0?, eventHandler: Event.Ha
3131
let exitCode = Locked(rawValue: EXIT_SUCCESS)
3232

3333
do {
34-
let args = try args ?? parseCommandLineArguments(from: CommandLine.arguments)
35-
if args.listTests ?? false {
36-
for testID in await listTestsForEntryPoint(Test.all) {
37-
#if SWT_TARGET_OS_APPLE && !SWT_NO_FILE_IO
38-
try? FileHandle.stdout.write("\(testID)\n")
39-
#else
40-
print(testID)
41-
#endif
42-
}
43-
} else {
4434
#if !SWT_NO_EXIT_TESTS
4535
// If an exit test was specified, run it. `exitTest` returns `Never`.
4636
if let exitTest = ExitTest.findInEnvironmentForEntryPoint() {
4737
await exitTest()
4838
}
4939
#endif
5040

51-
// Configure the test runner.
52-
var configuration = try configurationForEntryPoint(from: args)
41+
let args = try args ?? parseCommandLineArguments(from: CommandLine.arguments)
42+
// Configure the test runner.
43+
var configuration = try configurationForEntryPoint(from: args)
5344

54-
// Set up the event handler.
55-
configuration.eventHandler = { [oldEventHandler = configuration.eventHandler] event, context in
56-
if case let .issueRecorded(issue) = event.kind, !issue.isKnown {
57-
exitCode.withLock { exitCode in
58-
exitCode = EXIT_FAILURE
59-
}
45+
// Set up the event handler.
46+
configuration.eventHandler = { [oldEventHandler = configuration.eventHandler] event, context in
47+
if case let .issueRecorded(issue) = event.kind, !issue.isKnown {
48+
exitCode.withLock { exitCode in
49+
exitCode = EXIT_FAILURE
6050
}
61-
oldEventHandler(event, context)
6251
}
52+
oldEventHandler(event, context)
53+
}
6354

6455
#if !SWT_NO_FILE_IO
65-
// Configure the event recorder to write events to stderr.
66-
var options = Event.ConsoleOutputRecorder.Options()
67-
options = .for(.stderr)
68-
options.verbosity = args.verbosity
69-
let eventRecorder = Event.ConsoleOutputRecorder(options: options) { string in
70-
try? FileHandle.stderr.write(string)
71-
}
56+
// Configure the event recorder to write events to stderr.
57+
var options = Event.ConsoleOutputRecorder.Options()
58+
options = .for(.stderr)
59+
options.verbosity = args.verbosity
60+
let eventRecorder = Event.ConsoleOutputRecorder(options: options) { string in
61+
try? FileHandle.stderr.write(string)
62+
}
63+
configuration.eventHandler = { [oldEventHandler = configuration.eventHandler] event, context in
64+
eventRecorder.record(event, in: context)
65+
oldEventHandler(event, context)
66+
}
67+
#endif
68+
69+
// If the caller specified an alternate event handler, hook it up too.
70+
if let eventHandler {
7271
configuration.eventHandler = { [oldEventHandler = configuration.eventHandler] event, context in
73-
eventRecorder.record(event, in: context)
72+
eventHandler(event, context)
7473
oldEventHandler(event, context)
7574
}
76-
#endif
75+
}
7776

78-
// If the caller specified an alternate event handler, hook it up too.
79-
if let eventHandler {
80-
configuration.eventHandler = { [oldEventHandler = configuration.eventHandler] event, context in
81-
eventHandler(event, context)
82-
oldEventHandler(event, context)
83-
}
77+
if args.listTests ?? false {
78+
let tests = await Test.all
79+
for testID in listTestsForEntryPoint(tests) {
80+
// Print the test ID to stdout (classical CLI behavior.)
81+
#if SWT_TARGET_OS_APPLE && !SWT_NO_FILE_IO
82+
try? FileHandle.stdout.write("\(testID)\n")
83+
#else
84+
print(testID)
85+
#endif
8486
}
8587

88+
// Post an event for every discovered test. These events are turned into
89+
// JSON objects if JSON output is enabled.
90+
for test in tests {
91+
Event.post(.testDiscovered, for: test, testCase: nil, configuration: configuration)
92+
}
93+
} else {
8694
// Run the tests.
8795
let runner = await Runner(configuration: configuration)
8896
await runner.run()

Sources/Testing/ABI/v0/ABIv0.Record+Streaming.swift

+14-11
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,15 @@ extension ABIv0.Record {
3131
) -> Event.Handler {
3232
let humanReadableOutputRecorder = Event.HumanReadableOutputRecorder()
3333
return { event, context in
34-
if case let .runStarted(plan) = event.kind {
35-
// Gather all tests in the run and forward records for them.
36-
for test in plan.steps.lazy.map(\.test) {
37-
try? JSON.withEncoding(of: Self(encoding: test)) { testJSON in
38-
eventHandler(testJSON)
39-
}
34+
if case .testDiscovered = event.kind, let test = context.test {
35+
try? JSON.withEncoding(of: Self(encoding: test)) { testJSON in
36+
eventHandler(testJSON)
37+
}
38+
} else {
39+
let messages = humanReadableOutputRecorder.record(event, in: context)
40+
if let eventRecord = Self(encoding: event, in: context, messages: messages) {
41+
try? JSON.withEncoding(of: eventRecord, eventHandler)
4042
}
41-
}
42-
43-
let messages = humanReadableOutputRecorder.record(event, in: context)
44-
if let eventRecord = Self(encoding: event, in: context, messages: messages) {
45-
try? JSON.withEncoding(of: eventRecord, eventHandler)
4643
}
4744
}
4845
}
@@ -92,6 +89,12 @@ func eventHandlerForStreamingEventSnapshots(
9289
to eventHandler: @escaping @Sendable (_ eventAndContextJSON: UnsafeRawBufferPointer) -> Void
9390
) -> Event.Handler {
9491
return { event, context in
92+
if case .testDiscovered = event.kind {
93+
// Discard events of this kind rather than forwarding them to avoid a
94+
// crash in Xcode 16 Beta 1 (which does not expect any events to occur
95+
// before .runStarted.)
96+
return
97+
}
9598
let snapshot = EventAndContextSnapshot(
9699
event: Event.Snapshot(snapshotting: event),
97100
eventContext: Event.Context.Snapshot(snapshotting: context)

Sources/Testing/Events/Event.swift

+31-6
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,23 @@
1313
public struct Event: Sendable {
1414
/// An enumeration describing the various kinds of event that can be observed.
1515
public enum Kind: Sendable {
16-
/// A test run started.
16+
/// A test was discovered during test run planning.
1717
///
18-
/// - Parameters:
19-
/// - plan: The test plan of the run that started.
18+
/// This event is recorded once per discovered test when ``Runner/run()`` is
19+
/// called. It does not indicate whether or not a test will run or be
20+
/// skipped—only that the test was found by the testing library and is part
21+
/// of the runner's plan.
2022
///
21-
/// This event is the first event posted after ``Runner/run()`` is called.
22-
indirect case runStarted(_ plan: Runner.Plan)
23+
/// This event is also posted once per test when `swift test list` is
24+
/// called. In that case, events are posted for all discovered tests
25+
/// regardless of whether or not they would run.
26+
case testDiscovered
27+
28+
/// A test run started.
29+
///
30+
/// This event is posted when ``Runner/run()`` is called after
31+
/// ``testDiscovered`` has been posted for all tests in the runner's plan.
32+
case runStarted
2333

2434
/// An iteration of the test run started.
2535
///
@@ -318,9 +328,22 @@ extension Event {
318328
extension Event.Kind {
319329
/// A serializable enumeration describing the various kinds of event that can be observed.
320330
public enum Snapshot: Sendable, Codable {
331+
/// A test was discovered during test run planning.
332+
///
333+
/// This event is recorded once per discovered test when ``Runner/run()`` is
334+
/// called. It does not indicate whether or not a test will run or be
335+
/// skipped—only that the test was found by the testing library and is part
336+
/// of the runner's plan.
337+
///
338+
/// This event is also posted once per test when `swift test list` is
339+
/// called. In that case, events are posted for all discovered tests
340+
/// regardless of whether or not they would run.
341+
case testDiscovered
342+
321343
/// A test run started.
322344
///
323-
/// This is the first event posted after ``Runner/run()`` is called.
345+
/// This event is posted when ``Runner/run()`` is called after
346+
/// ``testDiscovered`` has been posted for all tests in the runner's plan.
324347
case runStarted
325348

326349
/// An iteration of the test run started.
@@ -420,6 +443,8 @@ extension Event.Kind {
420443
/// - Parameter kind: The original ``Event.Kind`` to snapshot.
421444
public init(snapshotting kind: Event.Kind) {
422445
switch kind {
446+
case .testDiscovered:
447+
self = .testDiscovered
423448
case .runStarted:
424449
self = .runStarted
425450
case let .iterationStarted(index):

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

+5
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,11 @@ extension Event.HumanReadableOutputRecorder {
296296

297297
// Finally, produce any messages for the event.
298298
switch event.kind {
299+
case .testDiscovered:
300+
// Suppress events of this kind from output as they are not generally
301+
// interesting in human-readable output.
302+
break
303+
299304
case .runStarted:
300305
var comments = [Comment]()
301306
if verbosity > 0 {

Sources/Testing/Running/Runner.swift

+9-3
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ public struct Runner: Sendable {
2727
/// - tests: The tests to run.
2828
/// - configuration: The configuration to use for running.
2929
public init(testing tests: [Test], configuration: Configuration = .init()) async {
30-
self.plan = await Plan(tests: tests, configuration: configuration)
31-
self.configuration = configuration
30+
let plan = await Plan(tests: tests, configuration: configuration)
31+
self.init(plan: plan, configuration: configuration)
3232
}
3333

3434
/// Initialize an instance of this type that runs the tests in the specified
@@ -343,7 +343,13 @@ extension Runner {
343343
}
344344

345345
await Configuration.withCurrent(runner.configuration) {
346-
Event.post(.runStarted(runner.plan), for: nil, testCase: nil, configuration: runner.configuration)
346+
// Post an event for every test in the test plan being run. These events
347+
// are turned into JSON objects if JSON output is enabled.
348+
for test in runner.plan.steps.lazy.map(\.test) {
349+
Event.post(.testDiscovered, for: test, testCase: nil, configuration: runner.configuration)
350+
}
351+
352+
Event.post(.runStarted, for: nil, testCase: nil, configuration: runner.configuration)
347353
defer {
348354
Event.post(.runEnded, for: nil, testCase: nil, configuration: runner.configuration)
349355
}

Tests/TestingTests/ABIEntryPointTests.swift

+19
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,25 @@ struct ABIEntryPointTests {
109109
#expect(result)
110110
}
111111

112+
@Test("v0 entry point listing tests only")
113+
func v0_listingTestsOnly() async throws {
114+
var arguments = __CommandLineArguments_v0()
115+
arguments.listTests = true
116+
arguments.eventStreamVersion = 0
117+
arguments.verbosity = .min
118+
119+
try await confirmation("Test matched", expectedCount: 1...) { testMatched in
120+
_ = try await _invokeEntryPointV0(passing: arguments) { recordJSON in
121+
let record = try! JSON.decode(ABIv0.Record.self, from: recordJSON)
122+
if case .test = record.kind {
123+
testMatched()
124+
} else {
125+
Issue.record("Unexpected record \(record)")
126+
}
127+
}
128+
}
129+
}
130+
112131
private func _invokeEntryPointV0(
113132
passing arguments: __CommandLineArguments_v0,
114133
recordHandler: @escaping @Sendable (_ recordJSON: UnsafeRawBufferPointer) -> Void = { _ in }

Tests/TestingTests/EventTests.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ struct EventTests {
4848
sourceLocation: nil)
4949
)
5050
),
51-
Event.Kind.runStarted(Runner.Plan(steps: [])),
51+
Event.Kind.runStarted,
5252
Event.Kind.runEnded,
5353
Event.Kind.testCaseStarted,
5454
Event.Kind.testCaseEnded,

Tests/TestingTests/SwiftPMTests.swift

+5-9
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ struct SwiftPMTests {
155155
do {
156156
let configuration = try configurationForEntryPoint(withArguments: ["PATH", "--xunit-output", temporaryFilePath])
157157
let eventContext = Event.Context()
158-
configuration.eventHandler(Event(.runStarted(Runner.Plan(steps: [])), testID: nil, testCaseID: nil), eventContext)
158+
configuration.eventHandler(Event(.runStarted, testID: nil, testCaseID: nil), eventContext)
159159
configuration.eventHandler(Event(.runEnded, testID: nil, testCaseID: nil), eventContext)
160160
}
161161

@@ -222,15 +222,11 @@ struct SwiftPMTests {
222222
}
223223
do {
224224
let configuration = try configurationForEntryPoint(withArguments: ["PATH", outputArgumentName, temporaryFilePath, versionArgumentName, version])
225-
let eventContext = Event.Context()
226-
227225
let test = Test {}
228-
let plan = Runner.Plan(
229-
steps: [
230-
Runner.Plan.Step(test: test, action: .run(options: .init(isParallelizationEnabled: true)))
231-
]
232-
)
233-
configuration.handleEvent(Event(.runStarted(plan), testID: nil, testCaseID: nil), in: eventContext)
226+
let eventContext = Event.Context(test: test)
227+
228+
configuration.handleEvent(Event(.testDiscovered, testID: test.id, testCaseID: nil), in: eventContext)
229+
configuration.handleEvent(Event(.runStarted, testID: nil, testCaseID: nil), in: eventContext)
234230
do {
235231
let eventContext = Event.Context(test: test)
236232
configuration.handleEvent(Event(.testStarted, testID: test.id, testCaseID: nil), in: eventContext)

0 commit comments

Comments
 (0)