Skip to content

Commit eca0abb

Browse files
committed
[WIP] Dependency traits
1 parent bc74ac0 commit eca0abb

File tree

10 files changed

+671
-61
lines changed

10 files changed

+671
-61
lines changed

Sources/Testing/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ add_library(Testing
5858
Running/Runner.Plan+Dumping.swift
5959
Running/Runner.RuntimeState.swift
6060
Running/Runner.swift
61+
Running/Serializer.swift
6162
Running/SkipInfo.swift
6263
SourceAttribution/Backtrace.swift
6364
SourceAttribution/Backtrace+Symbolication.swift

Sources/Testing/Running/Runner.Plan.swift

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,36 @@ extension Runner.Plan {
147147
}
148148
}
149149

150+
/// Recursively deduplicate traits on the given test by calling
151+
/// ``ReducibleTrait/reduce(_:)`` across all nodes in the graph.
152+
///
153+
/// - Parameters:
154+
/// - testGraph: The graph of tests to modify.
155+
private static func _recursivelyReduceTraits(in testGraph: inout Graph<String, Test?>) {
156+
if var test = testGraph.value {
157+
// O(n^2), but we expect n to be small, right?
158+
test.traits = test.traits.reduce(into: []) { traits, trait in
159+
for i in traits.indices {
160+
let other = traits[i]
161+
if let replacement = trait._reduce(into: other) {
162+
traits[i] = replacement
163+
return
164+
}
165+
}
166+
167+
// The trait wasn't reduced into any other traits, so preserve it.
168+
traits.append(trait)
169+
}
170+
testGraph.value = test
171+
}
172+
173+
testGraph.children = testGraph.children.mapValues { child in
174+
var child = child
175+
_recursivelyReduceTraits(in: &child)
176+
return child
177+
}
178+
}
179+
150180
/// Recursively synthesize test instances representing suites for all missing
151181
/// values in the specified test graph.
152182
///
@@ -250,6 +280,9 @@ extension Runner.Plan {
250280
// filtered out.
251281
_recursivelyApplyTraits(to: &testGraph)
252282

283+
// Recursively reduce traits in the graph.
284+
_recursivelyReduceTraits(in: &testGraph)
285+
253286
// For each test value, determine the appropriate action for it.
254287
//
255288
// FIXME: Parallelize this work. Calling `prepare(...)` on all traits and

Sources/Testing/Running/Runner.swift

Lines changed: 74 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,60 @@ extension Runner {
171171
}
172172
}
173173

