Skip to content

Commit 2faa04c

Browse files
committed
Emit ABIv0 JSON objects for tests when listing them.
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 --experimental-event-stream-output ... --experimental-event-stream-version 0`, it's a bit hard to test this code. However, it is possible to opt into this mode using `--experimental-configuration-path` and passing a path to a JSON file that includes `"listTests": true` (as noted by @allevato.) Resolves #506. Resolves rdar://130627856.
1 parent 258d2ce commit 2faa04c

File tree

10 files changed

+132
-63
lines changed

10 files changed

+132
-63
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/ABI/v0/Encoded/ABIv0.EncodedTest.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ extension ABIv0 {
8181
let testIsParameterized = test.isParameterized
8282
isParameterized = testIsParameterized
8383
if testIsParameterized {
84-
_testCases = test.testCases?.map(EncodedTestCase.init(encoding:))
84+
_testCases = test.uncheckedTestCases?.map(EncodedTestCase.init(encoding:))
8585
}
8686
}
8787
name = test.name

Sources/Testing/Events/Event.swift

+19-4
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,18 @@
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 an instance of
19+
/// ``Runner`` is initialized, _prior to_ ``Runner/run()`` being called. It
20+
/// does not indicate whether or not a test will run—only that the test was
21+
/// found by the testing library.
22+
case testDiscovered
23+
24+
/// A test run started.
2025
///
2126
/// This event is the first event posted after ``Runner/run()`` is called.
22-
indirect case runStarted(_ plan: Runner.Plan)
27+
case runStarted
2328

2429
/// An iteration of the test run started.
2530
///
@@ -318,6 +323,14 @@ extension Event {
318323
extension Event.Kind {
319324
/// A serializable enumeration describing the various kinds of event that can be observed.
320325
public enum Snapshot: Sendable, Codable {
326+
/// A test was discovered during test run planning.
327+
///
328+
/// This event is recorded once per discovered test when an instance of
329+
/// ``Runner`` is initialized, _prior to_ ``Runner/run()`` being called. It
330+
/// does not indicate whether or not a test will run—only that the test was
331+
/// found by the testing library.
332+
case testDiscovered
333+
321334
/// A test run started.
322335
///
323336
/// This is the first event posted after ``Runner/run()`` is called.
@@ -420,6 +433,8 @@ extension Event.Kind {
420433
/// - Parameter kind: The original ``Event.Kind`` to snapshot.
421434
public init(snapshotting kind: Event.Kind) {
422435
switch kind {
436+
case .testDiscovered:
437+
self = .testDiscovered
423438
case .runStarted:
424439
self = .runStarted
425440
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
@@ -40,6 +40,12 @@ public struct Runner: Sendable {
4040
public init(plan: Plan, configuration: Configuration = .init()) {
4141
self.plan = plan
4242
self.configuration = configuration
43+
44+
// Post an event for every test in the resulting test plan. These events are
45+
// turned into JSON objects if JSON output is enabled.
46+
for test in plan.steps.lazy.map(\.test) {
47+
Event.post(.testDiscovered, for: test, testCase: nil, configuration: configuration)
48+
}
4349
}
4450

4551
/// Initialize an instance of this type that runs all tests found in the
@@ -343,7 +349,7 @@ extension Runner {
343349
}
344350

345351
await Configuration.withCurrent(runner.configuration) {
346-
Event.post(.runStarted(runner.plan), for: nil, testCase: nil, configuration: runner.configuration)
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
}

Sources/Testing/Test.swift

+17
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,23 @@ public struct Test: Sendable {
127127
return testCases
128128
}
129129
}
130+
131+
/// Equivalent to ``testCases``, but without requiring that the test cases be
132+
/// evaluated first.
133+
///
134+
/// Most callers should not use this property and should prefer ``testCases``
135+
/// since it will help catch logic errors in the testing library. Use this
136+
/// property if you are interested in the test's test cases, but the test has
137+
/// not been evaluated by an instance of ``Runner/Plan`` (e.g. if you are
138+
/// implementing `swift test list`.)
139+
var uncheckedTestCases: (some Sequence<Test.Case>)? {
140+
testCasesState.flatMap { testCasesState in
141+
if case let .evaluated(testCases) = testCasesState {
142+
return testCases
143+
}
144+
return nil
145+
}
146+
}
130147

131148
/// Evaluate this test's cases if they have not been evaluated yet.
132149
///

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)