diff --git a/Sources/Testing/ABI/Encoded/ABI.EncodedTest.swift b/Sources/Testing/ABI/Encoded/ABI.EncodedTest.swift index 5f4d6b09e..f26206bd1 100644 --- a/Sources/Testing/ABI/Encoded/ABI.EncodedTest.swift +++ b/Sources/Testing/ABI/Encoded/ABI.EncodedTest.swift @@ -261,7 +261,8 @@ extension Test { traits: traits, sourceLocation: sourceLocation, containingTypeInfo: typeInfo, - isSynthesized: true + isSynthesized: true, + isPolymorphic: false ) case .function: let parameters = test._parameters.map { parameters in diff --git a/Sources/Testing/CMakeLists.txt b/Sources/Testing/CMakeLists.txt index 1b087f62c..db32c92e0 100644 --- a/Sources/Testing/CMakeLists.txt +++ b/Sources/Testing/CMakeLists.txt @@ -102,6 +102,7 @@ add_library(Testing Support/JSON.swift Support/Serializer.swift Support/SHA256.swift + Support/SubclassCache.swift Support/VersionNumber.swift Support/Versions.swift Discovery+Macro.swift diff --git a/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift b/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift index 8d4277d75..9a1592026 100644 --- a/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift +++ b/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift @@ -209,7 +209,7 @@ extension Test { /// /// - Returns: The name of this test, suitable for display to the user. func humanReadableName(withVerbosity verbosity: Int = 0) -> String { - switch displayName { + var result = switch displayName { case let .some(displayName) where verbosity > 0: #""\#(displayName)" (aka '\#(name)')"# case let .some(displayName): @@ -217,6 +217,19 @@ extension Test { default: name } + if isPolymorphic, let clazz = containingTypeInfo { + let className = if verbosity > 0 { + clazz.fullyQualifiedName + } else { + clazz.unqualifiedName + } + if wasInherited { + result = "\(result) (inherited by '\(className)')" + } else { + result = "\(result) (implemented in '\(className)')" + } + } + return result } } diff --git a/Sources/Testing/Parameterization/TypeInfo.swift b/Sources/Testing/Parameterization/TypeInfo.swift index 1ad586f83..33e42c349 100644 --- a/Sources/Testing/Parameterization/TypeInfo.swift +++ b/Sources/Testing/Parameterization/TypeInfo.swift @@ -49,13 +49,25 @@ public struct TypeInfo: Sendable { return nil } + /// The described type, if available. + /// + /// The value of this property is `nil` if the described type is not a class, + /// as well as under any conditions where ``type`` is `nil`. + var `class`: AnyClass? { + if let type { + // FIXME: casting `any (~).Type` to `AnyClass` warns that it always fails + return unsafeBitCast(type, to: Any.Type.self) as? AnyClass + } + return nil + } + /// Initialize an instance of this type with the specified names. /// /// - Parameters: /// - fullyQualifiedNameComponents: The fully-qualified name components of - /// the type. + /// the type. /// - unqualifiedName: The unqualified name of the type. If `nil`, the last - /// string in `fullyQualifiedNameComponents` is used instead. + /// string in `fullyQualifiedNameComponents` is used instead. /// - mangled: The mangled name of the type, if available. init(fullyQualifiedNameComponents: [String], unqualifiedName: String? = nil, mangledName: String? = nil) { let unqualifiedName = unqualifiedName ?? fullyQualifiedNameComponents.last ?? fullyQualifiedNameComponents.joined(separator: ".") diff --git a/Sources/Testing/Running/Runner.Plan.swift b/Sources/Testing/Running/Runner.Plan.swift index 05d1b4f0e..3a80c02c4 100644 --- a/Sources/Testing/Running/Runner.Plan.swift +++ b/Sources/Testing/Running/Runner.Plan.swift @@ -185,7 +185,7 @@ extension Runner.Plan { // source location, so we use the source location of a close descendant // test. We do this instead of falling back to some "unknown" // placeholder in an attempt to preserve the correct sort ordering. - graph.value = Test(traits: [], sourceLocation: sourceLocation, containingTypeInfo: typeInfo, isSynthesized: true) + graph.value = Test(traits: [], sourceLocation: sourceLocation, containingTypeInfo: typeInfo, isSynthesized: true, isPolymorphic: false) } } @@ -193,6 +193,70 @@ extension Runner.Plan { synthesizeSuites(in: &graph, sourceLocation: &sourceLocation) } + /// Add copies of test functions in `@polymorphic` suites to all known + /// subclasses of said suites. + /// + /// - Parameters: + /// - testGraph: The graph of tests to modify. + private static func _synthesizePolymorphicTests(in testGraph: inout Graph) { + // First, recursively find all classes associated with polymorphic test + // suites (as determined at macro expansion time). + var subclassCache = SubclassCache( + testGraph + .compactMap { $0.value } + .filter(\.isPolymorphic) + .compactMap { $0.containingTypeInfo?.class } + ) + + // The set of all copied tests we created while recursing through the graph. + var testCopies = [Test]() + + // Recursively walk through the graph looking for test functions that are + // associated with classes in the set we created above. Any such test + // functions are implicitly polymorphic themselves. + func makePolymorphicCopies(in testGraph: inout Graph) { + if var test = testGraph.value.take() { + defer { + testGraph.value = test + } + + // If this test is a test function and its type is marked polymorphic, + // mark the test function as polymorphic too and make copies of it to + // insert into the graph after recursion is complete. + if !test.isSuite, + let clazz = test.containingTypeInfo?.class, + subclassCache.contains(clazz) { + test.isPolymorphic = true + testCopies += subclassCache.subclasses(of: clazz).lazy + .map { subclass in + let subtypeInfo = TypeInfo(describing: subclass) + var testCopy = test + testCopy.containingTypeInfo = subtypeInfo + if testCopy.isSuite { + testCopy.name = subtypeInfo.unqualifiedName + } + testCopy.isSynthesized = true + testCopy.wasInherited = true + return testCopy + } + } + } + + // Recurse into child nodes. + testGraph.children = testGraph.children.mapValues { child in + var child = child + makePolymorphicCopies(in: &child) + return child + } + } + makePolymorphicCopies(in: &testGraph) + + // Insert the copied tests into the graph. + for testCopy in testCopies { + testGraph.insertValue(testCopy, at: testCopy.id.keyPathRepresentation) + } + } + /// Given an array of tests, synthesize any containing suites that are not /// already represented in that array. /// @@ -314,16 +378,11 @@ extension Runner.Plan { Backtrace.flushThrownErrorCache() } - // Convert the list of test into a graph of steps. The actions for these - // steps will all be .run() *unless* an error was thrown while examining - // them, in which case it will be .recordIssue(). + // Convert the list of test into a graph of steps. let runAction = _runAction var testGraph = Graph() - var actionGraph = Graph(value: runAction) for test in tests { - let idComponents = test.id.keyPathRepresentation - testGraph.insertValue(test, at: idComponents) - actionGraph.insertValue(runAction, at: idComponents, intermediateValue: runAction) + testGraph.insertValue(test, at: test.id.keyPathRepresentation) } // Remove any tests that should be filtered out per the runner's @@ -346,6 +405,15 @@ extension Runner.Plan { // Synthesize suites for nodes in the test graph for which they are missing. _recursivelySynthesizeSuites(in: &testGraph) + // Synthesize test functions inherited by subclasses of polymorphic test + // suites. + _synthesizePolymorphicTests(in: &testGraph) + + // Generate the initial action graph. The actions for these steps will all + // be .run() *unless* an error was thrown while examining them, in which + // case they will be .recordIssue(). + var actionGraph = testGraph.mapValues { _, _ in runAction } + // Recursively apply all recursive suite traits to children. // // This must be done _before_ calling `prepare(for:)` on the traits below. diff --git a/Sources/Testing/Running/Runner.swift b/Sources/Testing/Running/Runner.swift index 849e06b26..0e6da9384 100644 --- a/Sources/Testing/Running/Runner.swift +++ b/Sources/Testing/Running/Runner.swift @@ -175,7 +175,7 @@ extension Runner { /// - body: The function to invoke. /// /// - Throws: Whatever is thrown by `body`. - private static func _forEach( + private nonisolated(nonsending) static func _forEach( in sequence: some Sequence, namingTasksWith taskNamer: (borrowing E) -> (taskName: String, action: String?)?, _ body: @Sendable @escaping (borrowing E) async throws -> Void @@ -370,27 +370,29 @@ extension Runner { private static func _runTestCases(_ testCases: some Sequence, within step: Plan.Step, context: _Context) async { let configuration = _configuration - // Apply the configuration's test case filter. - let testCaseFilter = configuration.testCaseFilter - let testCases = testCases.lazy.filter { testCase in - testCaseFilter(testCase, step.test) - } - - // Figure out how to name child tasks. - let testName = "test \(step.test.humanReadableName())" - let taskNamer: (Int, Test.Case) -> (String, String?)? = if step.test.isParameterized { - { i, _ in (testName, "running test case #\(i + 1)") } - } else { - { _, _ in (testName, "running") } - } + await withCurrentPolymorphicSubclassIfNeeded(for: step.test) { + // Apply the configuration's test case filter. + let testCaseFilter = configuration.testCaseFilter + let testCases = testCases.lazy.filter { testCase in + testCaseFilter(testCase, step.test) + } - await _forEach(in: testCases.enumerated(), namingTasksWith: taskNamer) { _, testCase in - if let testCaseSerializer = context.testCaseSerializer { - // Note that if .serialized is applied to an inner scope, we still use - // this serializer (if set) so that we don't overcommit. - await testCaseSerializer.run { await _runTestCase(testCase, within: step, context: context) } + // Figure out how to name child tasks. + let testName = "test \(step.test.humanReadableName())" + let taskNamer: (Int, Test.Case) -> (String, String?)? = if step.test.isParameterized { + { i, _ in (testName, "running test case #\(i + 1)") } } else { - await _runTestCase(testCase, within: step, context: context) + { _, _ in (testName, "running") } + } + + await _forEach(in: testCases.enumerated(), namingTasksWith: taskNamer) { _, testCase in + if let testCaseSerializer = context.testCaseSerializer { + // Note that if .serialized is applied to an inner scope, we still use + // this serializer (if set) so that we don't overcommit. + await testCaseSerializer.run { await _runTestCase(testCase, within: step, context: context) } + } else { + await _runTestCase(testCase, within: step, context: context) + } } } } diff --git a/Sources/Testing/Support/SubclassCache.swift b/Sources/Testing/Support/SubclassCache.swift new file mode 100644 index 000000000..1726e5b11 --- /dev/null +++ b/Sources/Testing/Support/SubclassCache.swift @@ -0,0 +1,137 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 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 +// + +#if _runtime(_ObjC) +private import ObjectiveC +#else +private import _TestDiscovery +#endif + +/// A type that contains a cache of classes and their known subclasses. +/// +/// - Note: In general, this type is not able to dynamically discover generic +/// classes that are subclasses of a given class. +struct SubclassCache { +#if !_runtime(_ObjC) + /// A dictionary keyed by classes whose values are arrays of all known + /// subclasses of those classes. + /// + /// This dictionary is constructed in reverse by walking all known classes in + /// the current process and recursively querying each one for its immediate + /// superclass. This is less efficient than the Objective-C-based + /// implementation (which can avoid realizing classes that aren't of + /// interest to us). BUG: rdar://172942099 + private static let _allSubclasses: [TypeInfo: [AnyClass]] = { + var result = [TypeInfo: [AnyClass]]() + + for clazz in allClasses() { + let superclasses = sequence(first: clazz, next: _getSuperclass).dropFirst() + for superclass in superclasses { + let typeInfo = TypeInfo(describing: superclass) + result[typeInfo, default: []].append(clazz) + } + } + + return result + }() +#endif + + /// An entry in the subclass cache. + private struct _CacheEntry { + /// Whether or not the represented type belongs in the cache. + var inCache: Bool + + /// The set of known subclasses for this entry, if cached. + var subclasses: [AnyClass]? + } + + /// The set of cached information, keyed by type (class). + /// + /// Negative entries (`inCache = false`) indicate that a type is known _not_ + /// to be contained in this cache (after considering superclasses and + /// subclasses). + private var _cache: [TypeInfo: _CacheEntry] + + /// Initialize an instance of this type to provide information for the given + /// set of base classes. + /// + /// - Parameters: + /// - baseClasses: The set of base classes for which this instance will + /// cache information. + init(_ baseClasses: some Sequence) { + let baseClasses = Set(baseClasses.lazy.map { TypeInfo(describing: $0) }) + _cache = Dictionary(uniqueKeysWithValues: baseClasses.lazy.map { ($0, _CacheEntry(inCache: true)) }) + } + + /// Look up the given type in the cache. + /// + /// - Parameters: + /// - typeInfo: The type to look up. + /// + /// - Returns: Whether or not the given type is contained in this cache. + /// + /// If `typeInfo` represents a class, and one of that class' superclasses is + /// contained in this cache, then that class is _also_ considered to be + /// contained in the cache. + private mutating func _find(_ typeInfo: TypeInfo) -> _CacheEntry? { + if let cached = _cache[typeInfo] { + return cached.inCache ? cached : nil + } + + var superclassFound = false + if let clazz = typeInfo.class, let superclass = _getSuperclass(clazz) { + superclassFound = _find(TypeInfo(describing: superclass)) != nil + } + let result = _CacheEntry(inCache: superclassFound) + _cache[typeInfo] = result + return result + } + + /// Check whether or not a given class is contained in this cache. + /// + /// - Parameters: + /// - clazz: The class to look up. + /// + /// - Returns: Whether or not the given class is contained in this cache. + /// + /// If one of the superclasses of `clazz` is contained in this cache, then + /// `clazz` is _also_ considered to be contained in the cache. + mutating func contains(_ clazz: AnyClass) -> Bool { + _find(TypeInfo(describing: clazz)) != nil + } + + /// Look up all known subclasses of a given class. + /// + /// - Parameters: + /// - clazz: The base class of interest. + /// + /// - Returns: An array of all known subclasses of the given class. + /// + /// If `clazz` or a superclass thereof was not passed to ``init(_:)``, this + /// function returns the empty array. + mutating func subclasses(of clazz: AnyClass) -> [AnyClass] { + let typeInfo = TypeInfo(describing: clazz) + + guard let cached = _find(typeInfo) else { + return [] + } + + if let result = cached.subclasses { + return result + } +#if _runtime(_ObjC) + let result = Array(objc_enumerateClasses(subclassing: clazz)) +#else + let result = Self._allSubclasses[typeInfo] ?? [] +#endif + _cache[typeInfo]!.subclasses = result + return result + } +} diff --git a/Sources/Testing/Test+Macro.swift b/Sources/Testing/Test+Macro.swift index 97fe104de..5ea1688e1 100644 --- a/Sources/Testing/Test+Macro.swift +++ b/Sources/Testing/Test+Macro.swift @@ -107,10 +107,11 @@ extension Test { _ containingType: S.Type, displayName: String? = nil, traits: [any SuiteTrait], - sourceBounds: __SourceBounds + sourceBounds: __SourceBounds, + isPolymorphic: Bool = false ) -> Self where S: ~Copyable & ~Escapable { let containingTypeInfo = TypeInfo(describing: containingType) - return Self(displayName: displayName, traits: traits, sourceLocation: sourceBounds.lowerBound, containingTypeInfo: containingTypeInfo) + return Self(displayName: displayName, traits: traits, sourceLocation: sourceBounds.lowerBound, containingTypeInfo: containingTypeInfo, isPolymorphic: isPolymorphic) } } @@ -536,9 +537,9 @@ extension Test { /// /// - Warning: This function is used to implement the `@Test` macro. Do not use /// it directly. -@_lifetime(copy value) +@_lifetime(immortal) @inlinable public func __requiringTry(_ value: consuming T) throws -> T where T: ~Copyable & ~Escapable { - value + _overrideLifetime(value, copying: ()) } /// A function that abstracts away whether or not the `await` keyword is needed @@ -546,9 +547,9 @@ extension Test { /// /// - Warning: This function is used to implement the `@Test` macro. Do not use /// it directly. -@_lifetime(copy value) +@_lifetime(immortal) @inlinable public func __requiringAwait(_ value: consuming T, isolation: isolated (any Actor)? = #isolation) async -> T where T: ~Copyable & ~Escapable { - value + _overrideLifetime(value, copying: ()) } /// A function that abstracts away whether or not the `unsafe` keyword is needed @@ -556,9 +557,9 @@ extension Test { /// /// - Warning: This function is used to implement the `@Test` macro. Do not use /// it directly. -@_lifetime(copy value) +@_lifetime(immortal) @unsafe @inlinable public func __requiringUnsafe(_ value: consuming T) -> T where T: ~Copyable & ~Escapable { - value + _overrideLifetime(value, copying: ()) } /// The current default isolation context. @@ -619,3 +620,135 @@ public func __invokeXCTestMethod( issue.record() return true } + +// MARK: - Test inheritance + +/// A protocol applied automatically to suite types with the `@polymorphic` +/// attribute. +/// +/// - Warning: This function is used to implement the `@Suite` macro. Do not +/// call it directly. +public protocol __PolymorphicSuite: AnyObject & SendableMetatype { + init() async throws +} + +/// An attribute that marks a test suite as being polymorphic. +/// +/// When you apply this attribute to a class that you're using as a test suite, +/// the testing library looks up all subclasses of that class at runtime, makes +/// copies of all test functions in the test suite, and adds them to each of +/// those subclasses as distinct test functions. +/// +/// - Note A polymorphic test suite must be a class, and you must also apply the +/// ``Suite(_:_:)`` attribute to it. +/// +/// You can use this attribute when you want to share test functions among +/// multiple suites. The testing library will automatically create copies of any +/// test functions in the suite for all non-generic subclasses, subclasses of +/// subclasses, and so on. For example: +/// +/// ```swift +/// @polymorphic @Suite class IngredientTests { +/// open var ingredient: (any Ingredient)? { nil } +/// +/// @Test func `This ingredient is fresh`() throws { +/// guard let ingredient = self.ingredient else { +/// try Test.cancel() +/// } +/// #expect(ingredient.expirationDate > .now) +/// } +/// } +/// +/// final class LettuceTests: IngredientTests { +/// override var ingredient: (any Ingredient)? { Lettuce() } +/// } +/// +/// class CheeseTests: IngredientTests {} +/// +/// final class CheddarCheeseTests: IngredientTests { +/// override var ingredient: (any Ingredient)? { CheddarCheese() } +/// } +/// +/// final class SwissCheeseTests: IngredientTests { +/// override var ingredient: (any Ingredient)? { SwissCheese() } +/// } +/// ``` +/// +/// When you run this test target, the testing library will run the +/// \``This ingredient is fresh`\` test function on an instance of +/// `IngredientTests`, `LettuceTests`, `CheeseTests`, `CheddarCheeseTests`, and +/// `SwissCheeseTests`. +/// +/// In this example, we have configured the test function to cancel itself when +/// the value of the `ingredient` property is `nil`. +/// +/// - Important: If you subclass a polymorphic test suite with a generic +/// subclass, the testing library ignores that subclass when you run your +/// tests. +@_spi(Experimental) +@attached(extension, conformances: __PolymorphicSuite) public macro polymorphic() = #externalMacro(module: "TestingMacros", type: "PolymorphicSuiteMacro") + +/// The current subclass to use for inherited test functions. +@TaskLocal private var _currentPolymorphicSubclass: (any __PolymorphicSuite.Type)? + +/// Set the current subclass to use for inherited test functions. +/// +/// - Parameters: +/// - test: The test to treat as polymorphic. +/// - body: A function to run while the current subclass is set. +/// +/// - Returns: Whatever is returned by `body`. +/// +/// - Throws: Whatever is thrown by `body`. +nonisolated(nonsending) func withCurrentPolymorphicSubclassIfNeeded(for test: Test, _ body: nonisolated(nonsending) () async throws -> R) async rethrows -> R { + guard test.isPolymorphic, + let clazz = test.containingTypeInfo?.class, + let polymorphicClass = clazz as? any __PolymorphicSuite.Type else { + return try await body() + } + return try await $_currentPolymorphicSubclass.withValue(polymorphicClass) { + try await body() + } +} + +/// Initialize an instance of a type to be used as the `self` argument of a test +/// function. +/// +/// - Parameters: +/// - initExpr: A closure to invoke that creates an instance of the type `T`. +/// +/// - Returns: An instance of the type `T` returned from `initExpr`. +/// +/// - Throws: Whatever is thrown by `initExpr`. +/// +/// - Warning: This function is used to implement the `@Suite` macro. Do not +/// call it directly. +@_spi(Experimental) +@_lifetime(immortal) +public nonisolated(nonsending) func __initialize(_ initExpr: nonisolated(nonsending) () async throws -> T) async throws -> T where T: ~Copyable & ~Escapable { + try await _overrideLifetime(initExpr(), copying: ()) +} + +/// Initialize an instance of a subclass of some class `T` to be used as the +/// `self` argument of a polymorphic test function. +/// +/// - Parameters: +/// - initExpr: Unused. +/// +/// - Returns: An instance of the current subclass of class `T` (or `T` itself). +/// +/// - Throws: Any error that prevented instantiating a subclass of `T`. +/// +/// - Warning: This function is used to implement the `@Suite` macro. Do not +/// call it directly. +@_spi(Experimental) +public nonisolated(nonsending) func __initialize(_ initExpr: nonisolated(nonsending) () async throws -> C) async throws -> C where C: __PolymorphicSuite { + if let _currentPolymorphicSubclass { + guard let result = _currentPolymorphicSubclass as? C.Type else { + throw SystemError(description: "Expected a subclass of '\(C.self)' to instantiate for the current test, but found '\(_currentPolymorphicSubclass)' instead") + } + return try await result.init() + } else { + throw SystemError(description: "Expected a subclass of '\(C.self)' to instantiate for the current test, but no class was configured") + } +} diff --git a/Sources/Testing/Test.swift b/Sources/Testing/Test.swift index e89f2edae..eae3414d1 100644 --- a/Sources/Testing/Test.swift +++ b/Sources/Testing/Test.swift @@ -37,6 +37,8 @@ public struct Test: Sendable { var testCasesState: TestCasesState? var parameters: [Parameter]? var isSynthesized: Bool + var isPolymorphic: Bool + var wasInherited: Bool #if DEBUG var mutationCount = 0 #endif @@ -300,6 +302,27 @@ public struct Test: Sendable { } } + /// Whether or not this instance should be copied and inherited by subclasses + /// of ``containingTypeInfo``. + var isPolymorphic: Bool { + get { + _properties.value.isPolymorphic + } + set { + _setValue(newValue, forKeyPath: \.isPolymorphic) + } + } + + /// Whether or not this instance was inherited from a superclass. + var wasInherited: Bool { + get { + _properties.value.wasInherited + } + set { + _setValue(newValue, forKeyPath: \.wasInherited) + } + } + #if DEBUG /// The number of times any property on this instance of ``Test`` has been /// mutated after initialization. @@ -314,7 +337,8 @@ public struct Test: Sendable { traits: [any Trait], sourceLocation: SourceLocation, containingTypeInfo: TypeInfo, - isSynthesized: Bool = false + isSynthesized: Bool = false, + isPolymorphic: Bool ) { let name = containingTypeInfo.unqualifiedName var displayName = displayName @@ -329,7 +353,9 @@ public struct Test: Sendable { traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, - isSynthesized: isSynthesized + isSynthesized: isSynthesized, + isPolymorphic: isPolymorphic, + wasInherited: false ) _properties = Allocated(properties) } @@ -354,7 +380,9 @@ public struct Test: Sendable { xcTestCompatibleSelector: xcTestCompatibleSelector, testCasesState: .unevaluated { try await testCases() }, parameters: parameters, - isSynthesized: false + isSynthesized: false, + isPolymorphic: false, + wasInherited: false ) _properties = Allocated(properties) } @@ -379,7 +407,9 @@ public struct Test: Sendable { xcTestCompatibleSelector: xcTestCompatibleSelector, testCasesState: .evaluated(testCases), parameters: parameters, - isSynthesized: false + isSynthesized: false, + isPolymorphic: false, + wasInherited: false ) _properties = Allocated(properties) } diff --git a/Sources/TestingMacros/CMakeLists.txt b/Sources/TestingMacros/CMakeLists.txt index 97f06dd9c..209ee426b 100644 --- a/Sources/TestingMacros/CMakeLists.txt +++ b/Sources/TestingMacros/CMakeLists.txt @@ -84,6 +84,7 @@ endif() target_sources(TestingMacros PRIVATE ConditionMacro.swift ExitTestCapturedValueMacro.swift + PolymorphicSuiteMacro.swift PragmaMacro.swift SourceLocationMacro.swift SuiteDeclarationMacro.swift diff --git a/Sources/TestingMacros/PolymorphicSuiteMacro.swift b/Sources/TestingMacros/PolymorphicSuiteMacro.swift new file mode 100644 index 000000000..2ea49f4f8 --- /dev/null +++ b/Sources/TestingMacros/PolymorphicSuiteMacro.swift @@ -0,0 +1,81 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 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 +// + +import SwiftDiagnostics +public import SwiftSyntax +import SwiftSyntaxBuilder +public import SwiftSyntaxMacros + +/// A type describing the expansion of the `@polymorphic` attribute macro. +/// +/// This type is used to implement the `@polymorphic` attribute macro. Do not +/// use it directly. +public struct PolymorphicSuiteMacro: ExtensionMacro, Sendable { + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + guard _diagnoseIssues(with: declaration, polymorphicAttribute: node, in: context) else { + return [] + } + + let extensionDecl: DeclSyntax = """ + extension \(type.trimmed) : Testing.__PolymorphicSuite {} + """ + return [extensionDecl.cast(ExtensionDeclSyntax.self)] + } + + /// Diagnose issues with the `@polymorphic` attribute. + /// + /// - Parameters: + /// - decl: The declaration to diagnose. + /// - polymorphicAttribute: The `@polymorphic` attribute applied to `decl`. + /// - context: The macro context in which the expression is being parsed. + /// + /// - Returns: Whether or not macro expansion should continue (i.e. stopping + /// if a fatal error was diagnosed.) + private static func _diagnoseIssues( + with decl: some DeclSyntaxProtocol, + polymorphicAttribute: AttributeSyntax, + in context: some MacroExpansionContext + ) -> Bool { + var diagnostics = [DiagnosticMessage]() + defer { + context.diagnose(diagnostics) + } + + // The @polymorphic attribute is only supported on class and actor + // declarations. (Note that extension macros cannot be applied to extension + // declarations, so we don't need to think about them). + guard decl.kind == .classDecl || decl.kind == .actorDecl else { + diagnostics.append(.attributeNotSupported(polymorphicAttribute, on: decl)) + return false + } + + // The @polymorphic attribute must be used with @Suite because @Suite is the + // one that emits the test content record containing the isPolymorphic flag. + if let attributedDecl = decl.asProtocol((any WithAttributesSyntax).self), + attributedDecl.attributes(named: "Suite").isEmpty { + diagnostics.append(.attributeNotSupported(polymorphicAttribute, withoutAttribute: "@Suite", on: decl)) + } + + if let modifiedDecl = decl.asProtocol((any WithModifiersSyntax).self) { + let finalModifier = modifiedDecl.modifiers.first { $0.name.tokenKind == .keyword(.final) } + if let finalModifier { + diagnostics.append(.attributeHasNoEffect(polymorphicAttribute, becauseOf: finalModifier, on: decl)) + } + } + + return !diagnostics.lazy.map(\.severity).contains(.error) + } +} diff --git a/Sources/TestingMacros/Support/AttributeDiscovery.swift b/Sources/TestingMacros/Support/AttributeDiscovery.swift index c3d19543b..b88e96fd9 100644 --- a/Sources/TestingMacros/Support/AttributeDiscovery.swift +++ b/Sources/TestingMacros/Support/AttributeDiscovery.swift @@ -178,6 +178,11 @@ struct AttributeInfo { arguments.append(Argument(label: "sourceBounds", expression: sourceBounds)) + if let declaration = declaration.asProtocol((any WithAttributesSyntax).self), + !declaration.attributes(named: "polymorphic").isEmpty { + arguments.append(Argument(label: "isPolymorphic", expression: BooleanLiteralExprSyntax(true))) + } + return LabeledExprListSyntax(arguments) } } diff --git a/Sources/TestingMacros/Support/DiagnosticMessage.swift b/Sources/TestingMacros/Support/DiagnosticMessage.swift index 01bbffcce..e74059338 100644 --- a/Sources/TestingMacros/Support/DiagnosticMessage.swift +++ b/Sources/TestingMacros/Support/DiagnosticMessage.swift @@ -136,6 +136,8 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { result = ("enumeration", "an") case .actorDecl: result = ("actor", "an") + case .extensionDecl: + result = ("extension", "an") case .variableDecl: // This string could be "variable" in some contexts but none we're // currently looking at. @@ -282,6 +284,30 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { ) } + /// Create a diagnostic message stating that the given attribute cannot be + /// applied to the given declaration because it requires the presence of + /// another attribute. + /// + /// - Parameters: + /// - attribute: The `@Test` or `@Suite` attribute. + /// - decl: The declaration in question. + /// + /// - Returns: A diagnostic message. + static func attributeNotSupported(_ attribute: AttributeSyntax, withoutAttribute requiredAttribute: AttributeSyntax, on decl: some SyntaxProtocol) -> Self { + let combinedAttributes: AttributeSyntax = "\(attribute) \(requiredAttribute)" + return Self( + syntax: Syntax(decl), + message: "Attribute \(_macroName(attribute)) cannot be applied to \(_kindString(for: decl, includeA: true)) without also applying attribute \(_macroName(attribute))", + severity: .error, + fixIts: [ + FixIt( + message: MacroExpansionFixItMessage("Add \(_macroName(attribute))"), + changes: [.replace(oldNode: Syntax(attribute), newNode: Syntax(combinedAttributes)),] + ), + ] + ) + } + /// Create a diagnostic message stating that the given attribute can only be /// applied to `static` properties. /// @@ -456,17 +482,17 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { } /// Create a diagnostic message stating that the given attribute has no effect - /// when applied to the given extension declaration. + /// when applied to the given declaration. /// /// - Parameters: /// - attribute: The `@Test` or `@Suite` attribute. - /// - decl: The extension declaration in question. + /// - decl: The declaration in question. /// /// - Returns: A diagnostic message. - static func attributeHasNoEffect(_ attribute: AttributeSyntax, on decl: ExtensionDeclSyntax) -> Self { + static func attributeHasNoEffect(_ attribute: AttributeSyntax, on decl: some DeclSyntaxProtocol) -> Self { Self( syntax: Syntax(decl), - message: "Attribute \(_macroName(attribute)) has no effect when applied to an extension", + message: "Attribute \(_macroName(attribute)) has no effect when applied to \(_kindString(for: decl, includeA: true))", severity: .error, fixIts: [ FixIt( @@ -477,6 +503,33 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { ) } + /// Create a diagnostic message stating that the given attribute has no effect + /// when applied to the given declaration due to the presence of a modifier. + /// + /// - Parameters: + /// - attribute: The `@Test` or `@Suite` attribute. + /// - modifier: The modifier that makes `attribute` have no effect. + /// - decl: The declaration in question. + /// + /// - Returns: A diagnostic message. + static func attributeHasNoEffect(_ attribute: AttributeSyntax, becauseOf modifier: DeclModifierSyntax, on decl: some DeclSyntaxProtocol) -> Self { + Self( + syntax: Syntax(decl), + message: "Attribute \(_macroName(attribute)) has no effect when applied to \(_kindString(for: decl)) with modifier '\(modifier.name.trimmed)'", + severity: .warning, + fixIts: [ + FixIt( + message: MacroExpansionFixItMessage("Remove attribute \(_macroName(attribute))"), + changes: [.replace(oldNode: Syntax(attribute), newNode: Syntax("" as ExprSyntax))] + ), + FixIt( + message: MacroExpansionFixItMessage("Remove modifier '\(modifier.name.trimmed)'"), + changes: [.replace(oldNode: Syntax(modifier), newNode: Syntax("" as ExprSyntax))] + ), + ] + ) + } + /// Create a diagnostic message stating that the given attribute has the wrong /// number of arguments when applied to the given function declaration. /// diff --git a/Sources/TestingMacros/TestDeclarationMacro.swift b/Sources/TestingMacros/TestDeclarationMacro.swift index c3d9f64fd..c4d5f50fc 100644 --- a/Sources/TestingMacros/TestDeclarationMacro.swift +++ b/Sources/TestingMacros/TestDeclarationMacro.swift @@ -87,9 +87,9 @@ public struct TestDeclarationMacro: PeerMacro, Sendable { } // Only one @Test attribute is supported. - let suiteAttributes = function.attributes(named: "Test") - if suiteAttributes.count > 1 { - diagnostics.append(.multipleAttributesNotSupported(suiteAttributes, on: declaration)) + let testAttributes = function.attributes(named: "Test") + if testAttributes.count > 1 { + diagnostics.append(.multipleAttributesNotSupported(testAttributes, on: declaration)) } let parameterList = function.signature.parameterClause.parameters @@ -259,6 +259,22 @@ public struct TestDeclarationMacro: PeerMacro, Sendable { } } + // Should the thunk be declared generic over subclasses of `typeName`? + var isPolymorphicSuiteClass = false + var mayBePolymorphicSuiteExtension = false + if let containingTypeDecl = context.lexicalContext.first?.asProtocol((any WithAttributesSyntax).self) { + switch containingTypeDecl.kind { + case .classDecl, .actorDecl: + if !containingTypeDecl.attributes(named: "polymorphic").isEmpty { + isPolymorphicSuiteClass = true + } + case .extensionDecl: + mayBePolymorphicSuiteExtension = true + default: + break + } + } + // Generate a thunk function that invokes the actual function. var thunkBody: CodeBlockItemListSyntax if functionDecl.availability(when: .unavailable).first(where: { $0.platformVersion == nil }) != nil { @@ -269,10 +285,20 @@ public struct TestDeclarationMacro: PeerMacro, Sendable { if functionDecl.isStaticOrClass { thunkBody = "_ = \(forwardCall("\(typeName).\(functionDecl.name.trimmed)\(forwardedParamsExpr)"))" } else { - let instanceName = context.makeUniqueName("") + let instanceName = context.makeUniqueName("subclass") let varOrLet = functionDecl.isMutating ? "var" : "let" + var initExpr: ExprSyntax = "\(typeName)()" + if isPolymorphicSuiteClass || mayBePolymorphicSuiteExtension { + initExpr = """ + try await Testing.__initialize { + \(forwardInit(initExpr)) + } + """ + } else { + initExpr = forwardInit(initExpr) + } thunkBody = """ - \(raw: varOrLet) \(raw: instanceName) = \(forwardInit("\(typeName)()")) + \(raw: varOrLet) \(raw: instanceName) = \(initExpr) _ = \(forwardCall("\(raw: instanceName).\(functionDecl.name.trimmed)\(forwardedParamsExpr)")) """ @@ -312,7 +338,7 @@ public struct TestDeclarationMacro: PeerMacro, Sendable { // Get a unique name for this secondary thunk. We don't need it to be // uniqued against functionDecl because it's interior to the "real" thunk, // so its name can't conflict with any other names visible in this scope. - let isolationThunkName = context.makeUniqueName("") + let isolationThunkName = context.makeUniqueName("isolation") // Insert a (defaulted) isolated argument. If we emit a closure (or inner // function) that captured the arguments to the "real" thunk, the compiler diff --git a/Sources/TestingMacros/TestingMacrosMain.swift b/Sources/TestingMacros/TestingMacrosMain.swift index 4453ac8ef..19d549211 100644 --- a/Sources/TestingMacros/TestingMacrosMain.swift +++ b/Sources/TestingMacros/TestingMacrosMain.swift @@ -19,6 +19,7 @@ struct TestingMacrosMain: CompilerPlugin { var providingMacros: [any Macro.Type] { [ SuiteDeclarationMacro.self, + PolymorphicSuiteMacro.self, TestDeclarationMacro.self, ExpectMacro.self, RequireMacro.self, diff --git a/Sources/_TestDiscovery/TestContentRecord.swift b/Sources/_TestDiscovery/TestContentRecord.swift index 211025825..3d2bb3399 100644 --- a/Sources/_TestDiscovery/TestContentRecord.swift +++ b/Sources/_TestDiscovery/TestContentRecord.swift @@ -161,7 +161,7 @@ public struct TestContentRecord where T: DiscoverableAsTestContent { /// Invoke an accessor function to load a test content record. /// /// - Parameters: - /// - accessor: The accessor function to call. + /// - accessor: The accessor function to call. /// - typeAddress: A pointer to the type of test content record. /// - hint: An optional hint value. /// @@ -311,7 +311,7 @@ extension DiscoverableAsTestContent { return SectionBounds.all(.typeMetadata).lazy.flatMap { sb in stride(from: 0, to: sb.buffer.count, by: SWTTypeMetadataRecordByteCount).lazy .map { sb.buffer.baseAddress! + $0 } - .compactMap { swt_getType(fromTypeMetadataRecord: $0, ifNameContains: typeNameHint) } + .compactMap { swt_getType(fromTypeMetadataRecord: $0, ifClassOnly: false, ifNameContains: typeNameHint) } .map { unsafeBitCast($0, to: Any.Type.self) } .compactMap(loader) .filter { $0.kind == kind } @@ -319,4 +319,19 @@ extension DiscoverableAsTestContent { } } } + +#if !hasFeature(Embedded) +/// Get a sequence of all known, non-generic class types in the current process. +/// +/// - Returns: A sequence of classes. +package func allClasses() -> some Sequence { + SectionBounds.all(.typeMetadata).lazy.flatMap { sb in + stride(from: 0, to: sb.buffer.count, by: SWTTypeMetadataRecordByteCount).lazy + .map { sb.buffer.baseAddress! + $0 } + .compactMap { swt_getType(fromTypeMetadataRecord: $0, ifClassOnly: true, ifNameContains: "") } + .map { unsafeBitCast($0, to: Any.Type.self) } + .compactMap { $0 as? AnyClass } + } +} +#endif #endif diff --git a/Sources/_TestingInternals/Discovery.cpp b/Sources/_TestingInternals/Discovery.cpp index 1e70a038d..1a1bb9f92 100644 --- a/Sources/_TestingInternals/Discovery.cpp +++ b/Sources/_TestingInternals/Discovery.cpp @@ -129,6 +129,11 @@ struct SWTTypeContextDescriptor { bool isGeneric(void) const& { return (_flags & 0x80u) != 0; } + + bool isClass(void) const& { + // SEE: ContextDescriptorFlags and ContextDescriptorKind::Class + return (_flags & 0x1Fu) == 16; + } }; /// A type representing a relative pointer to a type descriptor. @@ -161,7 +166,7 @@ struct SWTTypeMetadataRecord { const size_t SWTTypeMetadataRecordByteCount = sizeof(SWTTypeMetadataRecord); -const void *swt_getTypeFromTypeMetadataRecord(const void *recordAddress, const char *nameSubstring) { +const void *swt_getTypeFromTypeMetadataRecord(const void *recordAddress, bool classesOnly, const char *nameSubstring) { auto record = reinterpret_cast(recordAddress); auto contextDescriptor = record->getContextDescriptor(); if (!contextDescriptor) { @@ -174,14 +179,20 @@ const void *swt_getTypeFromTypeMetadataRecord(const void *recordAddress, const c return nullptr; } - // Check that the type's name passes. This will be more expensive than the - // checks above, but should be cheaper than realizing the metadata. - const char *typeName = contextDescriptor->getName(); - bool nameOK = typeName && nullptr != std::strstr(typeName, nameSubstring); - if (!nameOK) { + if (classesOnly && !contextDescriptor->isClass()) { return nullptr; } + if (nameSubstring[0] != '\0') { + // Check that the type's name passes. This will be more expensive than the + // checks above, but should be cheaper than realizing the metadata. + const char *typeName = contextDescriptor->getName(); + bool nameOK = typeName && nullptr != std::strstr(typeName, nameSubstring); + if (!nameOK) { + return nullptr; + } + } + if (void *typeMetadata = contextDescriptor->getMetadata()) { return typeMetadata; } diff --git a/Sources/_TestingInternals/include/Discovery.h b/Sources/_TestingInternals/include/Discovery.h index 25c3603b3..965461db8 100644 --- a/Sources/_TestingInternals/include/Discovery.h +++ b/Sources/_TestingInternals/include/Discovery.h @@ -40,14 +40,16 @@ SWT_EXTERN const size_t SWTTypeMetadataRecordByteCount; /// /// - Parameters: /// - recordAddress: The address of the Swift type metadata record. +/// - classesOnly: Whether or not to only return class types. /// - nameSubstring: A string which the names of matching types contain. /// /// - Returns: A Swift metatype (as `const void *`) or `nullptr` if it wasn't a /// usable type metadata record or its name did not contain `nameSubstring`. SWT_EXTERN const void *_Nullable swt_getTypeFromTypeMetadataRecord( const void *recordAddress, + bool classesOnly, const char *nameSubstring -) SWT_SWIFT_NAME(swt_getType(fromTypeMetadataRecord:ifNameContains:)); +) SWT_SWIFT_NAME(swt_getType(fromTypeMetadataRecord:ifClassOnly:ifNameContains:)); SWT_ASSUME_NONNULL_END diff --git a/Tests/TestingMacrosTests/ConditionMacroTests.swift b/Tests/TestingMacrosTests/ConditionMacroTests.swift index c299389c8..eee2d71fb 100644 --- a/Tests/TestingMacrosTests/ConditionMacroTests.swift +++ b/Tests/TestingMacrosTests/ConditionMacroTests.swift @@ -445,17 +445,17 @@ struct ConditionMacroTests { "struct S { func f() { #expectExitTest(processExitsWith: x) {} } }": "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within generic structure 'S'", "extension S where T: U { func f() { #expectExitTest(processExitsWith: x) {} } }": - "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within a generic declaration", + "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within a generic extension", "extension [T] { func f() { #expectExitTest(processExitsWith: x) {} } }": - "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within a generic declaration", + "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within a generic extension", "extension [T:U] { func f() { #expectExitTest(processExitsWith: x) {} } }": - "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within a generic declaration", + "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within a generic extension", "extension T? { func f() { #expectExitTest(processExitsWith: x) {} } }": - "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within a generic declaration", + "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within a generic extension", "extension T! { func f() { #expectExitTest(processExitsWith: x) {} } }": - "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within a generic declaration", + "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within a generic extension", "extension [1 of T] { func f() { #expectExitTest(processExitsWith: x) {} } }": - "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within a generic declaration", + "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within a generic extension", "func f() { #expectExitTest(processExitsWith: x) {} }": "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within generic function 'f()'", "func f() where T: U { #expectExitTest(processExitsWith: x) {} }": diff --git a/Tests/TestingTests/MiscellaneousTests.swift b/Tests/TestingTests/MiscellaneousTests.swift index 5afad7f25..4ea064bf8 100644 --- a/Tests/TestingTests/MiscellaneousTests.swift +++ b/Tests/TestingTests/MiscellaneousTests.swift @@ -327,7 +327,7 @@ struct MiscellaneousTests { @Test func `Suite type with raw identifier gets a display name`() throws { struct `Suite With De Facto Display Name` {} let typeInfo = TypeInfo(describing: `Suite With De Facto Display Name`.self) - let suite = Test(traits: [], sourceLocation: #_sourceLocation, containingTypeInfo: typeInfo, isSynthesized: true) + let suite = Test(traits: [], sourceLocation: #_sourceLocation, containingTypeInfo: typeInfo, isSynthesized: true, isPolymorphic: false) #expect(suite.name == "`Suite With De Facto Display Name`") let displayName = try #require(suite.displayName) #expect(displayName == "Suite With De Facto Display Name") diff --git a/Tests/TestingTests/PolymorphismTests.swift b/Tests/TestingTests/PolymorphismTests.swift new file mode 100644 index 000000000..0bf1fc970 --- /dev/null +++ b/Tests/TestingTests/PolymorphismTests.swift @@ -0,0 +1,75 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 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 +// + +@_spi(Experimental) @_spi(ForToolsIntegrationOnly) @testable import Testing + +#if canImport(Synchronization) +private import Synchronization +#endif + +struct `Polymorphic test function tests` { + @Test func `Polymorphic functions are discovered and run`() async throws { + let testPlan = await Runner.Plan(selecting: PolymorphicBaseClass.self) + let runner = Runner(plan: testPlan) + let ranClasses = await $ranClasses.withValue(Allocated(Mutex([]))) { + await runner.run() + return $ranClasses.wrappedValue.value.rawValue + }.sorted { $0.unqualifiedName < $1.unqualifiedName } + let expectedClasses: [TypeInfo] = [ + TypeInfo(describing: PolymorphicBaseClass.self), + TypeInfo(describing: PolymorphicBaseClass.DerivedClass.self), + TypeInfo(describing: TertiaryClass.self), + ].sorted { $0.unqualifiedName < $1.unqualifiedName } + #expect(ranClasses == expectedClasses) + } + + @Test func `Non-polymorphic subclass does not inherit test functions`() async throws { + await confirmation("Test suite started", expectedCount: 1...) { suiteStarted in + var configuration = Configuration() + configuration.eventHandler = { event, eventContext in + if case .testStarted = event.kind { + let test = eventContext.test! + if test.isSuite { + suiteStarted() + } else { + Issue.record("Ran test function '\(eventContext.test!.name)' in what should have been an empty suite") + } + } + } + let testPlan = await Runner.Plan(selecting: PolymorphicBaseClass.DoesNotInheritBaseClass.DoesNotInheritDerivedClass.self, configuration: configuration) + let runner = Runner(plan: testPlan, configuration: configuration) + await runner.run() + } + } +} + +// MARK: - Fixtures + +@TaskLocal private var ranClasses = Allocated(Mutex<[TypeInfo]>([])) + +@polymorphic @Suite(.hidden) private class PolymorphicBaseClass { + @Test func `Invoke inherited test function`() { + ranClasses.value.withLock { $0.append(TypeInfo(describing: Self.self)) } + } + + required init() {} + + class DoesNotInheritBaseClass { + @Test(.hidden) func `This function should not be inherited`() { + Issue.record("Should not have run this function.") + } + + @Suite(.hidden) final class DoesNotInheritDerivedClass: DoesNotInheritBaseClass {} + } + + class DerivedClass: PolymorphicBaseClass {} +} + +private final class TertiaryClass: PolymorphicBaseClass.DerivedClass {}