174+
/// Post `testStarted` and `testEnded` (or `testSkipped`) events for the test
175+
/// at the given plan step.
176+
///
177+
/// - Parameters:
178+
/// - step: The plan step for which events should be posted.
179+
/// - configuration: The configuration to use for running.
180+
/// - body: A function to execute between the started/ended events.
181+
///
182+
/// - Throws: Whatever is thrown by `body` or while handling any issues
183+
/// recorded in the process.
184+
///
185+
/// - Returns: Whatever is returned by `body`.
186+
///
187+
/// This function does _not_ post the `planStepStarted` and `planStepEnded`
188+
/// events.
189+
private static func _postingTestStartedAndEndedEvents<R>(for step: Plan.Step, configuration: Configuration, _ body: @Sendable () async throws -> R) async throws -> R {
190+
// Whether to send a `.testEnded` event at the end of running this step.
191+
// Some steps' actions may not require a final event to be sent — for
192+
// example, a skip event only sends `.testSkipped`.
193+
let shouldSendTestEnded: Bool
194+
195+
// Determine what kind of event to send for this step based on its action.
196+
switch step.action {
197+
case .run:
198+
Event.post(.testStarted, for: (step.test, nil), configuration: configuration)
199+
shouldSendTestEnded = true
200+
case let .skip(skipInfo):
201+
Event.post(.testSkipped(skipInfo), for: (step.test, nil), configuration: configuration)
202+
shouldSendTestEnded = false
203+
case let .recordIssue(issue):
204+
// Scope posting the issue recorded event such that issue handling
205+
// traits have the opportunity to handle it. This ensures that if a test
206+
// has an issue handling trait _and_ some other trait which caused an
207+
// issue to be recorded, the issue handling trait can process the issue
208+
// even though it wasn't recorded by the test function.
209+
try await Test.withCurrent(step.test) {
210+
try await _applyIssueHandlingTraits(for: step.test) {
211+
// Don't specify `configuration` when posting this issue so that
212+
// traits can provide scope and potentially customize the
213+
// configuration.
214+
Event.post(.issueRecorded(issue), for: (step.test, nil))
215+
}
216+
}
217+
shouldSendTestEnded = false
218+
}
219+
defer {
220+
if shouldSendTestEnded {
221+
Event.post(.testEnded, for: (step.test, nil), configuration: configuration)
222+
}
223+
}
224+
225+
return try await body()
226+
}
227+
174228
/// Run this test.
175229
///
176230
/// - Parameters:
@@ -193,64 +247,34 @@ extension Runner {
193247
// Exit early if the task has already been cancelled.
194248
try Task.checkCancellation()
195249

196-
// Whether to send a `.testEnded` event at the end of running this step.
197-
// Some steps' actions may not require a final event to be sent — for
198-
// example, a skip event only sends `.testSkipped`.
199-
let shouldSendTestEnded: Bool
200-
201-
let configuration = _configuration
202-
203-
// Determine what action to take for this step.
204250
if let step = stepGraph.value {
251+
let configuration = _configuration
205252
Event.post(.planStepStarted(step), for: (step.test, nil), configuration: configuration)
206-
207-
// Determine what kind of event to send for this step based on its action.
208-
switch step.action {
209-
case .run:
210-
Event.post(.testStarted, for: (step.test, nil), configuration: configuration)
211-
shouldSendTestEnded = true
212-
case let .skip(skipInfo):
213-
Event.post(.testSkipped(skipInfo), for: (step.test, nil), configuration: configuration)
214-
shouldSendTestEnded = false
215-
case let .recordIssue(issue):
216-
// Scope posting the issue recorded event such that issue handling
217-
// traits have the opportunity to handle it. This ensures that if a test
218-
// has an issue handling trait _and_ some other trait which caused an
219-
// issue to be recorded, the issue handling trait can process the issue
220-
// even though it wasn't recorded by the test function.
221-
try await Test.withCurrent(step.test) {
222-
try await _applyIssueHandlingTraits(for: step.test) {
223-
// Don't specify `configuration` when posting this issue so that
224-
// traits can provide scope and potentially customize the
225-
// configuration.
226-
Event.post(.issueRecorded(issue), for: (step.test, nil))
227-
}
228-
}
229-
shouldSendTestEnded = false
230-
}
231-
} else {
232-
shouldSendTestEnded = false
233-
}
234-
defer {
235-
if let step = stepGraph.value {
236-
if shouldSendTestEnded {
237-
Event.post(.testEnded, for: (step.test, nil), configuration: configuration)
238-
}
253+
defer {
239254
Event.post(.planStepEnded(step), for: (step.test, nil), configuration: configuration)
240255
}
241-
}
242256

243-
if let step = stepGraph.value, case .run = step.action {
244257
await Test.withCurrent(step.test) {
245258
_ = await Issue.withErrorRecording(at: step.test.sourceLocation, configuration: configuration) {
246-
try await _applyScopingTraits(for: step.test, testCase: nil) {
247-
// Run the test function at this step (if one is present.)
248-
if let testCases = step.test.testCases {
249-
try await _runTestCases(testCases, within: step)
259+
switch step.action {
260+
case .run:
261+
try await _applyScopingTraits(for: step.test, testCase: nil) {
262+
try await _postingTestStartedAndEndedEvents(for: step, configuration: configuration) {
263+
// Run the test function at this step (if one is present.)
264+
if let testCases = step.test.testCases {
265+
try await _runTestCases(testCases, within: step)
266+
}
267+
268+
// Run the children of this test (i.e. the tests in this suite.)
269+
try await _runChildren(of: stepGraph)
270+
}
271+
}
272+
default:
273+
// Skipping this step or otherwise not running it. Post appropriate
274+
// started/ended events for the test and walk any child nodes.
275+
try await _postingTestStartedAndEndedEvents(for: step, configuration: configuration) {
276+
try await _runChildren(of: stepGraph)
250277
}
251-
252-
// Run the children of this test (i.e. the tests in this suite.)
253-
try await _runChildren(of: stepGraph)
254278
}
255279
}
256280
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
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+
/// A type whose instances can run a series of work items in strict order.
12+
///
13+
/// When a work item is scheduled on an instance of this type, it runs after any
14+
/// previously-scheduled work items. If it suspends, subsequently-scheduled work
15+
/// items do not start running; they must wait until the suspended work item
16+
/// either returns or throws an error.
17+
final actor Serializer {
18+
/// The number of scheduled work items, including (possibly) the one currently
19+
/// running.
20+
private var scheduledCount = 0
21+
22+
/// Continuations for any scheduled work items that haven't started yet.
23+
private var continuations = [CheckedContinuation<Void, Never>]()
24+
25+
/// Run a work item serially after any previously-scheduled work items.
26+
///
27+
/// - Parameters:
28+
/// - workItem: A closure to run.
29+
///
30+
/// - Returns: Whatever is returned from `workItem`.
31+
///
32+
/// - Throws: Whatever is thrown by `workItem`.
33+
///
34+
/// - Warning: Calling this function recursively on the same instance of
35+
/// ``Serializer`` will cause a deadlock.
36+
func run<R>(_ workItem: @Sendable () async throws -> R) async rethrows -> R {
37+
scheduledCount += 1
38+
defer {
39+
// Resume the next scheduled closure.
40+
if !continuations.isEmpty {
41+
let continuation = continuations.removeFirst()
42+
continuation.resume()
43+
}
44+
45+
scheduledCount -= 1
46+
}
47+
48+
await withCheckedContinuation { continuation in
49+
if scheduledCount == 1 {
50+
// Nothing else was scheduled, so we can resume immediately.
51+
continuation.resume()
52+
} else {
53+
// Something was scheduled, so add the continuation to the list. When it
54+
// resumes, we can run.
55+
continuations.append(continuation)
56+
}
57+
}
58+
59+
return try await workItem()
60+
}
61+
}
62+

Sources/Testing/Support/Environment.swift

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -89,24 +89,41 @@ enum Environment {
8989
}()
9090
#endif
9191

92+
/// The address of the environment block, if available.
93+
///
94+
/// The value of this property is always `nil` on Windows and on platforms
95+
/// that do not support environment variables.
96+
static var unsafeAddress: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>? {
97+
#if SWT_NO_ENVIRONMENT_VARIABLES
98+
nil
99+
#elseif SWT_TARGET_OS_APPLE
100+
_NSGetEnviron()?.pointee
101+
#elseif os(Linux) || os(FreeBSD) || os(OpenBSD) || os(Android)
102+
swt_environ()
103+
#elseif os(WASI)
104+
__wasilibc_get_environ()
105+
#elseif os(Windows)
106+
nil
107+
#else
108+
#warning("Platform-specific implementation missing: environment variables unavailable")
109+
nil
110+
#endif
111+
}
112+
92113
/// Get all environment variables in the current process.
93114
///
94115
/// - Returns: A copy of the current process' environment dictionary.
95116
static func get() -> [String: String] {
96117
#if SWT_NO_ENVIRONMENT_VARIABLES
97118
simulatedEnvironment.rawValue
98-
#elseif SWT_TARGET_OS_APPLE
99-
#if !SWT_NO_DYNAMIC_LINKING
119+
#elseif SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD) || os(Android) || os(WASI)
120+
#if SWT_TARGET_OS_APPLE && !SWT_NO_DYNAMIC_LINKING
100121
_environ_lock_np?()
101122
defer {
102123
_environ_unlock_np?()
103124
}
104125
#endif
105-
return _get(fromEnviron: _NSGetEnviron()!.pointee!)
106-
#elseif os(Linux) || os(FreeBSD) || os(OpenBSD) || os(Android)
107-
_get(fromEnviron: swt_environ())
108-
#elseif os(WASI)
109-
_get(fromEnviron: __wasilibc_get_environ())
126+
return _get(fromEnviron: Self.unsafeAddress!)
110127
#elseif os(Windows)
111128
guard let environ = GetEnvironmentStringsW() else {
112129
return [:]
@@ -153,7 +170,9 @@ enum Environment {
153170
defer {
154171
_environ_unlock_np?()
155172
}
156-
let environ = _NSGetEnviron()!.pointee!
173+
guard let environ = Self.unsafeAddress else {
174+
return nil
175+
}
157176

158177
return name.withCString { name in
159178
for i in 0... {

Sources/Testing/Testing.docc/Parallelization.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ accomplished by the testing library using task groups, and tests generally all
1919
run in the same process. The number of tests that run concurrently is controlled
2020
by the Swift runtime.
2121

22+
<!-- TODO: discuss .serialized(for:) -->
23+
2224
## Disabling parallelization
2325

2426
Parallelization can be disabled on a per-function or per-suite basis using the

0 commit comments

Comments
 (0)