Skip to content

Commit 6bbdb36

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 dd8988e commit 6bbdb36

11 files changed

+213
-65
lines changed

Sources/Testing/EntryPoints/ABIv0/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/EntryPoints/ABIv0/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/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/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/Issues/Confirmation.swift

+81-2
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,92 @@ public func confirmation<R>(
9696
expectedCount: Int = 1,
9797
sourceLocation: SourceLocation = #_sourceLocation,
9898
_ body: (Confirmation) async throws -> R
99+
) async rethrows -> R {
100+
try await confirmation(
101+
comment,
102+
expectedCount: expectedCount ... expectedCount,
103+
sourceLocation: sourceLocation,
104+
body
105+
)
106+
}
107+
108+
/// Confirm that some event occurs during the invocation of a function.
109+
///
110+
/// - Parameters:
111+
/// - comment: An optional comment to apply to any issues generated by this
112+
/// function.
113+
/// - expectedCount: A range of integers indicating the number of times the
114+
/// expected event should occur when `body` is invoked.
115+
/// - sourceLocation: The source location to which any recorded issues should
116+
/// be attributed.
117+
/// - body: The function to invoke.
118+
///
119+
/// - Returns: Whatever is returned by `body`.
120+
///
121+
/// - Throws: Whatever is thrown by `body`.
122+
///
123+
/// Use confirmations to check that an event occurs while a test is running in
124+
/// complex scenarios where `#expect()` and `#require()` are insufficient. For
125+
/// example, a confirmation may be useful when an expected event occurs:
126+
///
127+
/// - In a context that cannot be awaited by the calling function such as an
128+
/// event handler or delegate callback;
129+
/// - More than once, or never; or
130+
/// - As a callback that is invoked as part of a larger operation.
131+
///
132+
/// To use a confirmation, pass a closure containing the work to be performed.
133+
/// The testing library will then pass an instance of ``Confirmation`` to the
134+
/// closure. Every time the event in question occurs, the closure should call
135+
/// the confirmation:
136+
///
137+
/// ```swift
138+
/// let minBuns = 5
139+
/// let maxBuns = 10
140+
/// await confirmation(
141+
/// "Baked between \(minBuns) and \(maxBuns) buns",
142+
/// expectedCount: minBuns ... maxBuns
143+
/// ) { bunBaked in
144+
/// foodTruck.eventHandler = { event in
145+
/// if event == .baked(.cinnamonBun) {
146+
/// bunBaked()
147+
/// }
148+
/// }
149+
/// await foodTruck.bakeTray(of: .cinnamonBun)
150+
/// }
151+
/// ```
152+
///
153+
/// When the closure returns, the testing library checks if the confirmation's
154+
/// preconditions have been met, and records an issue if they have not.
155+
///
156+
/// If an exact count is expected, use
157+
/// ``confirmation(_:expectedCount:sourceLocation:_:)-7kfko`` instead.
158+
@_spi(Experimental)
159+
public func confirmation<R>(
160+
_ comment: Comment? = nil,
161+
expectedCount: some RangeExpression<Int>,
162+
sourceLocation: SourceLocation = #_sourceLocation,
163+
_ body: (Confirmation) async throws -> R
99164
) async rethrows -> R {
100165
let confirmation = Confirmation()
101166
defer {
102167
let actualCount = confirmation.count.rawValue
103-
if actualCount != expectedCount {
168+
if !expectedCount.contains(actualCount) {
169+
var comment = comment
170+
let issueKind: Issue.Kind
171+
if let expectedCount = expectedCount as? ClosedRange<Int>,
172+
expectedCount.lowerBound == expectedCount.upperBound {
173+
issueKind = .confirmationMiscounted(actual: actualCount, expected: expectedCount.lowerBound)
174+
} else {
175+
// TODO: define an issue kind for out-of-range confirmation failures
176+
issueKind = .unconditional
177+
comment = if let comment {
178+
"\(comment) - expected \(expectedCount) confirmations"
179+
} else {
180+
"expected \(expectedCount) confirmations"
181+
}
182+
}
104183
Issue.record(
105-
.confirmationMiscounted(actual: actualCount, expected: expectedCount),
184+
issueKind,
106185
comments: Array(comment),
107186
backtrace: .current(),
108187
sourceLocation: sourceLocation

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
@@ -321,7 +327,7 @@ extension Runner {
321327
}
322328

323329
await Configuration.withCurrent(runner.configuration) {
324-
Event.post(.runStarted(runner.plan), for: nil, testCase: nil, configuration: runner.configuration)
330+
Event.post(.runStarted, for: nil, testCase: nil, configuration: runner.configuration)
325331
defer {
326332
Event.post(.runEnded, for: nil, testCase: nil, configuration: runner.configuration)
327333
}

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,

0 commit comments

Comments
 (0)