diff --git a/Sources/Testing/CMakeLists.txt b/Sources/Testing/CMakeLists.txt index 5b84aeaf3..3c5915619 100644 --- a/Sources/Testing/CMakeLists.txt +++ b/Sources/Testing/CMakeLists.txt @@ -58,6 +58,7 @@ add_library(Testing Running/Runner.Plan+Dumping.swift Running/Runner.RuntimeState.swift Running/Runner.swift + Running/Serializer.swift Running/SkipInfo.swift SourceAttribution/Backtrace.swift SourceAttribution/Backtrace+Symbolication.swift diff --git a/Sources/Testing/Running/Runner.Plan.swift b/Sources/Testing/Running/Runner.Plan.swift index c89fdecb5..2fe87dde7 100644 --- a/Sources/Testing/Running/Runner.Plan.swift +++ b/Sources/Testing/Running/Runner.Plan.swift @@ -147,6 +147,36 @@ extension Runner.Plan { } } + /// Recursively deduplicate traits on the given test by calling + /// ``ReducibleTrait/reduce(_:)`` across all nodes in the graph. + /// + /// - Parameters: + /// - testGraph: The graph of tests to modify. + private static func _recursivelyReduceTraits(in testGraph: inout Graph) { + if var test = testGraph.value { + // O(n^2), but we expect n to be small, right? + test.traits = test.traits.reduce(into: []) { traits, trait in + for i in traits.indices { + let other = traits[i] + if let replacement = trait._reduce(into: other) { + traits[i] = replacement + return + } + } + + // The trait wasn't reduced into any other traits, so preserve it. + traits.append(trait) + } + testGraph.value = test + } + + testGraph.children = testGraph.children.mapValues { child in + var child = child + _recursivelyReduceTraits(in: &child) + return child + } + } + /// Recursively synthesize test instances representing suites for all missing /// values in the specified test graph. /// @@ -250,6 +280,9 @@ extension Runner.Plan { // filtered out. _recursivelyApplyTraits(to: &testGraph) + // Recursively reduce traits in the graph. + _recursivelyReduceTraits(in: &testGraph) + // For each test value, determine the appropriate action for it. // // FIXME: Parallelize this work. Calling `prepare(...)` on all traits and diff --git a/Sources/Testing/Running/Runner.swift b/Sources/Testing/Running/Runner.swift index bd1167b8e..f752942b9 100644 --- a/Sources/Testing/Running/Runner.swift +++ b/Sources/Testing/Running/Runner.swift @@ -171,6 +171,60 @@ extension Runner { } } + /// Post `testStarted` and `testEnded` (or `testSkipped`) events for the test + /// at the given plan step. + /// + /// - Parameters: + /// - step: The plan step for which events should be posted. + /// - configuration: The configuration to use for running. + /// - body: A function to execute between the started/ended events. + /// + /// - Throws: Whatever is thrown by `body` or while handling any issues + /// recorded in the process. + /// + /// - Returns: Whatever is returned by `body`. + /// + /// This function does _not_ post the `planStepStarted` and `planStepEnded` + /// events. + private static func _postingTestStartedAndEndedEvents(for step: Plan.Step, configuration: Configuration, _ body: @Sendable () async throws -> R) async throws -> R { + // Whether to send a `.testEnded` event at the end of running this step. + // Some steps' actions may not require a final event to be sent — for + // example, a skip event only sends `.testSkipped`. + let shouldSendTestEnded: Bool + + // Determine what kind of event to send for this step based on its action. + switch step.action { + case .run: + Event.post(.testStarted, for: (step.test, nil), configuration: configuration) + shouldSendTestEnded = true + case let .skip(skipInfo): + Event.post(.testSkipped(skipInfo), for: (step.test, nil), configuration: configuration) + shouldSendTestEnded = false + case let .recordIssue(issue): + // Scope posting the issue recorded event such that issue handling + // traits have the opportunity to handle it. This ensures that if a test + // has an issue handling trait _and_ some other trait which caused an + // issue to be recorded, the issue handling trait can process the issue + // even though it wasn't recorded by the test function. + try await Test.withCurrent(step.test) { + try await _applyIssueHandlingTraits(for: step.test) { + // Don't specify `configuration` when posting this issue so that + // traits can provide scope and potentially customize the + // configuration. + Event.post(.issueRecorded(issue), for: (step.test, nil)) + } + } + shouldSendTestEnded = false + } + defer { + if shouldSendTestEnded { + Event.post(.testEnded, for: (step.test, nil), configuration: configuration) + } + } + + return try await body() + } + /// Run this test. /// /// - Parameters: @@ -193,64 +247,34 @@ extension Runner { // Exit early if the task has already been cancelled. try Task.checkCancellation() - // Whether to send a `.testEnded` event at the end of running this step. - // Some steps' actions may not require a final event to be sent — for - // example, a skip event only sends `.testSkipped`. - let shouldSendTestEnded: Bool - - let configuration = _configuration - - // Determine what action to take for this step. if let step = stepGraph.value { + let configuration = _configuration Event.post(.planStepStarted(step), for: (step.test, nil), configuration: configuration) - - // Determine what kind of event to send for this step based on its action. - switch step.action { - case .run: - Event.post(.testStarted, for: (step.test, nil), configuration: configuration) - shouldSendTestEnded = true - case let .skip(skipInfo): - Event.post(.testSkipped(skipInfo), for: (step.test, nil), configuration: configuration) - shouldSendTestEnded = false - case let .recordIssue(issue): - // Scope posting the issue recorded event such that issue handling - // traits have the opportunity to handle it. This ensures that if a test - // has an issue handling trait _and_ some other trait which caused an - // issue to be recorded, the issue handling trait can process the issue - // even though it wasn't recorded by the test function. - try await Test.withCurrent(step.test) { - try await _applyIssueHandlingTraits(for: step.test) { - // Don't specify `configuration` when posting this issue so that - // traits can provide scope and potentially customize the - // configuration. - Event.post(.issueRecorded(issue), for: (step.test, nil)) - } - } - shouldSendTestEnded = false - } - } else { - shouldSendTestEnded = false - } - defer { - if let step = stepGraph.value { - if shouldSendTestEnded { - Event.post(.testEnded, for: (step.test, nil), configuration: configuration) - } + defer { Event.post(.planStepEnded(step), for: (step.test, nil), configuration: configuration) } - } - if let step = stepGraph.value, case .run = step.action { await Test.withCurrent(step.test) { _ = await Issue.withErrorRecording(at: step.test.sourceLocation, configuration: configuration) { - try await _applyScopingTraits(for: step.test, testCase: nil) { - // Run the test function at this step (if one is present.) - if let testCases = step.test.testCases { - try await _runTestCases(testCases, within: step) + switch step.action { + case .run: + try await _applyScopingTraits(for: step.test, testCase: nil) { + try await _postingTestStartedAndEndedEvents(for: step, configuration: configuration) { + // Run the test function at this step (if one is present.) + if let testCases = step.test.testCases { + try await _runTestCases(testCases, within: step) + } + + // Run the children of this test (i.e. the tests in this suite.) + try await _runChildren(of: stepGraph) + } + } + default: + // Skipping this step or otherwise not running it. Post appropriate + // started/ended events for the test and walk any child nodes. + try await _postingTestStartedAndEndedEvents(for: step, configuration: configuration) { + try await _runChildren(of: stepGraph) } - - // Run the children of this test (i.e. the tests in this suite.) - try await _runChildren(of: stepGraph) } } } diff --git a/Sources/Testing/Running/Serializer.swift b/Sources/Testing/Running/Serializer.swift new file mode 100644 index 000000000..08f135da7 --- /dev/null +++ b/Sources/Testing/Running/Serializer.swift @@ -0,0 +1,62 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +/// A type whose instances can run a series of work items in strict order. +/// +/// When a work item is scheduled on an instance of this type, it runs after any +/// previously-scheduled work items. If it suspends, subsequently-scheduled work +/// items do not start running; they must wait until the suspended work item +/// either returns or throws an error. +final actor Serializer { + /// The number of scheduled work items, including (possibly) the one currently + /// running. + private var scheduledCount = 0 + + /// Continuations for any scheduled work items that haven't started yet. + private var continuations = [CheckedContinuation]() + + /// Run a work item serially after any previously-scheduled work items. + /// + /// - Parameters: + /// - workItem: A closure to run. + /// + /// - Returns: Whatever is returned from `workItem`. + /// + /// - Throws: Whatever is thrown by `workItem`. + /// + /// - Warning: Calling this function recursively on the same instance of + /// ``Serializer`` will cause a deadlock. + func run(_ workItem: @Sendable () async throws -> R) async rethrows -> R { + scheduledCount += 1 + defer { + // Resume the next scheduled closure. + if !continuations.isEmpty { + let continuation = continuations.removeFirst() + continuation.resume() + } + + scheduledCount -= 1 + } + + await withCheckedContinuation { continuation in + if scheduledCount == 1 { + // Nothing else was scheduled, so we can resume immediately. + continuation.resume() + } else { + // Something was scheduled, so add the continuation to the list. When it + // resumes, we can run. + continuations.append(continuation) + } + } + + return try await workItem() + } +} + diff --git a/Sources/Testing/Support/Environment.swift b/Sources/Testing/Support/Environment.swift index 2ab3710a4..89787e299 100644 --- a/Sources/Testing/Support/Environment.swift +++ b/Sources/Testing/Support/Environment.swift @@ -89,24 +89,41 @@ enum Environment { }() #endif + /// The address of the environment block, if available. + /// + /// The value of this property is always `nil` on Windows and on platforms + /// that do not support environment variables. + static var unsafeAddress: UnsafeMutablePointer?>? { +#if SWT_NO_ENVIRONMENT_VARIABLES + nil +#elseif SWT_TARGET_OS_APPLE + _NSGetEnviron()?.pointee +#elseif os(Linux) || os(FreeBSD) || os(OpenBSD) || os(Android) + swt_environ() +#elseif os(WASI) + __wasilibc_get_environ() +#elseif os(Windows) + nil +#else +#warning("Platform-specific implementation missing: environment variables unavailable") + nil +#endif + } + /// Get all environment variables in the current process. /// /// - Returns: A copy of the current process' environment dictionary. static func get() -> [String: String] { #if SWT_NO_ENVIRONMENT_VARIABLES simulatedEnvironment.rawValue -#elseif SWT_TARGET_OS_APPLE -#if !SWT_NO_DYNAMIC_LINKING +#elseif SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD) || os(Android) || os(WASI) +#if SWT_TARGET_OS_APPLE && !SWT_NO_DYNAMIC_LINKING _environ_lock_np?() defer { _environ_unlock_np?() } #endif - return _get(fromEnviron: _NSGetEnviron()!.pointee!) -#elseif os(Linux) || os(FreeBSD) || os(OpenBSD) || os(Android) - _get(fromEnviron: swt_environ()) -#elseif os(WASI) - _get(fromEnviron: __wasilibc_get_environ()) + return _get(fromEnviron: Self.unsafeAddress!) #elseif os(Windows) guard let environ = GetEnvironmentStringsW() else { return [:] @@ -153,7 +170,9 @@ enum Environment { defer { _environ_unlock_np?() } - let environ = _NSGetEnviron()!.pointee! + guard let environ = Self.unsafeAddress else { + return nil + } return name.withCString { name in for i in 0... { diff --git a/Sources/Testing/Testing.docc/Parallelization.md b/Sources/Testing/Testing.docc/Parallelization.md index 7b3e4e2a1..970ce78ae 100644 --- a/Sources/Testing/Testing.docc/Parallelization.md +++ b/Sources/Testing/Testing.docc/Parallelization.md @@ -19,6 +19,8 @@ accomplished by the testing library using task groups, and tests generally all run in the same process. The number of tests that run concurrently is controlled by the Swift runtime. + + ## Disabling parallelization Parallelization can be disabled on a per-function or per-suite basis using the diff --git a/Sources/Testing/Traits/ParallelizationTrait.swift b/Sources/Testing/Traits/ParallelizationTrait.swift index c91e01761..baba867df 100644 --- a/Sources/Testing/Traits/ParallelizationTrait.swift +++ b/Sources/Testing/Traits/ParallelizationTrait.swift @@ -8,6 +8,14 @@ // See https://swift.org/CONTRIBUTORS.txt for Swift project authors // +private import _TestingInternals + +#if canImport(Foundation) && _runtime(_ObjC) +private import ObjectiveC +#endif + +// TODO: update this documentation to clarify .serialized vs. .serialized(for:) + /// A type that defines whether the testing library runs this test serially /// or in parallel. /// @@ -26,7 +34,113 @@ /// `swift test` command.) /// /// To add this trait to a test, use ``Trait/serialized``. -public struct ParallelizationTrait: TestTrait, SuiteTrait {} +public struct ParallelizationTrait: TestTrait, SuiteTrait { + /// A type that describes a data-based dependency that a test may have. + /// + /// When a test has a dependency, the testing library assumes it cannot run at + /// the same time as other tests with the same dependency. + @_spi(Experimental) + public struct Dependency: Sendable { + /// An enumeration describing the supported kinds of dependencies. + enum Kind: Equatable, Hashable { + /// An unbounded dependency. + case unbounded + + /// A dependency on all or part of the process' environment block. + case environ + + /// A dependency on a given key path. + /// + /// This case is used when a test author writes `.serialized(for: T.self)` + /// because key paths are equatable and hashable, but metatypes are not. + /// + /// - Note: Currently, we only provide an interface to describe a Swift + /// type. If the standard library adds API to decompose a key path, we + /// can support other kinds of key paths. + case keyPath(AnyKeyPath) + + /// A dependency on an address in memory. + case address(UnsafeMutableRawPointer) + + /// A dependency on a tag. + case tag(Tag) + } + + /// The kind of this dependency. + nonisolated(unsafe) var kind: Kind + } + + /// This instance's dependency, if any. + /// + /// If the value of this property is `nil`, it is the otherwise-unspecialized + /// ``serialized`` trait. + var dependency: Dependency? + + /// A mapping of dependencies to serializers. + private static nonisolated(unsafe) let _serializers = Locked<[Dependency.Kind: Serializer]>() +} + +// MARK: - Parallelization over a dependency + +extension ParallelizationTrait { + public var isRecursive: Bool { + // If the trait has a dependency, apply it to child tests/suites so that + // they are able to "see" parent suites' dependencies and correctly account + // for them. + dependency != nil + } + + public func prepare(for test: Test) async throws { + guard let dependency else { + return + } + + // Ensure a serializer has been created for this trait's dependency (except + // .unbounded which is special-cased.) + let kind = dependency.kind + if kind != .unbounded { + Self._serializers.withLock { serializers in + if serializers[kind] == nil { + serializers[kind] = Serializer() + } + } + } + } + + public func _reduce(into other: any Trait) -> (any Trait)? { + guard var other = other as? Self else { + // The other trait is not a ParallelizationTrait instance, so ignore it. + return nil + } + + let selfKind = dependency?.kind + let otherKind = other.dependency?.kind + + switch (selfKind, otherKind) { + case (.none, .none), + (.some, .some) where selfKind == otherKind: + // Both traits have equivalent (or no) dependencies. Use the other trait + // and discard this one. + break + case (.some, .some): + // The two traits have different dependencies. Combine them into a single + // .unbounded dependency. + other = .serialized(for: *) + case (.some, .none): + // This trait specifies a dependency, but the other one does not. Use this + // trait and discard the other one. + other = self + case (.none, .some): + // The other trait specifies a dependency, but this one does not. Use the + // other trait and discard this one. + break + } + + // NOTE: We always reduce to a single ParallelizationTrait instance, so this + // function always returns the other instance. + return other + } +} // MARK: - TestScoping @@ -45,7 +159,47 @@ extension ParallelizationTrait: TestScoping { } configuration.isParallelizationEnabled = false - try await Configuration.withCurrent(configuration, perform: function) + try await Configuration.withCurrent(configuration) { + if test.isSuite { + // Suites do not need to use a serializer since they don't run their own + // code. Test functions within the suite will use serializers as needed. + return try await function() + } + guard let dependency else { + // This trait does not specify a dependency to serialize for. + return try await function() + } + + switch dependency.kind { + case .unbounded: + try await withoutActuallyEscaping(function) { function in + // The function we're running depends on all global state, so it + // should be serialized by all serializers that were created by + // prepare(). See Runner._applyScopingTraits() for an explanation of + // what this code does. + // TODO: share an implementation with that function? + let function = Self._serializers.rawValue.values.lazy + .reduce(function) { function, serializer in + { + try await serializer.run { + try await function() + } + } + } + try await function() + } + case let kind: + // This test function has declared a single dependency, so fetch the + // serializer for that dependency and run the test in serial with any + // other tests that have the same dependency. + let serializer = Self._serializers.withLock { serializers in + serializers[kind]! + } + try await serializer.run { + try await function() + } + } + } } } @@ -61,3 +215,221 @@ extension Trait where Self == ParallelizationTrait { Self() } } + +@_spi(Experimental) +extension Trait where Self == ParallelizationTrait { + /// Constructs a trait that describes a dependency on a Swift type. + /// + /// - Parameters: + /// - type: The type of interest. + /// + /// - Returns: An instance of ``ParallelizationTrait`` that adds a dependency + /// on `type` to any test it is applied to. + /// + /// Use this trait when you write a test function is dependent on global + /// mutable state contained within `type`: + /// + /// ```swift + /// import Foundation + /// + /// @Test(.serialized(for: ProcessInfo.self)) + /// func `HAS_FREEZER environment variable`() { + /// _ = setenv("HAS_FREEZER", "1", 1) + /// #expect(FoodTruck.hasFreezer) + /// _ = setenv("HAS_FREEZER", "0", 1) + /// #expect(!FoodTruck.hasFreezer) + /// } + /// ``` + /// + /// ## See Also + /// + /// - ``ParallelizationTrait`` + public static func serialized(for type: T.Type) -> Self { + var isEnvironment = false +#if canImport(Foundation) + var processInfoClass: AnyClass? +#if _runtime(_ObjC) + processInfoClass = objc_getClass("NSProcessInfo") as? AnyClass +#else + processInfoClass = _typeByName("20FoundationEssentials11ProcessInfoC") as? AnyClass +#endif + if let type = type as? AnyClass, let processInfoClass, isClass(type, subclassOf: processInfoClass) { + // Assume that all accesses to `ProcessInfo` are accessing the environment + // block (as the only mutable state it contains.) + isEnvironment = true + } +#endif +#if DEBUG + isEnvironment = isEnvironment || type == Environment.self +#endif + + if isEnvironment { + return Self(dependency: .init(kind: .environ)) + } else { + return Self(dependency: .init(kind: .keyPath(\T.self))) + } + } + + /// Constructs a trait that describes a dependency on an address in memory. + /// + /// - Parameters: + /// - address: The address of the dependency. + /// + /// - Returns: An instance of ``ParallelizationTrait`` that adds a dependency + /// on `address` to any test it is applied to. + /// + /// Use this trait when you write a test function is dependent on global + /// mutable state that must be accessed using an unsafe pointer: + /// + /// ```swift + /// import Darwin + /// + /// @Test(.serialized(for: environ)) + /// func `HAS_FREEZER environment variable`() { + /// _ = setenv("HAS_FREEZER", "1", 1) + /// #expect(FoodTruck.hasFreezer) + /// _ = setenv("HAS_FREEZER", "0", 1) + /// #expect(!FoodTruck.hasFreezer) + /// } + /// ``` + /// + /// - Note: When compiling with [strict memory safety checking](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/memorysafety/) + /// enabled, you must use the `unsafe` keyword when adding a dependency on + /// an address in memory. + /// + /// ## See Also + /// + /// - ``ParallelizationTrait`` + @unsafe public static func serialized(@_nonEphemeral for address: UnsafeMutableRawPointer) -> Self { + var isEnvironment = false + if let environ = Environment.unsafeAddress, address == environ { + isEnvironment = true + } +#if !SWT_NO_ENVIRONMENT_VARIABLES && SWT_TARGET_OS_APPLE + isEnvironment = isEnvironment || address == _NSGetEnviron() +#endif + if isEnvironment { + return Self(dependency: .init(kind: .environ)) + } else { + return Self(dependency: .init(kind: .address(UnsafeMutableRawPointer(address)))) + } + } + + @available(*, unavailable, message: "Pointers passed to 'serialized(for:)' must be mutable") + @_documentation(visibility: private) + @unsafe public static func serialized(@_nonEphemeral for address: UnsafeRawPointer) -> Self { + swt_unreachable() + } + + /// Constructs a trait that describes a dependency on a tag. + /// + /// - Parameters: + /// - tag: The tag representing the dependency. + /// + /// - Returns: An instance of ``ParallelizationTrait`` that adds a dependency + /// on `tag` to any test it is applied to. + /// + /// Use this trait when you write a test function is dependent on global + /// mutable state and you want to track that state using a tag: + /// + /// ```swift + /// import Foundation + /// + /// extension Tag { + /// @Tag static var freezer: Self + /// } + /// + /// @Test(.serialized(for: .freezer)) + /// func `Freezer door works`() { + /// let freezer = FoodTruck.shared.freezer + /// freezer.openDoor() + /// #expect(freezer.isOpen) + /// freezer.closeDoor() + /// #expect(!freezer.isOpen) + /// } + /// ``` + /// + /// - Note: If you add `tag` to a test using the ``Trait/tags(_:)`` trait, + /// that test does not automatically become serialized. + /// + /// ## See Also + /// + /// - ``ParallelizationTrait`` + /// - ``Tag`` + public static func serialized(for tag: Tag) -> Self { + Self(dependency: .init(kind: .tag(tag))) + } +} + +// MARK: - Unbounded dependencies (*) + +@_spi(Experimental) +extension ParallelizationTrait.Dependency { + /// A dependency. + /// + /// An unbounded dependency is a dependency on the complete state of the + /// current process. To specify an unbounded dependency when using + /// ``Trait/serialized(for:)-(Self.Unbounded)``, pass a reference + /// to this function: + /// + /// ```swift + /// @Test(.serialized(for: *)) + /// func `All food truck environment variables`() { ... } + /// ``` + /// + /// If a test has more than one dependency, the testing library automatically + /// treats it as if it is dependent on the program's complete state. + @_documentation(visibility: private) + public static func *(_: Self, _: Never) {} + + /// A type describing unbounded dependencies. + /// + /// An unbounded dependency is a dependency on the complete state of the + /// current process. To specify an unbounded dependency when using + /// ``Trait/serialized(for:)-(Self.Dependency.Unbounded)``, pass a reference + /// to the `*` operator: + /// + /// ```swift + /// @Test(.serialized(for: *)) + /// func `All food truck environment variables`() { ... } + /// ``` + /// + /// If a test has more than one dependency, the testing library automatically + /// treats it as if it is dependent on the program's complete state. + public typealias Unbounded = (Self, Never) -> Void +} + +@_spi(Experimental) +extension Trait where Self == ParallelizationTrait { + /// Constructs a trait that describes a dependency on the complete state of + /// the current process. + /// + /// - Returns: An instance of ``ParallelizationTrait`` that adds a dependency + /// on the complete state of the current process to any test it is applied + /// to. + /// + /// Pass `*` to this trait when you write a test function is dependent on + /// global mutable state in the current process that cannot be fully described + /// or that isn't known at compile time: + /// + /// ```swift + /// @Test(.serialized(for: *)) + /// func `All food truck environment variables`() { ... } + /// ``` + /// + /// If a test has more than one dependency, the testing library automatically + /// treats it as if it is dependent on the program's complete state. + /// + /// ## See Also + /// + /// - ``ParallelizationTrait`` + public static func serialized(for _: Self.Dependency.Unbounded) -> Self { + Self(dependency: .init(kind: .unbounded)) + } + + @available(*, unavailable, message: "Pass a Swift type, a pointer to mutable global state, or '*' instead") + @_documentation(visibility: private) + public static func serialized(for _: borrowing T) -> Self where T: ~Copyable & ~Escapable { + swt_unreachable() + } +} diff --git a/Sources/Testing/Traits/Trait.swift b/Sources/Testing/Traits/Trait.swift index 70aafe90e..ba0dd516f 100644 --- a/Sources/Testing/Traits/Trait.swift +++ b/Sources/Testing/Traits/Trait.swift @@ -40,6 +40,24 @@ public protocol Trait: Sendable { /// The default implementation of this method does nothing. func prepare(for test: Test) async throws + /// Combine this trait with another instance of the same trait type. + /// + /// - Parameters: + /// - other: Another instance of this trait's type. + /// + /// - Returns: A single trait combining `other` and `self`. If `nil`, the two + /// traits were not combined. + /// + /// This function allows traits with duplicate or overlapping information to + /// be reduced into a smaller set of traits. The default implementation + /// returns `nil` and does not modify `other` or `self`. + /// + /// This function is called after the testing library applies recursive traits + /// (those whose ``SuiteTrait/isRecursive`` properties have the value `true`) + /// to child suites and test functions. + @_spi(Experimental) + func _reduce(into other: any Trait) -> (any Trait)? + /// The user-provided comments for this trait. /// /// The default value of this property is an empty array. @@ -252,6 +270,12 @@ public protocol SuiteTrait: Trait { extension Trait { public func prepare(for test: Test) async throws {} + /// - Warning: This function is experimental. It is publicly visible due to + /// Swift language requirements. It may be removed in a future update. + public func _reduce(into other: any Trait) -> (any Trait)? { + nil + } + public var comments: [Comment] { [] } diff --git a/Tests/TestingTests/Support/EnvironmentTests.swift b/Tests/TestingTests/Support/EnvironmentTests.swift index 43d8ea3f3..f043e3f4a 100644 --- a/Tests/TestingTests/Support/EnvironmentTests.swift +++ b/Tests/TestingTests/Support/EnvironmentTests.swift @@ -11,7 +11,7 @@ @testable @_spi(Experimental) import Testing private import _TestingInternals -@Suite("Environment Tests", .serialized) +@Suite("Environment Tests", .serialized(for: Environment.self)) struct EnvironmentTests { var name = "SWT_ENVIRONMENT_VARIABLE_FOR_TESTING" diff --git a/Tests/TestingTests/Traits/ParallelizationTraitTests.swift b/Tests/TestingTests/Traits/ParallelizationTraitTests.swift index 6c4963dc5..71a056384 100644 --- a/Tests/TestingTests/Traits/ParallelizationTraitTests.swift +++ b/Tests/TestingTests/Traits/ParallelizationTraitTests.swift @@ -9,6 +9,11 @@ // @testable @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing +private import _TestingInternals + +#if canImport(Foundation) +import Foundation +#endif @Suite("Parallelization Trait Tests", .tags(.traitRelated)) struct ParallelizationTraitTests { @@ -46,6 +51,107 @@ struct ParallelizationTraitTests { } } +// MARK: - + +@Suite("Parallelization Trait Tests with Dependencies") +struct ParallelizationTraitTestsWithDependencies { + func dependency() throws -> ParallelizationTrait.Dependency.Kind { + let traits = try #require(Test.current?.traits.compactMap { $0 as? ParallelizationTrait }) + try #require(traits.count == 1) + return try #require(traits[0].dependency?.kind) + } + + @Test(.serialized(for: Dependency1.self)) + func type() throws { + let dependency = try dependency() + #expect(dependency == .keyPath(\Dependency1.self)) + } + + @Test(.serialized(for: Dependency1.self), .serialized(for: Dependency1.self)) + func duplicates() throws { + let dependency = try dependency() + #expect(dependency == .keyPath(\Dependency1.self)) + } + + @Test(.serialized(for: Dependency1.self), .serialized(for: Dependency2.self)) + func multiple() throws { + let dependency = try dependency() + #expect(dependency == .unbounded) + } + + @Test(.serialized(for: Dependency1.self), .serialized, arguments: [0]) + func mixedDependencyAndNot(_: Int) throws { + let dependency = try dependency() + #expect(dependency == .keyPath(\Dependency1.self)) + } + + @Test(.serialized, .serialized(for: Dependency1.self), arguments: [0]) + func mixedNotAndDependency(_: Int) throws { + let dependency = try dependency() + #expect(dependency == .keyPath(\Dependency1.self)) + } + + @Test(unsafe .serialized(for: dependency3)) + func pointer() throws { + let dependency = try dependency() + #expect(dependency == .address(dependency3)) + } + + @Test(unsafe .serialized(for: dependency3), unsafe .serialized(for: dependency4)) + func multiplePointers() throws { + let dependency = try dependency() + #expect(dependency == .unbounded) + } + + @Test(.serialized(for: .tagDependency)) + func tag() throws { + let dependency = try dependency() + #expect(dependency == .tag(.tagDependency)) + } + + @Test(.serialized(for: Environment.self)) + func environment() throws { + let dependency = try dependency() + #expect(dependency == .environ) + } + +#if !SWT_NO_ENVIRONMENT_VARIABLES +#if canImport(Foundation) + @Test(.serialized(for: ProcessInfo.self)) + func foundationEnvironment() throws { + let dependency = try dependency() + #expect(dependency == .environ) + } +#endif + +#if SWT_TARGET_OS_APPLE + @Test(unsafe .serialized(for: _NSGetEnviron())) + func appleCRTEnvironOuterPointer() throws { + let dependency = try dependency() + #expect(dependency == .environ) + } + + @Test(unsafe .serialized(for: _NSGetEnviron()!.pointee!)) + func appleCRTEnviron() throws { + let dependency = try dependency() + #expect(dependency == .environ) + } +#elseif os(Linux) || os(FreeBSD) || os(OpenBSD) || os(Android) + @Test(unsafe .serialized(for: swt_environ())) + func posixEnviron() throws { + let dependency = try dependency() + #expect(dependency == .environ) + } +#elseif os(WASI) + @Test(unsafe .serialized(for: __wasilibc_get_environ())) + func wasiEnviron() throws { + let dependency = try dependency() + #expect(dependency == .environ) + } +#endif +#endif +} + // MARK: - Fixtures @Suite(.hidden, .serialized) @@ -66,3 +172,18 @@ private struct OuterSuite { private func globalParameterized(i: Int) { Issue.record("PARAMETERIZED\(i)") } + +private struct Dependency1 { + var x = 0 + var y = 0 +} + +private struct Dependency2 {} + +private nonisolated(unsafe) let dependency3 = UnsafeMutablePointer.allocate(capacity: 1) + +private nonisolated(unsafe) let dependency4 = UnsafeMutablePointer.allocate(capacity: 1) + +extension Tag { + @Tag fileprivate static var tagDependency: Self +}