diff --git a/Documentation/ABI/JSON.md b/Documentation/ABI/JSON.md index f313ddc00..e4ff24a4b 100644 --- a/Documentation/ABI/JSON.md +++ b/Documentation/ABI/JSON.md @@ -188,19 +188,24 @@ sufficient information to display the event in a human-readable format. "kind": , "instant": , ; when the event occurred ["issue": ,] ; the recorded issue (if "kind" is "issueRecorded") + ["attachment": ,] ; the attachment (if kind is "valueAttached") "messages": , ["testID": ,] } ::= "runStarted" | "testStarted" | "testCaseStarted" | "issueRecorded" | "testCaseEnded" | "testEnded" | "testSkipped" | - "runEnded" ; additional event kinds may be added in the future + "runEnded" | "valueAttached"; additional event kinds may be added in the future ::= { "isKnown": , ; is this a known issue or not? ["sourceLocation": ,] ; where the issue occurred, if known } + ::= { + "path": , ; the absolute path to the attachment on disk +} + ::= { "symbol": , "text": , ; the human-readable text of this message diff --git a/Documentation/ABI/TestContent.md b/Documentation/ABI/TestContent.md index cb68a2d6e..2d0b9a4b8 100644 --- a/Documentation/ABI/TestContent.md +++ b/Documentation/ABI/TestContent.md @@ -126,7 +126,10 @@ or a third-party library are inadvertently loaded into the same process. If the value at `type` does not match the test content record's expected type, the accessor function must return `false` and must not modify `outValue`. - +When building for **Embedded Swift**, the value passed as `type` by Swift +Testing is unspecified because type metadata pointers are not available in that +environment. + [^mightNotBeSwift]: Although this document primarily deals with Swift, the test content record section is generally language-agnostic. The use of languages @@ -146,25 +149,12 @@ The fourth argument to this function, `reserved`, is reserved for future use. Accessor functions should assume it is `0` and must not access it. The concrete Swift type of the value written to `outValue`, the type pointed to -by `type`, and the value pointed to by `hint` depend on the kind of record: +by `type`, and the value pointed to by `hint` depend on the kind of record. -- For test or suite declarations (kind `0x74657374`), the accessor produces a - structure of type `Testing.Test.Generator` that the testing library can use - to generate the corresponding test[^notAccessorSignature]. - - [^notAccessorSignature]: This level of indirection is necessary because - loading a test or suite declaration is an asynchronous operation, but C - functions cannot be `async`. - - Test content records of this kind do not specify a type for `hint`. Always - pass `nil`. - -- For exit test declarations (kind `0x65786974`), the accessor produces a - structure describing the exit test (of type `Testing.ExitTest`.) - - Test content records of this kind accept a `hint` of type `Testing.ExitTest.ID`. - They only produce a result if they represent an exit test declared with the - same ID (or if `hint` is `nil`.) +The record kinds defined by Swift Testing (kinds `0x74657374` and `0x65786974`) +make use of the `DiscoverableAsTestContent` protocol in the `_TestDiscovery` +module and do not publicly expose the types of their accessor functions' +arguments. Do not call the accessor functions for these records directly. > [!WARNING] > Calling code should use [`withUnsafeTemporaryAllocation(of:capacity:_:)`](https://developer.apple.com/documentation/swift/withunsafetemporaryallocation(of:capacity:_:)) @@ -271,7 +261,8 @@ extension FoodTruckDiagnostic: DiscoverableAsTestContent { ``` If you customize `TestContentContext`, be aware that the type you specify must -have the same stride and alignment as `UInt`. +have the same stride as `UInt` and must have an alignment less than or equal to +that of `UInt`. When you are done configuring your type's protocol conformance, you can then enumerate all test content records matching it as instances of diff --git a/Package.swift b/Package.swift index 8085d7bc8..44116b6d1 100644 --- a/Package.swift +++ b/Package.swift @@ -1,9 +1,9 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.1 // // This source file is part of the Swift.org open source project // -// Copyright (c) 2023 Apple Inc. and the Swift project authors +// Copyright (c) 2023–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 @@ -20,17 +20,49 @@ let git = Context.gitInformation /// distribution as a package dependency. let buildingForDevelopment = (git?.currentTag == nil) +/// Whether or not this package is being built for Embedded Swift. +/// +/// This value is `true` if `SWT_EMBEDDED` is set in the environment to `true` +/// when `swift build` is invoked. This inference is experimental and is subject +/// to change in the future. +/// +/// - Bug: There is currently no way for us to tell if we are being asked to +/// build for an Embedded Swift target at the package manifest level. +/// ([swift-syntax-#8431](https://github.com/swiftlang/swift-package-manager/issues/8431)) +let buildingForEmbedded: Bool = { + guard let envvar = Context.environment["SWT_EMBEDDED"] else { + return false + } + return Bool(envvar) ?? ((Int(envvar) ?? 0) != 0) +}() + let package = Package( name: "swift-testing", - platforms: [ - .macOS(.v10_15), - .iOS(.v13), - .watchOS(.v6), - .tvOS(.v13), - .macCatalyst(.v13), - .visionOS(.v1), - ], + platforms: { + if !buildingForEmbedded { + [ + .macOS(.v10_15), + .iOS(.v13), + .watchOS(.v6), + .tvOS(.v13), + .macCatalyst(.v13), + .visionOS(.v1), + ] + } else { + // Open-source main-branch toolchains (currently required to build this + // package for Embedded Swift) have higher Apple platform deployment + // targets than we would otherwise require. + [ + .macOS(.v14), + .iOS(.v18), + .watchOS(.v10), + .tvOS(.v18), + .macCatalyst(.v18), + .visionOS(.v1), + ] + } + }(), products: { var result = [Product]() @@ -63,8 +95,15 @@ let package = Package( return result }(), + traits: [ + .trait( + name: "ExperimentalExitTestValueCapture", + description: "Enable experimental support for capturing values in exit tests" + ), + ], + dependencies: [ - .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "601.0.0-latest"), + .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "602.0.0-latest"), ], targets: [ @@ -132,7 +171,7 @@ let package = Package( dependencies: ["_TestingInternals",], exclude: ["CMakeLists.txt"], cxxSettings: .packageSettings, - swiftSettings: .packageSettings + swiftSettings: .packageSettings + .enableLibraryEvolution() ), // Cross-import overlays (not supported by Swift Package Manager) @@ -185,6 +224,32 @@ package.targets.append(contentsOf: [ ]) #endif +extension BuildSettingCondition { + /// Creates a build setting condition that evaluates to `true` for Embedded + /// Swift. + /// + /// - Parameters: + /// - nonEmbeddedCondition: The value to return if the target is not + /// Embedded Swift. If `nil`, the build condition evaluates to `false`. + /// + /// - Returns: A build setting condition that evaluates to `true` for Embedded + /// Swift or is equal to `nonEmbeddedCondition` for non-Embedded Swift. + static func whenEmbedded(or nonEmbeddedCondition: @autoclosure () -> Self? = nil) -> Self? { + if !buildingForEmbedded { + if let nonEmbeddedCondition = nonEmbeddedCondition() { + nonEmbeddedCondition + } else { + // The caller did not supply a fallback. Specify a non-existent platform + // to ensure this condition never matches. + .when(platforms: [.custom("DoesNotExist")]) + } + } else { + // Enable unconditionally because the target is Embedded Swift. + nil + } + } +} + extension Array where Element == PackageDescription.SwiftSetting { /// Settings intended to be applied to every Swift target in this package. /// Analogous to project-level build settings in an Xcode project. @@ -195,6 +260,10 @@ extension Array where Element == PackageDescription.SwiftSetting { result.append(.unsafeFlags(["-require-explicit-sendable"])) } + if buildingForEmbedded { + result.append(.enableExperimentalFeature("Embedded")) + } + result += [ .enableUpcomingFeature("ExistentialAny"), @@ -208,19 +277,41 @@ extension Array where Element == PackageDescription.SwiftSetting { // proposal via Swift Evolution. .enableExperimentalFeature("SymbolLinkageMarkers"), + // This setting is no longer needed when building with a 6.2 or later + // toolchain now that SE-0458 has been accepted and implemented, but it is + // needed in order to preserve support for building with 6.1 development + // snapshot toolchains. (Production 6.1 toolchains can build the testing + // library even without this setting since this experimental feature is + // _suppressible_.) This setting can be removed once the minimum supported + // toolchain for building the testing library is ≥ 6.2. It is not needed + // in the CMake settings since that is expected to build using a + // new-enough toolchain. + .enableExperimentalFeature("AllowUnsafeAttribute"), + // When building as a package, the macro plugin always builds as an // executable rather than a library. .define("SWT_NO_LIBRARY_MACRO_PLUGINS"), .define("SWT_TARGET_OS_APPLE", .when(platforms: [.macOS, .iOS, .macCatalyst, .watchOS, .tvOS, .visionOS])), - .define("SWT_NO_EXIT_TESTS", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])), - .define("SWT_NO_PROCESS_SPAWNING", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])), - .define("SWT_NO_SNAPSHOT_TYPES", .when(platforms: [.linux, .custom("freebsd"), .openbsd, .windows, .wasi, .android])), - .define("SWT_NO_DYNAMIC_LINKING", .when(platforms: [.wasi])), - .define("SWT_NO_PIPES", .when(platforms: [.wasi])), + .define("SWT_NO_EXIT_TESTS", .whenEmbedded(or: .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android]))), + .define("SWT_NO_PROCESS_SPAWNING", .whenEmbedded(or: .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android]))), + .define("SWT_NO_SNAPSHOT_TYPES", .whenEmbedded(or: .when(platforms: [.linux, .custom("freebsd"), .openbsd, .windows, .wasi, .android]))), + .define("SWT_NO_DYNAMIC_LINKING", .whenEmbedded(or: .when(platforms: [.wasi]))), + .define("SWT_NO_PIPES", .whenEmbedded(or: .when(platforms: [.wasi]))), + + .define("SWT_NO_LEGACY_TEST_DISCOVERY", .whenEmbedded()), + .define("SWT_NO_LIBDISPATCH", .whenEmbedded()), ] + // Unconditionally enable 'ExperimentalExitTestValueCapture' when building + // for development. + if buildingForDevelopment { + result += [ + .define("ExperimentalExitTestValueCapture") + ] + } + return result } @@ -271,11 +362,14 @@ extension Array where Element == PackageDescription.CXXSetting { var result = Self() result += [ - .define("SWT_NO_EXIT_TESTS", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])), - .define("SWT_NO_PROCESS_SPAWNING", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])), - .define("SWT_NO_SNAPSHOT_TYPES", .when(platforms: [.linux, .custom("freebsd"), .openbsd, .windows, .wasi, .android])), - .define("SWT_NO_DYNAMIC_LINKING", .when(platforms: [.wasi])), - .define("SWT_NO_PIPES", .when(platforms: [.wasi])), + .define("SWT_NO_EXIT_TESTS", .whenEmbedded(or: .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android]))), + .define("SWT_NO_PROCESS_SPAWNING", .whenEmbedded(or: .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android]))), + .define("SWT_NO_SNAPSHOT_TYPES", .whenEmbedded(or: .when(platforms: [.linux, .custom("freebsd"), .openbsd, .windows, .wasi, .android]))), + .define("SWT_NO_DYNAMIC_LINKING", .whenEmbedded(or: .when(platforms: [.wasi]))), + .define("SWT_NO_PIPES", .whenEmbedded(or: .when(platforms: [.wasi]))), + + .define("SWT_NO_LEGACY_TEST_DISCOVERY", .whenEmbedded()), + .define("SWT_NO_LIBDISPATCH", .whenEmbedded()), ] // Capture the testing library's version as a C++ string constant. diff --git a/README.md b/README.md index 01b8a4fd6..9f7eb424f 100644 --- a/README.md +++ b/README.md @@ -93,15 +93,15 @@ very best ideas, from anywhere, can help shape the future of testing in Swift. The table below describes the current level of support that Swift Testing has for various platforms: -| **Platform** | **CI Status (6.0)** | **CI Status (main)** | **Support Status** | +| **Platform** | **CI Status (6.1)** | **CI Status (main)** | **Support Status** | |---|:-:|:-:|---| -| **macOS** | [![Build Status](https://ci.swift.org/buildStatus/icon?job=swift-testing-main-swift-6.0-macos)](https://ci.swift.org/job/swift-testing-main-swift-6.0-macos/) | [![Build Status](https://ci.swift.org/buildStatus/icon?job=swift-testing-main-swift-main-macos)](https://ci.swift.org/view/Swift%20Packages/job/swift-testing-main-swift-main-macos/) | Supported | +| **macOS** | [![Build Status](https://ci.swift.org/buildStatus/icon?job=swift-testing-main-swift-6.1-macos)](https://ci.swift.org/job/swift-testing-main-swift-6.1-macos/) | [![Build Status](https://ci.swift.org/buildStatus/icon?job=swift-testing-main-swift-main-macos)](https://ci.swift.org/view/Swift%20Packages/job/swift-testing-main-swift-main-macos/) | Supported | | **iOS** | | | Supported | | **watchOS** | | | Supported | | **tvOS** | | | Supported | | **visionOS** | | | Supported | -| **Ubuntu 22.04** | [![Build Status](https://ci.swift.org/buildStatus/icon?job=swift-testing-main-swift-6.0-linux)](https://ci.swift.org/job/swift-testing-main-swift-6.0-linux/) | [![Build Status](https://ci.swift.org/buildStatus/icon?job=swift-testing-main-swift-main-linux)](https://ci.swift.org/view/Swift%20Packages/job/swift-testing-main-swift-main-linux/) | Supported | -| **Windows** | | [![Build Status](https://ci-external.swift.org/buildStatus/icon?job=swift-testing-main-swift-main-windows)](https://ci-external.swift.org/job/swift-testing-main-swift-main-windows/) | Supported | +| **Ubuntu 22.04** | [![Build Status](https://ci.swift.org/buildStatus/icon?job=swift-testing-main-swift-6.1-linux)](https://ci.swift.org/job/swift-testing-main-swift-6.1-linux/) | [![Build Status](https://ci.swift.org/buildStatus/icon?job=swift-testing-main-swift-main-linux)](https://ci.swift.org/view/Swift%20Packages/job/swift-testing-main-swift-main-linux/) | Supported | +| **Windows** | [![Build Status](https://ci.swift.org/buildStatus/icon?job=swift-testing-main-swift-6.1-windows)](https://ci-external.swift.org/view/all/job/swift-testing-main-swift-6.1-windows/) | [![Build Status](https://ci-external.swift.org/buildStatus/icon?job=swift-testing-main-swift-main-windows)](https://ci-external.swift.org/job/swift-testing-main-swift-main-windows/) | Supported | | **Wasm** | | | Experimental | ### Works with XCTest diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift index ca520e0c0..ed1e6a2ee 100644 --- a/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift @@ -42,9 +42,9 @@ extension Attachment { contentType: (any Sendable)?, encodingQuality: Float, sourceLocation: SourceLocation - ) where AttachableValue == _AttachableImageContainer { - let imageContainer = _AttachableImageContainer(image: attachableValue, encodingQuality: encodingQuality, contentType: contentType) - self.init(imageContainer, named: preferredName, sourceLocation: sourceLocation) + ) where AttachableValue == _AttachableImageWrapper { + let imageWrapper = _AttachableImageWrapper(image: attachableValue, encodingQuality: encodingQuality, contentType: contentType) + self.init(imageWrapper, named: preferredName, sourceLocation: sourceLocation) } /// Initialize an instance of this type that encloses the given image. @@ -79,7 +79,7 @@ extension Attachment { as contentType: UTType?, encodingQuality: Float = 1.0, sourceLocation: SourceLocation = #_sourceLocation - ) where AttachableValue == _AttachableImageContainer { + ) where AttachableValue == _AttachableImageWrapper { self.init(attachableValue: attachableValue, named: preferredName, contentType: contentType, encodingQuality: encodingQuality, sourceLocation: sourceLocation) } @@ -109,7 +109,7 @@ extension Attachment { named preferredName: String? = nil, encodingQuality: Float = 1.0, sourceLocation: SourceLocation = #_sourceLocation - ) where AttachableValue == _AttachableImageContainer { + ) where AttachableValue == _AttachableImageWrapper { self.init(attachableValue: attachableValue, named: preferredName, contentType: nil, encodingQuality: encodingQuality, sourceLocation: sourceLocation) } } diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageContainer.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageWrapper.swift similarity index 96% rename from Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageContainer.swift rename to Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageWrapper.swift index 90d1c0c70..7aa1fd139 100644 --- a/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageContainer.swift +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageWrapper.swift @@ -9,7 +9,7 @@ // #if SWT_TARGET_OS_APPLE && canImport(CoreGraphics) -@_spi(Experimental) public import Testing +public import Testing private import CoreGraphics private import ImageIO @@ -48,7 +48,7 @@ import UniformTypeIdentifiers /// /// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage) @_spi(Experimental) -public struct _AttachableImageContainer: Sendable where Image: AttachableAsCGImage { +public struct _AttachableImageWrapper: Sendable where Image: AttachableAsCGImage { /// The underlying image. /// /// `CGImage` and `UIImage` are sendable, but `NSImage` is not. `NSImage` @@ -127,8 +127,8 @@ public struct _AttachableImageContainer: Sendable where Image: Attachable // MARK: - -extension _AttachableImageContainer: AttachableContainer { - public var attachableValue: Image { +extension _AttachableImageWrapper: AttachableWrapper { + public var wrappedValue: Image { image } diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift index cd26c24cc..46a1e11e6 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift @@ -9,7 +9,7 @@ // #if canImport(Foundation) -@_spi(Experimental) public import Testing +public import Testing public import Foundation // This implementation is necessary to let the compiler disambiguate when a type @@ -18,7 +18,9 @@ public import Foundation // (which explicitly document what happens when a type conforms to both // protocols.) -@_spi(Experimental) +/// @Metadata { +/// @Available(Swift, introduced: 6.2) +/// } extension Attachable where Self: Encodable & NSSecureCoding { @_documentation(visibility: private) public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift index 812db0b70..683888801 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift @@ -9,7 +9,7 @@ // #if canImport(Foundation) -@_spi(Experimental) public import Testing +public import Testing private import Foundation /// A common implementation of ``withUnsafeBytes(for:_:)`` that is used when a @@ -53,7 +53,10 @@ func withUnsafeBytes(encoding attachableValue: borrowing E, for attachment // Implement the protocol requirements generically for any encodable value by // encoding to JSON. This lets developers provide trivial conformance to the // protocol for types that already support Codable. -@_spi(Experimental) + +/// @Metadata { +/// @Available(Swift, introduced: 6.2) +/// } extension Attachable where Self: Encodable { /// Encode this value into a buffer using either [`PropertyListEncoder`](https://developer.apple.com/documentation/foundation/propertylistencoder) /// or [`JSONEncoder`](https://developer.apple.com/documentation/foundation/jsonencoder), @@ -86,6 +89,10 @@ extension Attachable where Self: Encodable { /// _and_ [`NSSecureCoding`](https://developer.apple.com/documentation/foundation/nssecurecoding), /// the default implementation of this function uses the value's conformance /// to `Encodable`. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { try _Testing_Foundation.withUnsafeBytes(encoding: self, for: attachment, body) } diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift index c2cc28ea0..4acbf4960 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift @@ -9,13 +9,16 @@ // #if canImport(Foundation) -@_spi(Experimental) public import Testing +public import Testing public import Foundation // As with Encodable, implement the protocol requirements for // NSSecureCoding-conformant classes by default. The implementation uses // NSKeyedArchiver for encoding. -@_spi(Experimental) + +/// @Metadata { +/// @Available(Swift, introduced: 6.2) +/// } extension Attachable where Self: NSSecureCoding { /// Encode this object using [`NSKeyedArchiver`](https://developer.apple.com/documentation/foundation/nskeyedarchiver) /// into a buffer, then call a function and pass that buffer to it. @@ -46,6 +49,10 @@ extension Attachable where Self: NSSecureCoding { /// _and_ [`NSSecureCoding`](https://developer.apple.com/documentation/foundation/nssecurecoding), /// the default implementation of this function uses the value's conformance /// to `Encodable`. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { let format = try EncodingFormat(for: attachment) diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Attachment+URL.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Attachment+URL.swift index dbf7e2688..83c3909be 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachment+URL.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachment+URL.swift @@ -9,7 +9,7 @@ // #if canImport(Foundation) -@_spi(Experimental) public import Testing +public import Testing public import Foundation #if !SWT_NO_PROCESS_SPAWNING && os(Windows) @@ -32,8 +32,7 @@ extension URL { } } -@_spi(Experimental) -extension Attachment where AttachableValue == _AttachableURLContainer { +extension Attachment where AttachableValue == _AttachableURLWrapper { #if SWT_TARGET_OS_APPLE /// An operation queue to use for asynchronously reading data from disk. private static let _operationQueue = OperationQueue() @@ -51,6 +50,10 @@ extension Attachment where AttachableValue == _AttachableURLContainer { /// attachment. /// /// - Throws: Any error that occurs attempting to read from `url`. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } public init( contentsOf url: URL, named preferredName: String? = nil, @@ -91,8 +94,8 @@ extension Attachment where AttachableValue == _AttachableURLContainer { } #endif - let urlContainer = _AttachableURLContainer(url: url, data: data, isCompressedDirectory: isDirectory) - self.init(urlContainer, named: preferredName, sourceLocation: sourceLocation) + let urlWrapper = _AttachableURLWrapper(url: url, data: data, isCompressedDirectory: isDirectory) + self.init(urlWrapper, named: preferredName, sourceLocation: sourceLocation) } } diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Data+Attachable.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Data+Attachable.swift index 38233cd3c..ce7b719a9 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Data+Attachable.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Data+Attachable.swift @@ -9,11 +9,16 @@ // #if canImport(Foundation) -@_spi(Experimental) public import Testing +public import Testing public import Foundation -@_spi(Experimental) +/// @Metadata { +/// @Available(Swift, introduced: 6.2) +/// } extension Data: Attachable { + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { try withUnsafeBytes(body) } diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/EncodingFormat.swift b/Sources/Overlays/_Testing_Foundation/Attachments/EncodingFormat.swift index bbbe934ab..49499a8c2 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/EncodingFormat.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/EncodingFormat.swift @@ -9,7 +9,7 @@ // #if canImport(Foundation) -@_spi(Experimental) import Testing +import Testing import Foundation /// An enumeration describing the encoding formats we support for `Encodable` diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/_AttachableURLContainer.swift b/Sources/Overlays/_Testing_Foundation/Attachments/_AttachableURLWrapper.swift similarity index 90% rename from Sources/Overlays/_Testing_Foundation/Attachments/_AttachableURLContainer.swift rename to Sources/Overlays/_Testing_Foundation/Attachments/_AttachableURLWrapper.swift index c7a223a51..d6be53c80 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/_AttachableURLContainer.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/_AttachableURLWrapper.swift @@ -9,7 +9,7 @@ // #if canImport(Foundation) -@_spi(Experimental) public import Testing +public import Testing public import Foundation /// A wrapper type representing file system objects and URLs that can be @@ -17,8 +17,7 @@ public import Foundation /// /// You do not need to use this type directly. Instead, initialize an instance /// of ``Attachment`` using a file URL. -@_spi(Experimental) -public struct _AttachableURLContainer: Sendable { +public struct _AttachableURLWrapper: Sendable { /// The underlying URL. var url: URL @@ -31,8 +30,8 @@ public struct _AttachableURLContainer: Sendable { // MARK: - -extension _AttachableURLContainer: AttachableContainer { - public var attachableValue: URL { +extension _AttachableURLWrapper: AttachableWrapper { + public var wrappedValue: URL { url } diff --git a/Sources/Overlays/_Testing_Foundation/CMakeLists.txt b/Sources/Overlays/_Testing_Foundation/CMakeLists.txt index 54a340323..9343960ab 100644 --- a/Sources/Overlays/_Testing_Foundation/CMakeLists.txt +++ b/Sources/Overlays/_Testing_Foundation/CMakeLists.txt @@ -7,7 +7,7 @@ # See http://swift.org/CONTRIBUTORS.txt for Swift project authors add_library(_Testing_Foundation - Attachments/_AttachableURLContainer.swift + Attachments/_AttachableURLWrapper.swift Attachments/EncodingFormat.swift Attachments/Attachment+URL.swift Attachments/Attachable+NSSecureCoding.swift diff --git a/Sources/Testing/ABI/ABI.Record+Streaming.swift b/Sources/Testing/ABI/ABI.Record+Streaming.swift index 7b86cb438..1aa1362ec 100644 --- a/Sources/Testing/ABI/ABI.Record+Streaming.swift +++ b/Sources/Testing/ABI/ABI.Record+Streaming.swift @@ -12,39 +12,6 @@ private import Foundation extension ABI.Version { - /// Post-process encoded JSON and write it to a file. - /// - /// - Parameters: - /// - json: The JSON to write. - /// - file: The file to write to. - /// - /// - Throws: Whatever is thrown when writing to `file`. - private static func _asJSONLine(_ json: UnsafeRawBufferPointer, _ eventHandler: (_ recordJSON: UnsafeRawBufferPointer) throws -> Void) rethrows { - // We don't actually expect the JSON encoder to produce output containing - // newline characters, so in debug builds we'll log a diagnostic message. - if _slowPath(json.contains(where: \.isASCIINewline)) { -#if DEBUG && !SWT_NO_FILE_IO - let message = Event.ConsoleOutputRecorder.warning( - "JSON encoder produced one or more newline characters while encoding an event to JSON. Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new", - options: .for(.stderr) - ) -#if SWT_TARGET_OS_APPLE - try? FileHandle.stderr.write(message) -#else - print(message) -#endif -#endif - - // Remove the newline characters to conform to JSON lines specification. - var json = Array(json) - json.removeAll(where: \.isASCIINewline) - try json.withUnsafeBytes(eventHandler) - } else { - // No newlines found, no need to copy the buffer. - try eventHandler(json) - } - } - static func eventHandler( encodeAsJSONLines: Bool, forwardingTo eventHandler: @escaping @Sendable (_ recordJSON: UnsafeRawBufferPointer) -> Void @@ -52,7 +19,7 @@ extension ABI.Version { // Encode as JSON Lines if requested. var eventHandlerCopy = eventHandler if encodeAsJSONLines { - eventHandlerCopy = { @Sendable in _asJSONLine($0, eventHandler) } + eventHandlerCopy = { @Sendable in JSON.asJSONLine($0, eventHandler) } } let humanReadableOutputRecorder = Event.HumanReadableOutputRecorder() diff --git a/Sources/Testing/ABI/Encoded/ABI.EncodedEvent.swift b/Sources/Testing/ABI/Encoded/ABI.EncodedEvent.swift index b8bafdde1..73e7db2ac 100644 --- a/Sources/Testing/ABI/Encoded/ABI.EncodedEvent.swift +++ b/Sources/Testing/ABI/Encoded/ABI.EncodedEvent.swift @@ -27,7 +27,7 @@ extension ABI { case testStarted case testCaseStarted case issueRecorded - case valueAttached = "_valueAttached" + case valueAttached case testCaseEnded case testEnded case testSkipped @@ -50,9 +50,7 @@ extension ABI { /// /// The value of this property is `nil` unless the value of the /// ``kind-swift.property`` property is ``Kind-swift.enum/valueAttached``. - /// - /// - Warning: Attachments are not yet part of the JSON schema. - var _attachment: EncodedAttachment? + var attachment: EncodedAttachment? /// Human-readable messages associated with this event that can be presented /// to the user. @@ -82,7 +80,7 @@ extension ABI { issue = EncodedIssue(encoding: recordedIssue, in: eventContext) case let .valueAttached(attachment): kind = .valueAttached - _attachment = EncodedAttachment(encoding: attachment, in: eventContext) + self.attachment = EncodedAttachment(encoding: attachment, in: eventContext) case .testCaseEnded: if eventContext.test?.isParameterized == false { return nil diff --git a/Sources/Testing/ABI/EntryPoints/EntryPoint.swift b/Sources/Testing/ABI/EntryPoints/EntryPoint.swift index c72542d65..7a2e63003 100644 --- a/Sources/Testing/ABI/EntryPoints/EntryPoint.swift +++ b/Sources/Testing/ABI/EntryPoints/EntryPoint.swift @@ -42,7 +42,7 @@ func entryPoint(passing args: __CommandLineArguments_v0?, eventHandler: Event.Ha // Set up the event handler. configuration.eventHandler = { [oldEventHandler = configuration.eventHandler] event, context in - if case let .issueRecorded(issue) = event.kind, !issue.isKnown, issue.severity >= .error { + if case let .issueRecorded(issue) = event.kind, issue.isFailure { exitCode.withLock { exitCode in exitCode = EXIT_FAILURE } @@ -285,8 +285,8 @@ public struct __CommandLineArguments_v0: Sendable { /// The value of the `--repeat-until` argument. public var repeatUntil: String? - /// The value of the `--experimental-attachments-path` argument. - public var experimentalAttachmentsPath: String? + /// The value of the `--attachments-path` argument. + public var attachmentsPath: String? /// Whether or not the experimental warning issue severity feature should be /// enabled. @@ -314,7 +314,7 @@ extension __CommandLineArguments_v0: Codable { case skip case repetitions case repeatUntil - case experimentalAttachmentsPath + case attachmentsPath } } @@ -396,8 +396,9 @@ func parseCommandLineArguments(from args: [String]) throws -> __CommandLineArgum } // Attachment output - if let attachmentsPathIndex = args.firstIndex(of: "--experimental-attachments-path"), !isLastArgument(at: attachmentsPathIndex) { - result.experimentalAttachmentsPath = args[args.index(after: attachmentsPathIndex)] + if let attachmentsPathIndex = args.firstIndex(of: "--attachments-path") ?? args.firstIndex(of: "--experimental-attachments-path"), + !isLastArgument(at: attachmentsPathIndex) { + result.attachmentsPath = args[args.index(after: attachmentsPathIndex)] } #endif @@ -509,9 +510,9 @@ public func configurationForEntryPoint(from args: __CommandLineArguments_v0) thr } // Attachment output. - if let attachmentsPath = args.experimentalAttachmentsPath { + if let attachmentsPath = args.attachmentsPath { guard fileExists(atPath: attachmentsPath) else { - throw _EntryPointError.invalidArgument("--experimental-attachments-path", value: attachmentsPath) + throw _EntryPointError.invalidArgument("---attachments-path", value: attachmentsPath) } configuration.attachmentsPath = attachmentsPath } diff --git a/Sources/Testing/Attachments/Attachable.swift b/Sources/Testing/Attachments/Attachable.swift index 09a7e0b78..be466940b 100644 --- a/Sources/Testing/Attachments/Attachable.swift +++ b/Sources/Testing/Attachments/Attachable.swift @@ -24,9 +24,12 @@ /// A type should conform to this protocol if it can be represented as a /// sequence of bytes that would be diagnostically useful if a test fails. If a /// type cannot conform directly to this protocol (such as a non-final class or -/// a type declared in a third-party module), you can create a container type -/// that conforms to ``AttachableContainer`` to act as a proxy. -@_spi(Experimental) +/// a type declared in a third-party module), you can create a wrapper type that +/// conforms to ``AttachableWrapper`` to act as a proxy. +/// +/// @Metadata { +/// @Available(Swift, introduced: 6.2) +/// } public protocol Attachable: ~Copyable { /// An estimate of the number of bytes of memory needed to store this value as /// an attachment. @@ -42,6 +45,10 @@ public protocol Attachable: ~Copyable { /// /// - Complexity: O(1) unless `Self` conforms to `Collection`, in which case /// up to O(_n_) where _n_ is the length of the collection. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } var estimatedAttachmentByteCount: Int? { get } /// Call a function and pass a buffer representing this instance to it. @@ -64,6 +71,10 @@ public protocol Attachable: ~Copyable { /// the buffer to contain an image in PNG format, JPEG format, etc., but it /// would not be idiomatic for the buffer to contain a textual description of /// the image. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } borrowing func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R /// Generate a preferred name for the given attachment. @@ -80,6 +91,10 @@ public protocol Attachable: ~Copyable { /// when adding `attachment` to a test report or persisting it to storage. The /// default implementation of this function returns `suggestedName` without /// any changes. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } borrowing func preferredName(for attachment: borrowing Attachment, basedOn suggestedName: String) -> String } @@ -119,28 +134,24 @@ extension Attachable where Self: StringProtocol { // Implement the protocol requirements for byte arrays and buffers so that // developers can attach raw data when needed. -@_spi(Experimental) extension Array: Attachable { public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { try withUnsafeBytes(body) } } -@_spi(Experimental) extension ContiguousArray: Attachable { public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { try withUnsafeBytes(body) } } -@_spi(Experimental) extension ArraySlice: Attachable { public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { try withUnsafeBytes(body) } } -@_spi(Experimental) extension String: Attachable { public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { var selfCopy = self @@ -150,7 +161,6 @@ extension String: Attachable { } } -@_spi(Experimental) extension Substring: Attachable { public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { var selfCopy = self diff --git a/Sources/Testing/Attachments/AttachableContainer.swift b/Sources/Testing/Attachments/AttachableWrapper.swift similarity index 70% rename from Sources/Testing/Attachments/AttachableContainer.swift rename to Sources/Testing/Attachments/AttachableWrapper.swift index e4d716e9c..81df52d4d 100644 --- a/Sources/Testing/Attachments/AttachableContainer.swift +++ b/Sources/Testing/Attachments/AttachableWrapper.swift @@ -21,11 +21,22 @@ /// A type can conform to this protocol if it represents another type that /// cannot directly conform to ``Attachable``, such as a non-final class or a /// type declared in a third-party module. -@_spi(Experimental) -public protocol AttachableContainer: Attachable, ~Copyable { - /// The type of the attachable value represented by this type. - associatedtype AttachableValue +/// +/// @Metadata { +/// @Available(Swift, introduced: 6.2) +/// } +public protocol AttachableWrapper: Attachable, ~Copyable { + /// The type of the underlying value represented by this type. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } + associatedtype Wrapped - /// The attachable value represented by this instance. - var attachableValue: AttachableValue { get } + /// The underlying value represented by this instance. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } + var wrappedValue: Wrapped { get } } diff --git a/Sources/Testing/Attachments/Attachment.swift b/Sources/Testing/Attachments/Attachment.swift index d7c1cddb7..7468834bf 100644 --- a/Sources/Testing/Attachments/Attachment.swift +++ b/Sources/Testing/Attachments/Attachment.swift @@ -18,7 +18,10 @@ private import _TestingInternals /// of some type that conforms to ``Attachable``. Initialize an instance of /// ``Attachment`` with that value and, optionally, a preferred filename to use /// when writing to disk. -@_spi(Experimental) +/// +/// @Metadata { +/// @Available(Swift, introduced: 6.2) +/// } public struct Attachment: ~Copyable where AttachableValue: Attachable & ~Copyable { /// Storage for ``attachableValue-7dyjv``. fileprivate var _attachableValue: AttachableValue @@ -27,9 +30,9 @@ public struct Attachment: ~Copyable where AttachableValue: Atta /// /// If a developer sets the ``Configuration/attachmentsPath`` property of the /// current configuration before running tests, or if a developer passes - /// `--experimental-attachments-path` on the command line, then attachments - /// will be automatically written to disk when they are attached and the value - /// of this property will describe the path where they were written. + /// `--attachments-path` on the command line, then attachments will be + /// automatically written to disk when they are attached and the value of this + /// property will describe the path where they were written. /// /// If no destination path is set, or if an error occurred while writing this /// attachment to disk, the value of this property is `nil`. @@ -51,6 +54,10 @@ public struct Attachment: ~Copyable where AttachableValue: Atta /// testing library may substitute a different filename as needed. If the /// value of this property has not been explicitly set, the testing library /// will attempt to generate its own value. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } public var preferredName: String { let suggestedName = if let _preferredName, !_preferredName.isEmpty { _preferredName @@ -90,6 +97,10 @@ extension Attachment where AttachableValue: ~Copyable { /// - sourceLocation: The source location of the call to this initializer. /// This value is used when recording issues associated with the /// attachment. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } public init(_ attachableValue: consuming AttachableValue, named preferredName: String? = nil, sourceLocation: SourceLocation = #_sourceLocation) { self._attachableValue = attachableValue self._preferredName = preferredName @@ -97,7 +108,7 @@ extension Attachment where AttachableValue: ~Copyable { } } -@_spi(Experimental) @_spi(ForToolsIntegrationOnly) +@_spi(ForToolsIntegrationOnly) extension Attachment where AttachableValue == AnyAttachable { /// Create a type-erased attachment from an instance of ``Attachment``. /// @@ -105,7 +116,7 @@ extension Attachment where AttachableValue == AnyAttachable { /// - attachment: The attachment to type-erase. fileprivate init(_ attachment: Attachment) { self.init( - _attachableValue: AnyAttachable(attachableValue: attachment.attachableValue), + _attachableValue: AnyAttachable(wrappedValue: attachment.attachableValue), fileSystemPath: attachment.fileSystemPath, _preferredName: attachment._preferredName, sourceLocation: attachment.sourceLocation @@ -114,7 +125,7 @@ extension Attachment where AttachableValue == AnyAttachable { } #endif -/// A type-erased container type that represents any attachable value. +/// A type-erased wrapper type that represents any attachable value. /// /// This type is not generally visible to developers. It is used when posting /// events of kind ``Event/Kind/valueAttached(_:)``. Test tools authors who use @@ -125,54 +136,55 @@ extension Attachment where AttachableValue == AnyAttachable { // Swift's type system requires that this type be at least as visible as // `Event.Kind.valueAttached(_:)`, otherwise it would be declared private. // } -@_spi(Experimental) @_spi(ForToolsIntegrationOnly) -public struct AnyAttachable: AttachableContainer, Copyable, Sendable { +@_spi(ForToolsIntegrationOnly) +public struct AnyAttachable: AttachableWrapper, Copyable, Sendable { #if !SWT_NO_LAZY_ATTACHMENTS - public typealias AttachableValue = any Attachable & Sendable /* & Copyable rdar://137614425 */ + public typealias Wrapped = any Attachable & Sendable /* & Copyable rdar://137614425 */ #else - public typealias AttachableValue = [UInt8] + public typealias Wrapped = [UInt8] #endif - public var attachableValue: AttachableValue + public var wrappedValue: Wrapped - init(attachableValue: AttachableValue) { - self.attachableValue = attachableValue + init(wrappedValue: Wrapped) { + self.wrappedValue = wrappedValue } public var estimatedAttachmentByteCount: Int? { - attachableValue.estimatedAttachmentByteCount + wrappedValue.estimatedAttachmentByteCount } public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { - func open(_ attachableValue: T, for attachment: borrowing Attachment) throws -> R where T: Attachable & Sendable & Copyable { + func open(_ wrappedValue: T, for attachment: borrowing Attachment) throws -> R where T: Attachable & Sendable & Copyable { let temporaryAttachment = Attachment( - _attachableValue: attachableValue, + _attachableValue: wrappedValue, fileSystemPath: attachment.fileSystemPath, _preferredName: attachment._preferredName, sourceLocation: attachment.sourceLocation ) return try temporaryAttachment.withUnsafeBytes(body) } - return try open(attachableValue, for: attachment) + return try open(wrappedValue, for: attachment) } public borrowing func preferredName(for attachment: borrowing Attachment, basedOn suggestedName: String) -> String { - func open(_ attachableValue: T, for attachment: borrowing Attachment) -> String where T: Attachable & Sendable & Copyable { + func open(_ wrappedValue: T, for attachment: borrowing Attachment) -> String where T: Attachable & Sendable & Copyable { let temporaryAttachment = Attachment( - _attachableValue: attachableValue, + _attachableValue: wrappedValue, fileSystemPath: attachment.fileSystemPath, _preferredName: attachment._preferredName, sourceLocation: attachment.sourceLocation ) return temporaryAttachment.preferredName } - return open(attachableValue, for: attachment) + return open(wrappedValue, for: attachment) } } // MARK: - Describing an attachment extension Attachment where AttachableValue: ~Copyable { + @_documentation(visibility: private) public var description: String { let typeInfo = TypeInfo(describing: AttachableValue.self) return #""\#(preferredName)": instance of '\#(typeInfo.unqualifiedName)'"# @@ -180,6 +192,9 @@ extension Attachment where AttachableValue: ~Copyable { } extension Attachment: CustomStringConvertible { + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } public var description: String { #""\#(preferredName)": \#(String(describingForTest: attachableValue))"# } @@ -187,9 +202,12 @@ extension Attachment: CustomStringConvertible { // MARK: - Getting an attachable value from an attachment -@_spi(Experimental) extension Attachment where AttachableValue: ~Copyable { /// The value of this attachment. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } @_disfavoredOverload public var attachableValue: AttachableValue { _read { yield _attachableValue @@ -197,21 +215,24 @@ extension Attachment where AttachableValue: ~Copyable { } } -@_spi(Experimental) -extension Attachment where AttachableValue: AttachableContainer & ~Copyable { +extension Attachment where AttachableValue: AttachableWrapper & ~Copyable { /// The value of this attachment. /// - /// When the attachable value's type conforms to ``AttachableContainer``, the - /// value of this property equals the container's underlying attachable value. + /// When the attachable value's type conforms to ``AttachableWrapper``, the + /// value of this property equals the wrapper's underlying attachable value. /// To access the attachable value as an instance of `T` (where `T` conforms - /// to ``AttachableContainer``), specify the type explicitly: + /// to ``AttachableWrapper``), specify the type explicitly: /// /// ```swift /// let attachableValue = attachment.attachableValue as T /// ``` - public var attachableValue: AttachableValue.AttachableValue { + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } + public var attachableValue: AttachableValue.Wrapped { _read { - yield attachableValue.attachableValue + yield attachableValue.wrappedValue } } } @@ -235,6 +256,10 @@ extension Attachment where AttachableValue: Sendable & Copyable { /// disk. /// /// An attachment can only be attached once. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } @_documentation(visibility: private) public static func record(_ attachment: consuming Self, sourceLocation: SourceLocation = #_sourceLocation) { var attachmentCopy = Attachment(attachment) @@ -263,6 +288,10 @@ extension Attachment where AttachableValue: Sendable & Copyable { /// attaches it to the current test. /// /// An attachment can only be attached once. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } @_documentation(visibility: private) public static func record(_ attachableValue: consuming AttachableValue, named preferredName: String? = nil, sourceLocation: SourceLocation = #_sourceLocation) { record(Self(attachableValue, named: preferredName, sourceLocation: sourceLocation), sourceLocation: sourceLocation) @@ -286,12 +315,16 @@ extension Attachment where AttachableValue: ~Copyable { /// disk. /// /// An attachment can only be attached once. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } public static func record(_ attachment: consuming Self, sourceLocation: SourceLocation = #_sourceLocation) { do { let attachmentCopy = try attachment.withUnsafeBytes { buffer in - let attachableContainer = AnyAttachable(attachableValue: Array(buffer)) + let attachableWrapper = AnyAttachable(wrappedValue: Array(buffer)) return Attachment( - _attachableValue: attachableContainer, + _attachableValue: attachableWrapper, fileSystemPath: attachment.fileSystemPath, _preferredName: attachment.preferredName, // invokes preferredName(for:basedOn:) sourceLocation: sourceLocation @@ -325,6 +358,10 @@ extension Attachment where AttachableValue: ~Copyable { /// attaches it to the current test. /// /// An attachment can only be attached once. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } public static func record(_ attachableValue: consuming AttachableValue, named preferredName: String? = nil, sourceLocation: SourceLocation = #_sourceLocation) { record(Self(attachableValue, named: preferredName, sourceLocation: sourceLocation), sourceLocation: sourceLocation) } @@ -349,6 +386,10 @@ extension Attachment where AttachableValue: ~Copyable { /// test report or to a file on disk. This function calls the /// ``Attachable/withUnsafeBytes(for:_:)`` function on this attachment's /// ``attachableValue-2tnj5`` property. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } @inlinable public borrowing func withUnsafeBytes(_ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { try attachableValue.withUnsafeBytes(for: self, body) } @@ -371,9 +412,9 @@ extension Attachment where AttachableValue: ~Copyable { /// The attachment is written to a file _within_ `directoryPath`, whose name /// is derived from the value of the ``Attachment/preferredName`` property. /// - /// If you pass `--experimental-attachments-path` to `swift test`, the testing - /// library automatically uses this function to persist attachments to the - /// directory you specify. + /// If you pass `--attachments-path` to `swift test`, the testing library + /// automatically uses this function to persist attachments to the directory + /// you specify. /// /// This function does not get or set the value of the attachment's /// ``fileSystemPath`` property. The caller is responsible for setting the @@ -382,7 +423,7 @@ extension Attachment where AttachableValue: ~Copyable { /// This function is provided as a convenience to allow tools authors to write /// attachments to persistent storage the same way that Swift Package Manager /// does. You are not required to use this function. - @_spi(Experimental) @_spi(ForToolsIntegrationOnly) + @_spi(ForToolsIntegrationOnly) public borrowing func write(toFileInDirectoryAtPath directoryPath: String) throws -> String { try write( toFileInDirectoryAtPath: directoryPath, diff --git a/Sources/Testing/CMakeLists.txt b/Sources/Testing/CMakeLists.txt index 7e07636d5..b4e865427 100644 --- a/Sources/Testing/CMakeLists.txt +++ b/Sources/Testing/CMakeLists.txt @@ -22,7 +22,7 @@ add_library(Testing ABI/Encoded/ABI.EncodedMessage.swift ABI/Encoded/ABI.EncodedTest.swift Attachments/Attachable.swift - Attachments/AttachableContainer.swift + Attachments/AttachableWrapper.swift Attachments/Attachment.swift Events/Clock.swift Events/Event.swift @@ -32,6 +32,7 @@ add_library(Testing Events/Recorder/Event.Symbol.swift Events/TimeValue.swift ExitTests/ExitTest.swift + ExitTests/ExitTest.CapturedValue.swift ExitTests/ExitTest.Condition.swift ExitTests/ExitTest.Result.swift ExitTests/SpawnProcess.swift diff --git a/Sources/Testing/Discovery+Macro.swift b/Sources/Testing/Discovery+Macro.swift index 97b925e55..35b276efe 100644 --- a/Sources/Testing/Discovery+Macro.swift +++ b/Sources/Testing/Discovery+Macro.swift @@ -8,15 +8,6 @@ // See https://swift.org/CONTRIBUTORS.txt for Swift project authors // -@_spi(Experimental) @_spi(ForToolsIntegrationOnly) internal import _TestDiscovery - -/// A shadow declaration of `_TestDiscovery.DiscoverableAsTestContent` that -/// allows us to add public conformances to it without causing the -/// `_TestDiscovery` module to appear in `Testing.private.swiftinterface`. -/// -/// This protocol is not part of the public interface of the testing library. -protocol DiscoverableAsTestContent: _TestDiscovery.DiscoverableAsTestContent, ~Copyable {} - /// The type of the accessor function used to access a test content record. /// /// The signature of this function type must match that of the corresponding diff --git a/Sources/Testing/Events/Event.swift b/Sources/Testing/Events/Event.swift index b81f1c2c7..0be14ae88 100644 --- a/Sources/Testing/Events/Event.swift +++ b/Sources/Testing/Events/Event.swift @@ -102,7 +102,6 @@ public struct Event: Sendable { /// /// - Parameters: /// - attachment: The attachment that was created. - @_spi(Experimental) indirect case valueAttached(_ attachment: Attachment) /// A test ended. diff --git a/Sources/Testing/Events/Recorder/Event.Symbol.swift b/Sources/Testing/Events/Recorder/Event.Symbol.swift index 846fb2d4d..3354691fb 100644 --- a/Sources/Testing/Events/Recorder/Event.Symbol.swift +++ b/Sources/Testing/Events/Recorder/Event.Symbol.swift @@ -44,7 +44,6 @@ extension Event { case details /// The symbol to use when describing an instance of ``Attachment``. - @_spi(Experimental) case attachment } } diff --git a/Sources/Testing/ExitTests/ExitTest.CapturedValue.swift b/Sources/Testing/ExitTests/ExitTest.CapturedValue.swift new file mode 100644 index 000000000..d4c84e446 --- /dev/null +++ b/Sources/Testing/ExitTests/ExitTest.CapturedValue.swift @@ -0,0 +1,186 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2023–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 +// + +@_spi(Experimental) @_spi(ForToolsIntegrationOnly) +#if SWT_NO_EXIT_TESTS +@available(*, unavailable, message: "Exit tests are not available on this platform.") +#endif +extension ExitTest { + /// A type representing a value captured by an exit test's body. + /// + /// An instance of this type may represent the actual value that was captured + /// when the exit test was invoked. In the child process created by the + /// current exit test handler, instances will initially only have the type of + /// the value, but not the value itself. + /// + /// Instances of this type are created automatically by the testing library + /// for all elements in an exit test body's capture list and are stored in the + /// exit test's ``capturedValues`` property. For example, given the following + /// exit test: + /// + /// ```swift + /// await #expect(exitsWith: .failure) { [a = a as T, b = b as U, c = c as V] in + /// ... + /// } + /// ``` + /// + /// There are three captured values in its ``capturedValues`` property. These + /// values are captured at the time the exit test is called, as they would be + /// if the closure were called locally. + /// + /// The current exit test handler is responsible for encoding and decoding + /// instances of this type. When the handler is called, it is passed an + /// instance of ``ExitTest``. The handler encodes the values in that + /// instance's ``capturedValues`` property, then passes the encoded forms of + /// those values to the child process. The encoding format and message-passing + /// interface are implementation details of the exit test handler. + /// + /// When the child process calls ``ExitTest/find(identifiedBy:)``, it receives + /// an instance of ``ExitTest`` whose ``capturedValues`` property contains + /// type information but no values. The child process decodes the values it + /// encoded in the parent process and then updates the ``wrappedValue`` + /// property of each element in the array before calling the exit test's body. + public struct CapturedValue: Sendable { +#if !SWT_NO_EXIT_TESTS + /// An enumeration of the different states a captured value can have. + private enum _Kind: Sendable { + /// The runtime value of the captured value is known. + case wrappedValue(any Codable & Sendable) + + /// Only the type of the captured value is known. + case typeOnly(any (Codable & Sendable).Type) + } + + /// The current state of this instance. + private var _kind: _Kind + + init(wrappedValue: some Codable & Sendable) { + _kind = .wrappedValue(wrappedValue) + } + + init(typeOnly type: (some Codable & Sendable).Type) { + _kind = .typeOnly(type) + } +#endif + + /// The underlying value captured by this instance at runtime. + /// + /// In a child process created by the current exit test handler, the value + /// of this property is `nil` until the entry point sets it. + public var wrappedValue: (any Codable & Sendable)? { + get { +#if !SWT_NO_EXIT_TESTS + if case let .wrappedValue(wrappedValue) = _kind { + return wrappedValue + } + return nil +#else + fatalError("Unsupported") +#endif + } + + set { +#if !SWT_NO_EXIT_TESTS + let type = typeOfWrappedValue + + func validate(_ newValue: T, is expectedType: U.Type) { + assert(newValue is U, "Attempted to set a captured value to an instance of '\(String(describingForTest: T.self))', but an instance of '\(String(describingForTest: U.self))' was expected.") + } + validate(newValue, is: type) + + if let newValue { + _kind = .wrappedValue(newValue) + } else { + _kind = .typeOnly(type) + } +#else + fatalError("Unsupported") +#endif + } + } + + /// The type of the underlying value captured by this instance. + /// + /// This type is known at compile time and is always available, even before + /// this instance's ``wrappedValue`` property is set. + public var typeOfWrappedValue: any (Codable & Sendable).Type { +#if !SWT_NO_EXIT_TESTS + switch _kind { + case let .wrappedValue(wrappedValue): + type(of: wrappedValue) + case let .typeOnly(type): + type + } +#else + fatalError("Unsupported") +#endif + } + } +} + +#if !SWT_NO_EXIT_TESTS +// MARK: - Collection conveniences + +extension Array where Element == ExitTest.CapturedValue { + init(_ wrappedValues: repeat each T) where repeat each T: Codable & Sendable { + self.init() + repeat self.append(ExitTest.CapturedValue(wrappedValue: each wrappedValues)) + } + + init(_ typesOfWrappedValues: repeat (each T).Type) where repeat each T: Codable & Sendable { + self.init() + repeat self.append(ExitTest.CapturedValue(typeOnly: (each typesOfWrappedValues).self)) + } +} + +extension Collection where Element == ExitTest.CapturedValue { + /// Cast the elements in this collection to a tuple of their wrapped values. + /// + /// - Returns: A tuple containing the wrapped values of the elements in this + /// collection. + /// + /// - Throws: If an expected value could not be found or was not of the + /// type the caller expected. + /// + /// This function assumes that the entry point function has already set the + /// ``wrappedValue`` property of each element in this collection. + func takeCapturedValues() throws -> (repeat each T) { + func nextValue( + as type: U.Type, + from capturedValues: inout SubSequence + ) throws -> U { + // Get the next captured value in the collection. If we run out of values + // before running out of parameter pack elements, then something in the + // exit test handler or entry point is likely broken. + guard let wrappedValue = capturedValues.first?.wrappedValue else { + let actualCount = self.count + let expectedCount = parameterPackCount(repeat (each T).self) + fatalError("Found fewer captured values (\(actualCount)) than expected (\(expectedCount)) when passing them to the current exit test.") + } + + // Next loop, get the next element. (We're mutating a subsequence, not + // self, so this is generally an O(1) operation.) + capturedValues = capturedValues.dropFirst() + + // Make sure the value is of the correct type. If it's not, that's also + // probably a problem with the exit test handler or entry point. + guard let wrappedValue = wrappedValue as? U else { + fatalError("Expected captured value at index \(capturedValues.startIndex) with type '\(String(describingForTest: U.self))', but found an instance of '\(String(describingForTest: Swift.type(of: wrappedValue)))' instead.") + } + + return wrappedValue + } + + var capturedValues = self[...] + return (repeat try nextValue(as: (each T).self, from: &capturedValues)) + } +} +#endif + diff --git a/Sources/Testing/ExitTests/ExitTest.Condition.swift b/Sources/Testing/ExitTests/ExitTest.Condition.swift index 10f2a6ff0..d2c637d79 100644 --- a/Sources/Testing/ExitTests/ExitTest.Condition.swift +++ b/Sources/Testing/ExitTests/ExitTest.Condition.swift @@ -27,6 +27,9 @@ extension ExitTest { /// The exit test must exit with a particular exit status. case statusAtExit(StatusAtExit) + /// The exit test must exit successfully. + case success + /// The exit test must exit with any failure. case failure } @@ -46,15 +49,7 @@ extension ExitTest.Condition { /// A condition that matches when a process terminates successfully with exit /// code `EXIT_SUCCESS`. public static var success: Self { - // Strictly speaking, the C standard treats 0 as a successful exit code and - // potentially distinct from EXIT_SUCCESS. To my knowledge, no modern - // operating system defines EXIT_SUCCESS to any value other than 0, so the - // distinction is academic. -#if !SWT_NO_EXIT_TESTS - .exitCode(EXIT_SUCCESS) -#else - fatalError("Unsupported") -#endif + Self(_kind: .success) } /// A condition that matches when a process terminates abnormally with any @@ -122,6 +117,29 @@ extension ExitTest.Condition { } } +// MARK: - CustomStringConvertible + +@_spi(Experimental) +#if SWT_NO_EXIT_TESTS +@available(*, unavailable, message: "Exit tests are not available on this platform.") +#endif +extension ExitTest.Condition: CustomStringConvertible { + public var description: String { +#if !SWT_NO_EXIT_TESTS + switch _kind { + case .failure: + ".failure" + case .success: + ".success" + case let .statusAtExit(statusAtExit): + String(describing: statusAtExit) + } +#else + fatalError("Unsupported") +#endif + } +} + // MARK: - Comparison #if SWT_NO_EXIT_TESTS @@ -139,7 +157,13 @@ extension ExitTest.Condition { /// Two exit test conditions can be compared; if either instance is equal to /// ``failure``, it will compare equal to any instance except ``success``. func isApproximatelyEqual(to statusAtExit: StatusAtExit) -> Bool { + // Strictly speaking, the C standard treats 0 as a successful exit code and + // potentially distinct from EXIT_SUCCESS. To my knowledge, no modern + // operating system defines EXIT_SUCCESS to any value other than 0, so the + // distinction is academic. return switch (self._kind, statusAtExit) { + case let (.success, .exitCode(exitCode)): + exitCode == EXIT_SUCCESS case let (.failure, .exitCode(exitCode)): exitCode != EXIT_SUCCESS case (.failure, .signal): diff --git a/Sources/Testing/ExitTests/ExitTest.swift b/Sources/Testing/ExitTests/ExitTest.swift index 95c3ec696..2ad905379 100644 --- a/Sources/Testing/ExitTests/ExitTest.swift +++ b/Sources/Testing/ExitTests/ExitTest.swift @@ -66,12 +66,18 @@ public struct ExitTest: Sendable, ~Copyable { @_spi(ForToolsIntegrationOnly) public var id: ID + /// An exit test body function. + /// + /// - Parameters: + /// - exitTest: The exit test to which this body closure belongs. + fileprivate typealias Body = @Sendable (_ exitTest: inout Self) async throws -> Void + /// The body closure of the exit test. /// /// Do not invoke this closure directly. Instead, invoke ``callAsFunction()`` /// to run the exit test. Running the exit test will always terminate the /// current process. - fileprivate var body: @Sendable () async throws -> Void = {} + fileprivate var body: Body = { _ in } /// Storage for ``observedValues``. /// @@ -108,6 +114,19 @@ public struct ExitTest: Sendable, ~Copyable { } } + /// The set of values captured in the parent process before the exit test is + /// called. + /// + /// This property is automatically set by the testing library when using the + /// built-in exit test handler and entry point functions. Do not modify the + /// value of this property unless you are implementing a custom exit test + /// handler or entry point function. + /// + /// The order of values in this array must be the same between the parent and + /// child processes. + @_spi(Experimental) @_spi(ForToolsIntegrationOnly) + public var capturedValues = [CapturedValue]() + /// Make a copy of this instance. /// /// - Returns: A copy of this instance. @@ -117,6 +136,7 @@ public struct ExitTest: Sendable, ~Copyable { fileprivate borrowing func unsafeCopy() -> Self { var result = Self(id: id, body: body) result._observedValues = _observedValues + result.capturedValues = capturedValues return result } } @@ -245,7 +265,7 @@ extension ExitTest { } do { - try await body() + try await body(&self) } catch { _errorInMain(error) } @@ -258,12 +278,34 @@ extension ExitTest { // MARK: - Discovery -extension ExitTest: DiscoverableAsTestContent { - fileprivate static var testContentKind: TestContentKind { - "exit" - } +extension ExitTest { + /// A type representing an exit test as a test content record. + fileprivate struct Record: Sendable, DiscoverableAsTestContent { + static var testContentKind: TestContentKind { + "exit" + } + + typealias TestContentAccessorHint = ID + + /// The ID of the represented exit test. + var id: ExitTest.ID - fileprivate typealias TestContentAccessorHint = ID + /// The body of the represented exit test. + var body: ExitTest.Body + + /// The set of values captured in the parent process before the exit test is + /// called. + var capturedValues = [CapturedValue]() + + /// Make the exit test represented by this instance. + /// + /// - Returns: A new exit test as represented by this instance. + func makeExitTest() -> ExitTest { + var exitTest = ExitTest(id: id, body: body) + exitTest.capturedValues = capturedValues + return exitTest + } + } /// Store the exit test into the given memory. /// @@ -279,23 +321,37 @@ extension ExitTest: DiscoverableAsTestContent { /// /// - Warning: This function is used to implement the `#expect(exitsWith:)` /// macro. Do not use it directly. - public static func __store( + public static func __store( _ id: (UInt64, UInt64, UInt64, UInt64), - _ body: @escaping @Sendable () async throws -> Void, + _ body: @escaping @Sendable (repeat each T) async throws -> Void, into outValue: UnsafeMutableRawPointer, asTypeAt typeAddress: UnsafeRawPointer, withHintAt hintAddress: UnsafeRawPointer? = nil - ) -> CBool { - let callerExpectedType = TypeInfo(describing: typeAddress.load(as: Any.Type.self)) - let selfType = TypeInfo(describing: Self.self) - guard callerExpectedType == selfType else { + ) -> CBool where repeat each T: Codable & Sendable { +#if !hasFeature(Embedded) + // Check that the type matches. + guard typeAddress.load(as: Any.Type.self) == Record.self else { return false } +#endif + + // Check that the ID matches if provided. let id = ID(id) if let hintedID = hintAddress?.load(as: ID.self), hintedID != id { return false } - outValue.initializeMemory(as: Self.self, to: Self(id: id, body: body)) + + // Wrap the body function in a thunk that decodes any captured state and + // passes it along. + let body: ExitTest.Body = { exitTest in + let values: (repeat each T) = try exitTest.capturedValues.takeCapturedValues() + try await body(repeat each values) + } + + // Construct and return the record. + var record = Record(id: id, body: body) + record.capturedValues = Array(repeat (each T).self) + outValue.initializeMemory(as: Record.self, to: record) return true } } @@ -310,16 +366,16 @@ extension ExitTest { /// - Returns: The specified exit test function, or `nil` if no such exit test /// could be found. public static func find(identifiedBy id: ExitTest.ID) -> Self? { - for record in Self.allTestContentRecords() { - if let exitTest = record.load(withHint: id) { + for record in Record.allTestContentRecords() { + if let exitTest = record.load(withHint: id)?.makeExitTest() { return exitTest } } #if !SWT_NO_LEGACY_TEST_DISCOVERY // Call the legacy lookup function that discovers tests embedded in types. - for record in Self.allTypeMetadataBasedTestContentRecords() { - if let exitTest = record.load(withHint: id) { + for record in Record.allTypeMetadataBasedTestContentRecords() { + if let exitTest = record.load(withHint: id)?.makeExitTest() { return exitTest } } @@ -336,6 +392,7 @@ extension ExitTest { /// /// - Parameters: /// - exitTestID: The unique identifier of the exit test. +/// - capturedValues: Any values captured by the exit test. /// - expectedExitCondition: The expected exit condition. /// - observedValues: An array of key paths representing results from within /// the exit test that should be observed and returned by this macro. The @@ -355,6 +412,7 @@ extension ExitTest { /// convention. func callExitTest( identifiedBy exitTestID: (UInt64, UInt64, UInt64, UInt64), + encodingCapturedValues capturedValues: [ExitTest.CapturedValue], exitsWith expectedExitCondition: ExitTest.Condition, observing observedValues: [any PartialKeyPath & Sendable], expression: __Expression, @@ -369,8 +427,12 @@ func callExitTest( var result: ExitTest.Result do { + // Construct a temporary/local exit test to pass to the exit test handler. var exitTest = ExitTest(id: ExitTest.ID(exitTestID)) exitTest.observedValues = observedValues + exitTest.capturedValues = capturedValues + + // Invoke the exit test handler and wait for the child process to terminate. result = try await configuration.exitTestHandler(exitTest) #if os(Windows) @@ -465,38 +527,81 @@ extension ExitTest { /// are available or the child environment is otherwise terminated. The parent /// environment is then responsible for interpreting those results and /// recording any issues that occur. - public typealias Handler = @Sendable (_ exitTest: borrowing ExitTest) async throws -> ExitTest.Result + public typealias Handler = @Sendable (_ exitTest: borrowing Self) async throws -> ExitTest.Result - /// The back channel file handle set up by the parent process. + /// Make a file handle from the string contained in the given environment + /// variable. + /// + /// - Parameters: + /// - name: The name of the environment variable to read. The value of this + /// environment variable should represent the file handle. The exact value + /// is platform-specific but is generally the file descriptor as a string. + /// - mode: The mode to open the file with, such as `"wb"`. /// - /// The value of this property is a file handle open for writing to which - /// events should be written, or `nil` if the file handle could not be - /// resolved. - private static let _backChannelForEntryPoint: FileHandle? = { - guard let backChannelEnvironmentVariable = Environment.variable(named: "SWT_EXPERIMENTAL_BACKCHANNEL") else { + /// - Returns: A new file handle, or `nil` if one could not be created. + /// + /// The effect of calling this function more than once for the same + /// environment variable is undefined. + private static func _makeFileHandle(forEnvironmentVariableNamed name: String, mode: String) -> FileHandle? { + guard let environmentVariable = Environment.variable(named: name) else { return nil } // Erase the environment variable so that it cannot accidentally be opened // twice (nor, in theory, affect the code of the exit test.) - Environment.setVariable(nil, named: "SWT_EXPERIMENTAL_BACKCHANNEL") + Environment.setVariable(nil, named: name) var fd: CInt? #if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD) - fd = CInt(backChannelEnvironmentVariable) + fd = CInt(environmentVariable) #elseif os(Windows) - if let handle = UInt(backChannelEnvironmentVariable).flatMap(HANDLE.init(bitPattern:)) { - fd = _open_osfhandle(Int(bitPattern: handle), _O_WRONLY | _O_BINARY) + if let handle = UInt(environmentVariable).flatMap(HANDLE.init(bitPattern:)) { + var flags: CInt = switch (mode.contains("r"), mode.contains("w")) { + case (true, true): + _O_RDWR + case (true, false): + _O_RDONLY + case (false, true): + _O_WRONLY + case (false, false): + 0 + } + flags |= _O_BINARY + fd = _open_osfhandle(Int(bitPattern: handle), flags) } #else -#warning("Platform-specific implementation missing: back-channel pipe unavailable") +#warning("Platform-specific implementation missing: additional file descriptors unavailable") #endif guard let fd, fd >= 0 else { return nil } - return try? FileHandle(unsafePOSIXFileDescriptor: fd, mode: "wb") - }() + return try? FileHandle(unsafePOSIXFileDescriptor: fd, mode: mode) + } + + /// Make a string suitable for use as the value of an environment variable + /// that describes the given file handle. + /// + /// - Parameters: + /// - fileHandle: The file handle to represent. + /// + /// - Returns: A string representation of `fileHandle` that can be converted + /// back to a (new) file handle with `_makeFileHandle()`, or `nil` if the + /// file handle could not be converted to a string. + private static func _makeEnvironmentVariable(for fileHandle: borrowing FileHandle) -> String? { +#if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD) + return fileHandle.withUnsafePOSIXFileDescriptor { fd in + fd.map(String.init(describing:)) + } +#elseif os(Windows) + return fileHandle.withUnsafeWindowsHANDLE { handle in + handle.flatMap { String(describing: UInt(bitPattern: $0)) } + } +#else +#warning("Platform-specific implementation missing: additional file descriptors unavailable") + return nil +#endif + } /// Find the exit test function specified in the environment of the current /// process, if any. @@ -531,7 +636,7 @@ extension ExitTest { } // We can't say guard let here because it counts as a consume. - guard _backChannelForEntryPoint != nil else { + guard let backChannel = _makeFileHandle(forEnvironmentVariableNamed: "SWT_EXPERIMENTAL_BACKCHANNEL", mode: "wb") else { return result } @@ -542,9 +647,9 @@ extension ExitTest { // Only forward issue-recorded events. (If we start handling other kinds of // events in the future, we can forward them too.) let eventHandler = ABI.BackChannelVersion.eventHandler(encodeAsJSONLines: true) { json in - _ = try? _backChannelForEntryPoint?.withLock { - try _backChannelForEntryPoint?.write(json) - try _backChannelForEntryPoint?.write("\n") + _ = try? backChannel.withLock { + try backChannel.write(json) + try backChannel.write("\n") } } configuration.eventHandler = { event, eventContext in @@ -553,8 +658,11 @@ extension ExitTest { } } - result.body = { [configuration, body = result.body] in - try await Configuration.withCurrent(configuration, perform: body) + result.body = { [configuration, body = result.body] exitTest in + try await Configuration.withCurrent(configuration) { + try exitTest._decodeCapturedValuesForEntryPoint() + try await body(&exitTest) + } } return result } @@ -624,7 +732,7 @@ extension ExitTest { return result }() - return { exitTest in + @Sendable func result(_ exitTest: borrowing ExitTest) async throws -> ExitTest.Result { let childProcessExecutablePath = try childProcessExecutablePath.get() // Inherit the environment from the parent process and make any necessary @@ -677,36 +785,49 @@ extension ExitTest { var backChannelWriteEnd: FileHandle! try FileHandle.makePipe(readEnd: &backChannelReadEnd, writeEnd: &backChannelWriteEnd) - // Let the child process know how to find the back channel by setting a - // known environment variable to the corresponding file descriptor - // (HANDLE on Windows.) - var backChannelEnvironmentVariable: String? -#if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD) - backChannelEnvironmentVariable = backChannelWriteEnd.withUnsafePOSIXFileDescriptor { fd in - fd.map(String.init(describing:)) - } -#elseif os(Windows) - backChannelEnvironmentVariable = backChannelWriteEnd.withUnsafeWindowsHANDLE { handle in - handle.flatMap { String(describing: UInt(bitPattern: $0)) } - } -#else -#warning("Platform-specific implementation missing: back-channel pipe unavailable") -#endif - if let backChannelEnvironmentVariable { + // Create another pipe to send captured values (and possibly other state + // in the future) to the child process. + var capturedValuesReadEnd: FileHandle! + var capturedValuesWriteEnd: FileHandle! + try FileHandle.makePipe(readEnd: &capturedValuesReadEnd, writeEnd: &capturedValuesWriteEnd) + + // Let the child process know how to find the back channel and + // captured values channel by setting a known environment variable to + // the corresponding file descriptor (HANDLE on Windows) for each. + if let backChannelEnvironmentVariable = _makeEnvironmentVariable(for: backChannelWriteEnd) { childEnvironment["SWT_EXPERIMENTAL_BACKCHANNEL"] = backChannelEnvironmentVariable } + if let capturedValuesEnvironmentVariable = _makeEnvironmentVariable(for: capturedValuesReadEnd) { + childEnvironment["SWT_EXPERIMENTAL_CAPTURED_VALUES"] = capturedValuesEnvironmentVariable + } // Spawn the child process. let processID = try withUnsafePointer(to: backChannelWriteEnd) { backChannelWriteEnd in - try spawnExecutable( - atPath: childProcessExecutablePath, - arguments: childArguments, - environment: childEnvironment, - standardOutput: stdoutWriteEnd, - standardError: stderrWriteEnd, - additionalFileHandles: [backChannelWriteEnd] - ) + try withUnsafePointer(to: capturedValuesReadEnd) { capturedValuesReadEnd in + try spawnExecutable( + atPath: childProcessExecutablePath, + arguments: childArguments, + environment: childEnvironment, + standardOutput: stdoutWriteEnd, + standardError: stderrWriteEnd, + additionalFileHandles: [backChannelWriteEnd, capturedValuesReadEnd] + ) + } + } + + // Write the captured values blob over the back channel to the child + // process. (If we end up needing to write additional data, we can + // define a full schema for this stream. Fortunately, both endpoints are + // implemented in the same copy of the testing library, so we don't have + // to worry about backwards-compatibility.) + try capturedValuesWriteEnd.withLock { + try exitTest._withEncodedCapturedValuesForEntryPoint { capturedValuesJSON in + try capturedValuesWriteEnd.write(capturedValuesJSON) + try capturedValuesWriteEnd.write("\n") + } } + capturedValuesReadEnd.close() + capturedValuesWriteEnd.close() // Await termination of the child process. taskGroup.addTask { @@ -748,6 +869,8 @@ extension ExitTest { return result } } + + return result } /// Read lines from the given back channel file handle and process them as @@ -795,9 +918,7 @@ extension ExitTest { // Translate the issue back into a "real" issue and record it // in the parent process. This translation is, of course, lossy // due to the process boundary, but we make a best effort. - let comments: [Comment] = event.messages.compactMap { message in - message.symbol == .details ? Comment(rawValue: message.text) : nil - } + let comments: [Comment] = event.messages.map(\.text).map(Comment.init(rawValue:)) let issueKind: Issue.Kind = if let error = issue._error { .errorCaught(error) } else { @@ -813,5 +934,62 @@ extension ExitTest { issueCopy.record() } } + + /// Decode this exit test's captured values and update its ``capturedValues`` + /// property. + /// + /// - Throws: If a captured value could not be decoded. + /// + /// This function should only be used when the process was started via the + /// `__swiftPMEntryPoint()` function. The effect of using it under other + /// configurations is undefined. + private mutating func _decodeCapturedValuesForEntryPoint() throws { + // Read the content of the captured values stream provided by the parent + // process above. + guard let fileHandle = Self._makeFileHandle(forEnvironmentVariableNamed: "SWT_EXPERIMENTAL_CAPTURED_VALUES", mode: "rb") else { + return + } + let capturedValuesJSON = try fileHandle.readToEnd() + let capturedValuesJSONLines = capturedValuesJSON.split(whereSeparator: \.isASCIINewline) + assert(capturedValues.count == capturedValuesJSONLines.count, "Expected to decode \(capturedValues.count) captured value(s) for the current exit test, but received \(capturedValuesJSONLines.count). Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new") + + // Walk the list of captured values' types, map them to their JSON blobs, + // and decode them. + capturedValues = try zip(capturedValues, capturedValuesJSONLines).map { capturedValue, capturedValueJSON in + var capturedValue = capturedValue + + func open(_ type: T.Type) throws -> T where T: Codable & Sendable { + return try capturedValueJSON.withUnsafeBytes { capturedValueJSON in + try JSON.decode(type, from: capturedValueJSON) + } + } + capturedValue.wrappedValue = try open(capturedValue.typeOfWrappedValue) + + return capturedValue + } + } + + /// Encode this exit test's captured values in a format suitable for passing + /// to the child process. + /// + /// - Parameters: + /// - body: A function to call. This function is called once per captured + /// value in the exit test. + /// + /// - Throws: Whatever is thrown by `body` or while encoding. + /// + /// This function produces a byte buffer representing each value in this exit + /// test's ``capturedValues`` property and passes each buffer to `body`. + /// + /// This function should only be used when the process was started via the + /// `__swiftPMEntryPoint()` function. The effect of using it under other + /// configurations is undefined. + private borrowing func _withEncodedCapturedValuesForEntryPoint(_ body: (UnsafeRawBufferPointer) throws -> Void) throws -> Void { + for capturedValue in capturedValues { + try JSON.withEncoding(of: capturedValue.wrappedValue!) { capturedValueJSON in + try JSON.asJSONLine(capturedValueJSON, body) + } + } + } } #endif diff --git a/Sources/Testing/ExitTests/StatusAtExit.swift b/Sources/Testing/ExitTests/StatusAtExit.swift index 26514ffa5..ea5e287c7 100644 --- a/Sources/Testing/ExitTests/StatusAtExit.swift +++ b/Sources/Testing/ExitTests/StatusAtExit.swift @@ -71,3 +71,19 @@ public enum StatusAtExit: Sendable { @available(*, unavailable, message: "Exit tests are not available on this platform.") #endif extension StatusAtExit: Equatable {} + +// MARK: - CustomStringConvertible +@_spi(Experimental) +#if SWT_NO_PROCESS_SPAWNING +@available(*, unavailable, message: "Exit tests are not available on this platform.") +#endif +extension StatusAtExit: CustomStringConvertible { + public var description: String { + switch self { + case let .exitCode(exitCode): + ".exitCode(\(exitCode))" + case let .signal(signal): + ".signal(\(signal))" + } + } +} diff --git a/Sources/Testing/Expectations/ExpectationChecking+Macro.swift b/Sources/Testing/Expectations/ExpectationChecking+Macro.swift index aa999395a..e8767d01f 100644 --- a/Sources/Testing/Expectations/ExpectationChecking+Macro.swift +++ b/Sources/Testing/Expectations/ExpectationChecking+Macro.swift @@ -1139,9 +1139,8 @@ public func __checkClosureCall( /// Check that an expression always exits (terminates the current process) with /// a given status. /// -/// This overload is used for `await #expect(exitsWith:) { }` invocations. Note -/// that the `body` argument is thin here because it cannot meaningfully capture -/// state from the enclosing context. +/// This overload is used for `await #expect(exitsWith:) { }` invocations that +/// do not capture any state. /// /// - Warning: This function is used to implement the `#expect()` and /// `#require()` macros. Do not call it directly. @@ -1149,8 +1148,8 @@ public func __checkClosureCall( public func __checkClosureCall( identifiedBy exitTestID: (UInt64, UInt64, UInt64, UInt64), exitsWith expectedExitCondition: ExitTest.Condition, - observing observedValues: [any PartialKeyPath & Sendable], - performing body: @convention(thin) () -> Void, + observing observedValues: [any PartialKeyPath & Sendable] = [], + performing _: @convention(thin) () -> Void, expression: __Expression, comments: @autoclosure () -> [Comment], isRequired: Bool, @@ -1159,6 +1158,40 @@ public func __checkClosureCall( ) async -> Result { await callExitTest( identifiedBy: exitTestID, + encodingCapturedValues: [], + exitsWith: expectedExitCondition, + observing: observedValues, + expression: expression, + comments: comments(), + isRequired: isRequired, + sourceLocation: sourceLocation + ) +} + +/// Check that an expression always exits (terminates the current process) with +/// a given status. +/// +/// This overload is used for `await #expect(exitsWith:) { }` invocations that +/// capture some values with an explicit capture list. +/// +/// - Warning: This function is used to implement the `#expect()` and +/// `#require()` macros. Do not call it directly. +@_spi(Experimental) +public func __checkClosureCall( + identifiedBy exitTestID: (UInt64, UInt64, UInt64, UInt64), + encodingCapturedValues capturedValues: (repeat each T), + exitsWith expectedExitCondition: ExitTest.Condition, + observing observedValues: [any PartialKeyPath & Sendable] = [], + performing _: @convention(thin) () -> Void, + expression: __Expression, + comments: @autoclosure () -> [Comment], + isRequired: Bool, + isolation: isolated (any Actor)? = #isolation, + sourceLocation: SourceLocation +) async -> Result where repeat each T: Codable & Sendable { + await callExitTest( + identifiedBy: exitTestID, + encodingCapturedValues: Array(repeat each capturedValues), exitsWith: expectedExitCondition, observing: observedValues, expression: expression, diff --git a/Sources/Testing/Issues/Issue+Recording.swift b/Sources/Testing/Issues/Issue+Recording.swift index aaf721c6a..bd3e9a3bb 100644 --- a/Sources/Testing/Issues/Issue+Recording.swift +++ b/Sources/Testing/Issues/Issue+Recording.swift @@ -73,9 +73,31 @@ extension Issue { @discardableResult public static func record( _ comment: Comment? = nil, sourceLocation: SourceLocation = #_sourceLocation + ) -> Self { + record(comment, severity: .error, sourceLocation: sourceLocation) + } + + /// Record an issue when a running test fails unexpectedly. + /// + /// - Parameters: + /// - comment: A comment describing the expectation. + /// - severity: The severity of the issue. + /// - sourceLocation: The source location to which the issue should be + /// attributed. + /// + /// - Returns: The issue that was recorded. + /// + /// Use this function if, while running a test, an issue occurs that cannot be + /// represented as an expectation (using the ``expect(_:_:sourceLocation:)`` + /// or ``require(_:_:sourceLocation:)-5l63q`` macros.) + @_spi(Experimental) + @discardableResult public static func record( + _ comment: Comment? = nil, + severity: Severity, + sourceLocation: SourceLocation = #_sourceLocation ) -> Self { let sourceContext = SourceContext(backtrace: .current(), sourceLocation: sourceLocation) - let issue = Issue(kind: .unconditional, comments: Array(comment), sourceContext: sourceContext) + let issue = Issue(kind: .unconditional, severity: severity, comments: Array(comment), sourceContext: sourceContext) return issue.record() } } @@ -101,10 +123,35 @@ extension Issue { _ error: any Error, _ comment: Comment? = nil, sourceLocation: SourceLocation = #_sourceLocation + ) -> Self { + record(error, comment, severity: .error, sourceLocation: sourceLocation) + } + + /// Record a new issue when a running test unexpectedly catches an error. + /// + /// - Parameters: + /// - error: The error that caused the issue. + /// - comment: A comment describing the expectation. + /// - severity: The severity of the issue. + /// - sourceLocation: The source location to which the issue should be + /// attributed. + /// + /// - Returns: The issue that was recorded. + /// + /// This function can be used if an unexpected error is caught while running a + /// test and it should be treated as a test failure. If an error is thrown + /// from a test function, it is automatically recorded as an issue and this + /// function does not need to be used. + @_spi(Experimental) + @discardableResult public static func record( + _ error: any Error, + _ comment: Comment? = nil, + severity: Severity, + sourceLocation: SourceLocation = #_sourceLocation ) -> Self { let backtrace = Backtrace(forFirstThrowOf: error) ?? Backtrace.current() let sourceContext = SourceContext(backtrace: backtrace, sourceLocation: sourceLocation) - let issue = Issue(kind: .errorCaught(error), comments: Array(comment), sourceContext: sourceContext) + let issue = Issue(kind: .errorCaught(error), severity: severity, comments: Array(comment), sourceContext: sourceContext) return issue.record() } diff --git a/Sources/Testing/Issues/Issue.swift b/Sources/Testing/Issues/Issue.swift index 5d7449b7b..53364c151 100644 --- a/Sources/Testing/Issues/Issue.swift +++ b/Sources/Testing/Issues/Issue.swift @@ -65,7 +65,6 @@ public struct Issue: Sendable { /// /// - Parameters: /// - error: The error which was associated with this issue. - @_spi(Experimental) case valueAttachmentFailed(_ error: any Error) /// An issue occurred due to misuse of the testing library. @@ -104,6 +103,21 @@ public struct Issue: Sendable { /// The severity of this issue. @_spi(Experimental) public var severity: Severity + + /// Whether or not this issue should cause the test it's associated with to be + /// considered a failure. + /// + /// The value of this property is `true` for issues which have a severity level of + /// ``Issue/Severity/error`` or greater and are not known issues via + /// ``withKnownIssue(_:isIntermittent:sourceLocation:_:when:matching:)``. + /// Otherwise, the value of this property is `false.` + /// + /// Use this property to determine if an issue should be considered a failure, instead of + /// directly comparing the value of the ``severity`` property. + @_spi(Experimental) + public var isFailure: Bool { + return !self.isKnown && self.severity >= .error + } /// Any comments provided by the developer and associated with this issue. /// diff --git a/Sources/Testing/Running/Configuration.swift b/Sources/Testing/Running/Configuration.swift index 30a2ce303..b8c48aa79 100644 --- a/Sources/Testing/Running/Configuration.swift +++ b/Sources/Testing/Running/Configuration.swift @@ -237,7 +237,6 @@ public struct Configuration: Sendable { /// The value of this property must refer to a directory on the local file /// system that already exists and which the current user can write to. If it /// is a relative path, it is resolved to an absolute path automatically. - @_spi(Experimental) public var attachmentsPath: String? { get { _attachmentsPath diff --git a/Sources/Testing/Support/Additions/ArrayAdditions.swift b/Sources/Testing/Support/Additions/ArrayAdditions.swift index 462a330fd..eee74037d 100644 --- a/Sources/Testing/Support/Additions/ArrayAdditions.swift +++ b/Sources/Testing/Support/Additions/ArrayAdditions.swift @@ -21,3 +21,21 @@ extension Array { self = optionalValue.map { [$0] } ?? [] } } + +/// Get the number of elements in a parameter pack. +/// +/// - Parameters: +/// - pack: The parameter pack. +/// +/// - Returns: The number of elements in `pack`. +/// +/// - Complexity: O(_n_) where _n_ is the number of elements in `pack`. The +/// compiler may be able to optimize this operation when the types of `pack` +/// are statically known. +func parameterPackCount(_ pack: repeat each T) -> Int { + var result = 0 + for _ in repeat each pack { + result += 1 + } + return result +} diff --git a/Sources/Testing/Support/JSON.swift b/Sources/Testing/Support/JSON.swift index 76c7b7f07..3d656687f 100644 --- a/Sources/Testing/Support/JSON.swift +++ b/Sources/Testing/Support/JSON.swift @@ -50,6 +50,30 @@ enum JSON { #endif } + /// Post-process encoded JSON and write it to a file. + /// + /// - Parameters: + /// - json: The JSON to write. + /// - body: A function to call. A copy of `json` is passed to it with any + /// newlines removed. + /// + /// - Returns: Whatever is returned by `body`. + /// + /// - Throws: Whatever is thrown by `body`. + static func asJSONLine(_ json: UnsafeRawBufferPointer, _ body: (UnsafeRawBufferPointer) throws -> R) rethrows -> R { + if _slowPath(json.contains(where: \.isASCIINewline)) { + // Remove the newline characters to conform to JSON lines specification. + // This is not actually expected to happen in practice with Foundation's + // JSON encoder. + var json = Array(json) + json.removeAll(where: \.isASCIINewline) + return try json.withUnsafeBytes(body) + } else { + // No newlines found, no need to copy the buffer. + return try body(json) + } + } + /// Decode a value from JSON data. /// /// - Parameters: diff --git a/Sources/Testing/Support/Locked+Platform.swift b/Sources/Testing/Support/Locked+Platform.swift index 2b3f5b648..951e62da8 100644 --- a/Sources/Testing/Support/Locked+Platform.swift +++ b/Sources/Testing/Support/Locked+Platform.swift @@ -94,14 +94,3 @@ typealias DefaultLock = Never #warning("Platform-specific implementation missing: locking unavailable") typealias DefaultLock = Never #endif - -#if SWT_NO_DYNAMIC_LINKING -/// A function which, when called by another file, ensures that the file in -/// which ``DefaultLock`` is declared is linked. -/// -/// When static linking is used, the linker may opt to strip some or all of the -/// symbols (including protocol conformance metadata) declared in this file. -/// ``LockedWith`` calls this function in ``LockedWith/init(rawValue:)`` to work -/// around that issue. -func linkLockImplementations() {} -#endif diff --git a/Sources/Testing/Support/Locked.swift b/Sources/Testing/Support/Locked.swift index a4ab92d66..d1db8ef1f 100644 --- a/Sources/Testing/Support/Locked.swift +++ b/Sources/Testing/Support/Locked.swift @@ -66,10 +66,6 @@ struct LockedWith: RawRepresentable where L: Lockable { private nonisolated(unsafe) var _storage: ManagedBuffer init(rawValue: T) { -#if SWT_NO_DYNAMIC_LINKING - linkLockImplementations() -#endif - _storage = _Storage.create(minimumCapacity: 1, makingHeaderWith: { _ in rawValue }) _storage.withUnsafeMutablePointerToElements { lock in L.initializeLock(at: lock) diff --git a/Sources/Testing/Test+Discovery.swift b/Sources/Testing/Test+Discovery.swift index 5d1b204ae..35f716525 100644 --- a/Sources/Testing/Test+Discovery.swift +++ b/Sources/Testing/Test+Discovery.swift @@ -44,9 +44,11 @@ extension Test { into outValue: UnsafeMutableRawPointer, asTypeAt typeAddress: UnsafeRawPointer ) -> CBool { +#if !hasFeature(Embedded) guard typeAddress.load(as: Any.Type.self) == Generator.self else { return false } +#endif outValue.initializeMemory(as: Generator.self, to: .init(rawValue: generator)) return true } diff --git a/Sources/Testing/Test+Macro.swift b/Sources/Testing/Test+Macro.swift index 86fb42c14..d1ad6623b 100644 --- a/Sources/Testing/Test+Macro.swift +++ b/Sources/Testing/Test+Macro.swift @@ -537,6 +537,15 @@ extension Test { value } +/// A function that abstracts away whether or not the `unsafe` keyword is needed +/// on an expression. +/// +/// - Warning: This function is used to implement the `@Test` macro. Do not use +/// it directly. +@unsafe @inlinable public func __requiringUnsafe(_ value: consuming T) throws -> T where T: ~Copyable { + value +} + /// The current default isolation context. /// /// - Warning: This property is used to implement the `@Test` macro. Do not call diff --git a/Sources/Testing/Testing.docc/Attachments.md b/Sources/Testing/Testing.docc/Attachments.md new file mode 100644 index 000000000..0da40c201 --- /dev/null +++ b/Sources/Testing/Testing.docc/Attachments.md @@ -0,0 +1,32 @@ +# Attachments + + + +Attach values to tests to help diagnose issues and gather feedback. + +## Overview + +Attach values such as strings and files to tests. Implement the ``Attachable`` +protocol to create your own attachable types. + +## Topics + +### Attaching values to tests + +- ``Attachment`` +- ``Attachable`` +- ``AttachableWrapper`` + + diff --git a/Sources/Testing/Testing.docc/Documentation.md b/Sources/Testing/Testing.docc/Documentation.md index 901c0e3a6..cc4001889 100644 --- a/Sources/Testing/Testing.docc/Documentation.md +++ b/Sources/Testing/Testing.docc/Documentation.md @@ -69,3 +69,7 @@ their problems. ### Test customization - + +### Data collection + +- diff --git a/Sources/Testing/Testing.docc/MigratingFromXCTest.md b/Sources/Testing/Testing.docc/MigratingFromXCTest.md index e3a9d961f..60744ba7a 100644 --- a/Sources/Testing/Testing.docc/MigratingFromXCTest.md +++ b/Sources/Testing/Testing.docc/MigratingFromXCTest.md @@ -742,6 +742,66 @@ suite serially: For more information, see . +### Attach values + +In XCTest, you can create an instance of [`XCTAttachment`](https://developer.apple.com/documentation/xctest/xctattachment) +representing arbitrary data, files, property lists, encodable objects, images, +and other types of information that would be useful to have available if a test +fails. Swift Testing has an ``Attachment`` type that serves much the same +purpose. + +To attach a value from a test to the output of a test run, that value must +conform to the ``Attachable`` protocol. The testing library provides default +conformances for various standard library and Foundation types. + +If you want to attach a value of another type, and that type already conforms to +[`Encodable`](https://developer.apple.com/documentation/swift/encodable) or to +[`NSSecureCoding`](https://developer.apple.com/documentation/foundation/nssecurecoding), +the testing library automatically provides a default implementation when you +import Foundation: + +@Row { + @Column { + ```swift + // Before + import Foundation + + class Tortilla: NSSecureCoding { /* ... */ } + + func testTortillaIntegrity() async { + let tortilla = Tortilla(diameter: .large) + ... + let attachment = XCTAttachment( + archivableObject: tortilla + ) + self.add(attachment) + } + ``` + } + @Column { + ```swift + // After + import Foundation + + struct Tortilla: Codable, Attachable { /* ... */ } + + @Test func tortillaIntegrity() async { + let tortilla = Tortilla(diameter: .large) + ... + Attachment.record(tortilla) + } + ``` + } +} + +If you have a type that does not (or cannot) conform to `Encodable` or +`NSSecureCoding`, or if you want fine-grained control over how it is serialized +when attaching it to a test, you can provide your own implementation of +``Attachable/withUnsafeBytes(for:_:)``. + + + ## See Also - diff --git a/Sources/TestingMacros/CMakeLists.txt b/Sources/TestingMacros/CMakeLists.txt index 72184f94b..c9a579eaf 100644 --- a/Sources/TestingMacros/CMakeLists.txt +++ b/Sources/TestingMacros/CMakeLists.txt @@ -31,7 +31,7 @@ if(SwiftTesting_BuildMacrosAsExecutables) set(FETCHCONTENT_BASE_DIR ${CMAKE_BINARY_DIR}/_d) FetchContent_Declare(SwiftSyntax GIT_REPOSITORY https://github.com/swiftlang/swift-syntax - GIT_TAG 1cd35348b089ff8966588742c69727205d99f8ed) # 601.0.0-prerelease-2024-11-18 + GIT_TAG 340f8400262d494c7c659cd838223990195d7fed) # 602.0.0-prerelease-2025-04-10 FetchContent_MakeAvailable(SwiftSyntax) endif() @@ -97,10 +97,12 @@ target_sources(TestingMacros PRIVATE Support/Argument.swift Support/AttributeDiscovery.swift Support/AvailabilityGuards.swift + Support/ClosureCaptureListParsing.swift Support/CommentParsing.swift Support/ConditionArgumentParsing.swift Support/DiagnosticMessage.swift Support/DiagnosticMessage+Diagnosing.swift + Support/EffectfulExpressionHandling.swift Support/SHA256.swift Support/SourceCodeCapturing.swift Support/SourceLocationGeneration.swift diff --git a/Sources/TestingMacros/ConditionMacro.swift b/Sources/TestingMacros/ConditionMacro.swift index f8b87e1fa..326522858 100644 --- a/Sources/TestingMacros/ConditionMacro.swift +++ b/Sources/TestingMacros/ConditionMacro.swift @@ -117,7 +117,6 @@ extension ConditionMacro { var checkArguments = [Argument]() do { if let trailingClosureIndex { - // Include all arguments other than the "comment" and "sourceLocation" // arguments here. checkArguments += macroArguments.indices.lazy @@ -421,29 +420,28 @@ extension ExitTestConditionMacro { _ = try Base.expansion(of: macro, in: context) var arguments = argumentList(of: macro, in: context) - let requirementIndex = arguments.firstIndex { $0.label?.tokenKind == .identifier("exitsWith") } - guard let requirementIndex else { - fatalError("Could not find the requirement for this exit test. Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new") - } - let observationListIndex = arguments.firstIndex { $0.label?.tokenKind == .identifier("observing") } - if observationListIndex == nil { - arguments.insert( - Argument(label: "observing", expression: ArrayExprSyntax(expressions: [])), - at: arguments.index(after: requirementIndex) - ) - } let trailingClosureIndex = arguments.firstIndex { $0.label?.tokenKind == _trailingClosureLabel.tokenKind } guard let trailingClosureIndex else { fatalError("Could not find the body argument to this exit test. Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new") } - // Extract the body argument and, if it's a closure with a capture list, - // emit an appropriate diagnostic. var bodyArgumentExpr = arguments[trailingClosureIndex].expression bodyArgumentExpr = removeParentheses(from: bodyArgumentExpr) ?? bodyArgumentExpr - if let closureExpr = bodyArgumentExpr.as(ClosureExprSyntax.self), - let captureClause = closureExpr.signature?.capture, - !captureClause.items.isEmpty { + + // Find any captured values and extract them from the trailing closure. + var capturedValues = [CapturedValueInfo]() + if ExitTestExpectMacro.isValueCapturingEnabled { + // The source file imports @_spi(Experimental), so allow value capturing. + if var closureExpr = bodyArgumentExpr.as(ClosureExprSyntax.self), + let captureList = closureExpr.signature?.capture?.items { + closureExpr.signature?.capture = ClosureCaptureClauseSyntax(items: [], trailingTrivia: .space) + capturedValues = captureList.map { CapturedValueInfo($0, in: context) } + bodyArgumentExpr = ExprSyntax(closureExpr) + } + + } else if let closureExpr = bodyArgumentExpr.as(ClosureExprSyntax.self), + let captureClause = closureExpr.signature?.capture, + !captureClause.items.isEmpty { context.diagnose(.captureClauseUnsupported(captureClause, in: closureExpr, inExitTest: macro)) } @@ -455,10 +453,20 @@ extension ExitTestConditionMacro { // Implement the body of the exit test outside the enum we're declaring so // that `Self` resolves to the type containing the exit test, not the enum. let bodyThunkName = context.makeUniqueName("") + let bodyThunkParameterList = FunctionParameterListSyntax { + for capturedValue in capturedValues { + FunctionParameterSyntax( + firstName: .wildcardToken(trailingTrivia: .space), + secondName: capturedValue.name.trimmed, + colon: .colonToken(trailingTrivia: .space), + type: capturedValue.type.trimmed + ) + } + } decls.append( """ - @Sendable func \(bodyThunkName)() async throws -> Swift.Void { - return try await Testing.__requiringTry(Testing.__requiringAwait(\(bodyArgumentExpr.trimmed)))() + @Sendable func \(bodyThunkName)(\(bodyThunkParameterList)) async throws { + _ = \(applyEffectfulKeywords([.try, .await, .unsafe], to: bodyArgumentExpr))() } """ ) @@ -522,12 +530,24 @@ extension ExitTestConditionMacro { } ) - // Insert the exit test's ID as the first argument. Note that this will - // invalidate all indices into `arguments`! - arguments.insert( + // Insert additional arguments at the beginning of the argument list. Note + // that this will invalidate all indices into `arguments`! + var leadingArguments = [ Argument(label: "identifiedBy", expression: idExpr), - at: arguments.startIndex - ) + ] + if !capturedValues.isEmpty { + leadingArguments.append( + Argument( + label: "encodingCapturedValues", + expression: TupleExprSyntax { + for capturedValue in capturedValues { + LabeledExprSyntax(expression: capturedValue.expression.trimmed) + } + } + ) + ) + } + arguments = leadingArguments + arguments // Replace the exit test body (as an argument to the macro) with a stub // closure that hosts the type we created above. @@ -583,6 +603,22 @@ extension ExitTestConditionMacro { } } +extension ExitTestExpectMacro { + /// Whether or not experimental value capturing via explicit capture lists is + /// enabled. + /// + /// This member is declared on ``ExitTestExpectMacro`` but also applies to + /// ``ExitTestRequireMacro``. + @TaskLocal + static var isValueCapturingEnabled: Bool = { +#if ExperimentalExitTestValueCapture + return true +#else + return false +#endif + }() +} + /// A type describing the expansion of the `#expect(exitsWith:)` macro. /// /// This type checks for nested invocations of `#expect()` and `#require()` and diff --git a/Sources/TestingMacros/Support/Additions/EditorPlaceholderExprSyntaxAdditions.swift b/Sources/TestingMacros/Support/Additions/EditorPlaceholderExprSyntaxAdditions.swift index a8b5063cc..9a0d31ab3 100644 --- a/Sources/TestingMacros/Support/Additions/EditorPlaceholderExprSyntaxAdditions.swift +++ b/Sources/TestingMacros/Support/Additions/EditorPlaceholderExprSyntaxAdditions.swift @@ -52,3 +52,16 @@ extension EditorPlaceholderExprSyntax { self.init(type, type: type) } } + +extension TypeSyntax { + /// Construct a type syntax node containing a placeholder string. + /// + /// - Parameters: + /// - placeholder: The placeholder string, not including surrounding angle + /// brackets or pound characters. + /// + /// - Returns: A new `TypeSyntax` instance representing a placeholder. + static func placeholder(_ placeholder: String) -> Self { + return Self(IdentifierTypeSyntax(name: .identifier("<#\(placeholder)#" + ">"))) + } +} diff --git a/Sources/TestingMacros/Support/Additions/MacroExpansionContextAdditions.swift b/Sources/TestingMacros/Support/Additions/MacroExpansionContextAdditions.swift index 322a84f3a..d0f296892 100644 --- a/Sources/TestingMacros/Support/Additions/MacroExpansionContextAdditions.swift +++ b/Sources/TestingMacros/Support/Additions/MacroExpansionContextAdditions.swift @@ -14,17 +14,18 @@ import SwiftSyntaxMacros import SwiftDiagnostics extension MacroExpansionContext { - /// Get the type of the lexical context enclosing the given node. + /// Get the type of the given lexical context. /// /// - Parameters: - /// - node: The node whose lexical context should be examined. + /// - lexicalContext: The lexical context. /// - /// - Returns: The type of the lexical context enclosing `node`, or `nil` if - /// the lexical context cannot be represented as a type. + /// - Returns: The type represented by `lexicalContext`, or `nil` if one could + /// not be derived (for example, because the lexical context inclues a + /// function, closure, or some other non-type scope.) /// /// If the lexical context includes functions, closures, or some other /// non-type scope, the value of this property is `nil`. - var typeOfLexicalContext: TypeSyntax? { + func type(ofLexicalContext lexicalContext: some RandomAccessCollection) -> TypeSyntax? { var typeNames = [String]() for lexicalContext in lexicalContext.reversed() { guard let decl = lexicalContext.asProtocol((any DeclGroupSyntax).self) else { @@ -38,6 +39,14 @@ extension MacroExpansionContext { return "\(raw: typeNames.joined(separator: "."))" } + + /// The type of the lexical context enclosing the given node. + /// + /// If the lexical context includes functions, closures, or some other + /// non-type scope, the value of this property is `nil`. + var typeOfLexicalContext: TypeSyntax? { + type(ofLexicalContext: lexicalContext) + } } // MARK: - diff --git a/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift b/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift new file mode 100644 index 000000000..41abe711c --- /dev/null +++ b/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift @@ -0,0 +1,88 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2023–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 +// + +import SwiftDiagnostics +import SwiftParser +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +/// A type representing a value extracted from a closure's capture list. +struct CapturedValueInfo { + /// The original instance of `ClosureCaptureSyntax` used to create this value. + var capture: ClosureCaptureSyntax + + /// The name of the captured value. + var name: TokenSyntax { + let text = capture.name.textWithoutBackticks + if text.isValidSwiftIdentifier(for: .variableName) { + return capture.name + } + return .identifier("`\(text)`") + } + + /// The expression to assign to the captured value. + var expression: ExprSyntax + + /// The type of the captured value. + var type: TypeSyntax + + init(_ capture: ClosureCaptureSyntax, in context: some MacroExpansionContext) { + self.capture = capture + self.expression = "()" + self.type = "Swift.Void" + + // We don't support capture specifiers at this time. + if let specifier = capture.specifier { + context.diagnose(.specifierUnsupported(specifier, on: capture)) + return + } + + // Potentially get the name of the type comprising the current lexical + // context (i.e. whatever `Self` is.) + lazy var typeNameOfLexicalContext = { + let lexicalContext = context.lexicalContext.drop { !$0.isProtocol((any DeclGroupSyntax).self) } + return context.type(ofLexicalContext: lexicalContext) + }() + + if let initializer = capture.initializer { + // Found an initializer clause. Extract the expression it captures. + self.expression = removeParentheses(from: initializer.value) ?? initializer.value + + // Find the 'as' clause so we can determine the type of the captured value. + if let asExpr = self.expression.as(AsExprSyntax.self) { + self.type = if asExpr.questionOrExclamationMark?.tokenKind == .postfixQuestionMark { + // If the caller is using as?, make the type optional. + TypeSyntax(OptionalTypeSyntax(wrappedType: asExpr.type.trimmed)) + } else { + asExpr.type + } + } else if let selfExpr = self.expression.as(DeclReferenceExprSyntax.self), + selfExpr.baseName.tokenKind == .keyword(.self), + selfExpr.argumentNames == nil, + let typeNameOfLexicalContext { + // Copying self. + self.type = typeNameOfLexicalContext + } else { + context.diagnose(.typeOfCaptureIsAmbiguous(capture, initializedWith: initializer)) + } + + } else if capture.name.tokenKind == .keyword(.self), + let typeNameOfLexicalContext { + // Capturing self. + self.expression = "self" + self.type = typeNameOfLexicalContext + + } else { + // Not enough contextual information to derive the type here. + context.diagnose(.typeOfCaptureIsAmbiguous(capture)) + } + } +} diff --git a/Sources/TestingMacros/Support/ConditionArgumentParsing.swift b/Sources/TestingMacros/Support/ConditionArgumentParsing.swift index edf9a23c3..e0ccda9a7 100644 --- a/Sources/TestingMacros/Support/ConditionArgumentParsing.swift +++ b/Sources/TestingMacros/Support/ConditionArgumentParsing.swift @@ -472,17 +472,6 @@ private func _parseCondition(from expr: ExprSyntax, for macro: some Freestanding return _parseCondition(from: closureExpr, for: macro, in: context) } - // If the condition involves the `try` or `await` keywords, assume we cannot - // expand it. This check cannot handle expressions like - // `try #expect(a.b(c))` where `b()` is throwing because the `try` keyword is - // outside the macro expansion. SEE: rdar://109470248 - let containsTryOrAwait = expr.tokens(viewMode: .sourceAccurate).lazy - .map(\.tokenKind) - .contains { $0 == .keyword(.try) || $0 == .keyword(.await) } - if containsTryOrAwait { - return Condition(expression: expr) - } - if let infixOperator = expr.as(InfixOperatorExprSyntax.self), let op = infixOperator.operator.as(BinaryOperatorExprSyntax.self) { return _parseCondition(from: expr, leftOperand: infixOperator.leftOperand, operator: op, rightOperand: infixOperator.rightOperand, for: macro, in: context) @@ -527,6 +516,15 @@ private func _parseCondition(from expr: ExprSyntax, for macro: some Freestanding /// /// - Returns: An instance of ``Condition`` describing `expr`. func parseCondition(from expr: ExprSyntax, for macro: some FreestandingMacroExpansionSyntax, in context: some MacroExpansionContext) -> Condition { + // If the condition involves the `unsafe`, `try`, or `await` keywords, assume + // we cannot expand it. This check cannot handle expressions like + // `try #expect(a.b(c))` where `b()` is throwing because the `try` keyword is + // outside the macro expansion. SEE: rdar://109470248 + let effectKeywordsToApply = findEffectKeywords(in: expr, context: context) + guard effectKeywordsToApply.intersection([.unsafe, .try, .await]).isEmpty else { + return Condition(expression: expr) + } + _diagnoseTrivialBooleanValue(from: expr, for: macro, in: context) let result = _parseCondition(from: expr, for: macro, in: context) return result diff --git a/Sources/TestingMacros/Support/DiagnosticMessage.swift b/Sources/TestingMacros/Support/DiagnosticMessage.swift index dc9defe5d..36186ec4b 100644 --- a/Sources/TestingMacros/Support/DiagnosticMessage.swift +++ b/Sources/TestingMacros/Support/DiagnosticMessage.swift @@ -739,22 +739,6 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { ) } - /// Create a diagnostic message stating that a condition macro nested inside - /// an exit test will not record any diagnostics. - /// - /// - Parameters: - /// - checkMacro: The inner condition macro invocation. - /// - exitTestMacro: The containing exit test macro invocation. - /// - /// - Returns: A diagnostic message. - static func checkUnsupported(_ checkMacro: some FreestandingMacroExpansionSyntax, inExitTest exitTestMacro: some FreestandingMacroExpansionSyntax) -> Self { - Self( - syntax: Syntax(checkMacro), - message: "Expression \(_macroName(checkMacro)) will not record an issue on failure inside exit test \(_macroName(exitTestMacro))", - severity: .error - ) - } - var syntax: Syntax // MARK: - DiagnosticMessage @@ -768,6 +752,81 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { // MARK: - Captured values extension DiagnosticMessage { + /// Create a diagnostic message stating that a specifier keyword cannot be + /// used with a given closure capture list item. + /// + /// - Parameters: + /// - specifier: The invalid specifier. + /// - capture: The closure capture list item. + /// + /// - Returns: A diagnostic message. + static func specifierUnsupported(_ specifier: ClosureCaptureSpecifierSyntax, on capture: ClosureCaptureSyntax) -> Self { + Self( + syntax: Syntax(specifier), + message: "Specifier '\(specifier.trimmed)' cannot be used with captured value '\(capture.name.textWithoutBackticks)'", + severity: .error, + fixIts: [ + FixIt( + message: MacroExpansionFixItMessage("Remove '\(specifier.trimmed)'"), + changes: [ + .replace( + oldNode: Syntax(capture), + newNode: Syntax(capture.with(\.specifier, nil)) + ) + ] + ), + ] + ) + } + + /// Create a diagnostic message stating that a closure capture list item's + /// type is ambiguous and must be made explicit. + /// + /// - Parameters: + /// - capture: The closure capture list item. + /// - initializerClause: The existing initializer clause, if any. + /// + /// - Returns: A diagnostic message. + static func typeOfCaptureIsAmbiguous(_ capture: ClosureCaptureSyntax, initializedWith initializerClause: InitializerClauseSyntax? = nil) -> Self { + let castValueExpr: some ExprSyntaxProtocol = if let initializerClause { + ExprSyntax(initializerClause.value.trimmed) + } else { + ExprSyntax(DeclReferenceExprSyntax(baseName: capture.name.trimmed)) + } + let initializerValueExpr = ExprSyntax( + AsExprSyntax( + expression: castValueExpr, + asKeyword: .keyword(.as, leadingTrivia: .space, trailingTrivia: .space), + type: TypeSyntax.placeholder("T") + ) + ) + let placeholderInitializerClause = if let initializerClause { + initializerClause.with(\.value, initializerValueExpr) + } else { + InitializerClauseSyntax( + equal: .equalToken(leadingTrivia: .space, trailingTrivia: .space), + value: initializerValueExpr + ) + } + + return Self( + syntax: Syntax(capture), + message: "Type of captured value '\(capture.name.textWithoutBackticks)' is ambiguous", + severity: .error, + fixIts: [ + FixIt( + message: MacroExpansionFixItMessage("Add '= \(castValueExpr) as T'"), + changes: [ + .replace( + oldNode: Syntax(capture), + newNode: Syntax(capture.with(\.initializer, placeholderInitializerClause)) + ) + ] + ), + ] + ) + } + /// Create a diagnostic message stating that a capture clause cannot be used /// in an exit test. /// diff --git a/Sources/TestingMacros/Support/EffectfulExpressionHandling.swift b/Sources/TestingMacros/Support/EffectfulExpressionHandling.swift new file mode 100644 index 000000000..f67ca40ee --- /dev/null +++ b/Sources/TestingMacros/Support/EffectfulExpressionHandling.swift @@ -0,0 +1,159 @@ +// +// 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 SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +// MARK: - Finding effect keywords + +/// A syntax visitor class that looks for effectful keywords in a given +/// expression. +private final class _EffectFinder: SyntaxAnyVisitor { + /// The effect keywords discovered so far. + var effectKeywords: Set = [] + + override func visitAny(_ node: Syntax) -> SyntaxVisitorContinueKind { + switch node.kind { + case .tryExpr: + effectKeywords.insert(.try) + case .awaitExpr: + effectKeywords.insert(.await) + case .consumeExpr: + effectKeywords.insert(.consume) + case .borrowExpr: + effectKeywords.insert(.borrow) + case .unsafeExpr: + effectKeywords.insert(.unsafe) + case .closureExpr, .functionDecl: + // Do not delve into closures or function declarations. + return .skipChildren + case .variableDecl: + // Delve into variable declarations. + return .visitChildren + default: + // Do not delve into declarations other than variables. + if node.isProtocol((any DeclSyntaxProtocol).self) { + return .skipChildren + } + } + + // Recurse into everything else. + return .visitChildren + } +} + +/// Find effectful keywords in a syntax node. +/// +/// - Parameters: +/// - node: The node to inspect. +/// - context: The macro context in which the expression is being parsed. +/// +/// - Returns: A set of effectful keywords such as `await` that are present in +/// `node`. +/// +/// This function does not descend into function declarations or closure +/// expressions because they represent distinct lexical contexts and their +/// effects are uninteresting in the context of `node` unless they are called. +func findEffectKeywords(in node: some SyntaxProtocol, context: some MacroExpansionContext) -> Set { + // TODO: gather any effects from the lexical context once swift-syntax-#3037 and related PRs land + let effectFinder = _EffectFinder(viewMode: .sourceAccurate) + effectFinder.walk(node) + return effectFinder.effectKeywords +} + +// MARK: - Inserting effect keywords/thunks + +/// Make a function call expression to an effectful thunk function provided by +/// the testing library. +/// +/// - Parameters: +/// - thunkName: The unqualified name of the thunk function to call. This +/// token must be the name of a function in the `Testing` module. +/// - expr: The expression to thunk. +/// +/// - Returns: An expression representing a call to the function named +/// `thunkName`, passing `expr`. +private func _makeCallToEffectfulThunk(_ thunkName: TokenSyntax, passing expr: some ExprSyntaxProtocol) -> ExprSyntax { + ExprSyntax( + FunctionCallExprSyntax( + calledExpression: MemberAccessExprSyntax( + base: DeclReferenceExprSyntax(baseName: .identifier("Testing")), + declName: DeclReferenceExprSyntax(baseName: thunkName) + ), + leftParen: .leftParenToken(), + rightParen: .rightParenToken() + ) { + LabeledExprSyntax(expression: expr.trimmed) + } + ) +} + +/// Apply the given effectful keywords (i.e. `try` and `await`) to an expression +/// using thunk functions provided by the testing library. +/// +/// - Parameters: +/// - effectfulKeywords: The effectful keywords to apply. +/// - expr: The expression to apply the keywords and thunk functions to. +/// +/// - Returns: A copy of `expr` if no changes are needed, or an expression that +/// adds the keywords in `effectfulKeywords` to `expr`. +func applyEffectfulKeywords(_ effectfulKeywords: Set, to expr: some ExprSyntaxProtocol) -> ExprSyntax { + let originalExpr = expr + var expr = ExprSyntax(expr.trimmed) + + let needAwait = effectfulKeywords.contains(.await) && !expr.is(AwaitExprSyntax.self) + let needTry = effectfulKeywords.contains(.try) && !expr.is(TryExprSyntax.self) + let needUnsafe = effectfulKeywords.contains(.unsafe) && !expr.is(UnsafeExprSyntax.self) + + // First, add thunk function calls. + if needAwait { + expr = _makeCallToEffectfulThunk(.identifier("__requiringAwait"), passing: expr) + } + if needTry { + expr = _makeCallToEffectfulThunk(.identifier("__requiringTry"), passing: expr) + } + if needUnsafe { + expr = _makeCallToEffectfulThunk(.identifier("__requiringUnsafe"), passing: expr) + } + + // Then add keyword expressions. (We do this separately so we end up writing + // `try await __r(__r(self))` instead of `try __r(await __r(self))` which is + // less accepted by the compiler.) + if needAwait { + expr = ExprSyntax( + AwaitExprSyntax( + awaitKeyword: .keyword(.await).with(\.trailingTrivia, .space), + expression: expr + ) + ) + } + if needTry { + expr = ExprSyntax( + TryExprSyntax( + tryKeyword: .keyword(.try).with(\.trailingTrivia, .space), + expression: expr + ) + ) + } + if needUnsafe { + expr = ExprSyntax( + UnsafeExprSyntax( + unsafeKeyword: .keyword(.unsafe).with(\.trailingTrivia, .space), + expression: expr + ) + ) + } + + expr.leadingTrivia = originalExpr.leadingTrivia + expr.trailingTrivia = originalExpr.trailingTrivia + + return expr +} diff --git a/Sources/TestingMacros/TestDeclarationMacro.swift b/Sources/TestingMacros/TestDeclarationMacro.swift index 2a4da4e3c..0b2d43f1e 100644 --- a/Sources/TestingMacros/TestDeclarationMacro.swift +++ b/Sources/TestingMacros/TestDeclarationMacro.swift @@ -246,17 +246,17 @@ public struct TestDeclarationMacro: PeerMacro, Sendable { // detecting isolation to other global actors. lazy var isMainActorIsolated = !functionDecl.attributes(named: "MainActor", inModuleNamed: "_Concurrency").isEmpty var forwardCall: (ExprSyntax) -> ExprSyntax = { - "try await Testing.__requiringTry(Testing.__requiringAwait(\($0)))" + applyEffectfulKeywords([.try, .await, .unsafe], to: $0) } let forwardInit = forwardCall if functionDecl.noasyncAttribute != nil { if isMainActorIsolated { forwardCall = { - "try await MainActor.run { try Testing.__requiringTry(\($0)) }" + "try await MainActor.run { \(applyEffectfulKeywords([.try, .unsafe], to: $0)) }" } } else { forwardCall = { - "try { try Testing.__requiringTry(\($0)) }()" + "try { \(applyEffectfulKeywords([.try, .unsafe], to: $0)) }()" } } } @@ -328,7 +328,7 @@ public struct TestDeclarationMacro: PeerMacro, Sendable { } FunctionParameterSyntax( firstName: .wildcardToken(), - type: "isolated (any Actor)?" as TypeSyntax, + type: "isolated (any _Concurrency.Actor)?" as TypeSyntax, defaultValue: InitializerClauseSyntax(value: "Testing.__defaultSynchronousIsolationContext" as ExprSyntax) ) } diff --git a/Sources/_TestDiscovery/TestContentKind.swift b/Sources/_TestDiscovery/TestContentKind.swift index 4e6955acc..30f201a83 100644 --- a/Sources/_TestDiscovery/TestContentKind.swift +++ b/Sources/_TestDiscovery/TestContentKind.swift @@ -52,9 +52,11 @@ extension TestContentKind: Equatable, Hashable { } } +#if !hasFeature(Embedded) // MARK: - Codable extension TestContentKind: Codable {} +#endif // MARK: - ExpressibleByStringLiteral, ExpressibleByIntegerLiteral diff --git a/Sources/_TestDiscovery/TestContentRecord.swift b/Sources/_TestDiscovery/TestContentRecord.swift index 25f46fa44..9224fc2ea 100644 --- a/Sources/_TestDiscovery/TestContentRecord.swift +++ b/Sources/_TestDiscovery/TestContentRecord.swift @@ -52,7 +52,7 @@ extension DiscoverableAsTestContent where Self: ~Copyable { /// ([swift-#79667](https://github.com/swiftlang/swift/issues/79667)) fileprivate static func validateMemoryLayout() { precondition(MemoryLayout.stride == MemoryLayout.stride, "'\(self).TestContentContext' aka '\(TestContentContext.self)' must have the same stride as 'UInt'.") - precondition(MemoryLayout.alignment == MemoryLayout.alignment, "'\(self).TestContentContext' aka '\(TestContentContext.self)' must have the same alignment as 'UInt'.") + precondition(MemoryLayout.alignment <= MemoryLayout.alignment, "'\(self).TestContentContext' aka '\(TestContentContext.self)' must have an alignment less than or equal to that of 'UInt'.") } } @@ -139,6 +139,34 @@ public struct TestContentRecord where T: DiscoverableAsTestContent & ~Copyabl /// The type of the `hint` argument to ``load(withHint:)``. public typealias Hint = T.TestContentAccessorHint + /// Invoke an accessor function to load a test content record. + /// + /// - Parameters: + /// - accessor: The accessor function to call. + /// - typeAddress: A pointer to the type of test content record. + /// - hint: An optional hint value. + /// + /// - Returns: An instance of the test content type `T`, or `nil` if the + /// underlying test content record did not match `hint` or otherwise did not + /// produce a value. + /// + /// Do not call this function directly. Instead, call ``load(withHint:)``. + private static func _load(using accessor: _TestContentRecordAccessor, withTypeAt typeAddress: UnsafeRawPointer, withHint hint: Hint? = nil) -> T? { + withUnsafeTemporaryAllocation(of: T.self, capacity: 1) { buffer in + let initialized = if let hint { + withUnsafePointer(to: hint) { hint in + accessor(buffer.baseAddress!, typeAddress, hint, 0) + } + } else { + accessor(buffer.baseAddress!, typeAddress, nil, 0) + } + guard initialized else { + return nil + } + return buffer.baseAddress!.move() + } + } + /// Load the value represented by this record. /// /// - Parameters: @@ -157,21 +185,14 @@ public struct TestContentRecord where T: DiscoverableAsTestContent & ~Copyabl return nil } - return withUnsafePointer(to: T.self) { type in - withUnsafeTemporaryAllocation(of: T.self, capacity: 1) { buffer in - let initialized = if let hint { - withUnsafePointer(to: hint) { hint in - accessor(buffer.baseAddress!, type, hint, 0) - } - } else { - accessor(buffer.baseAddress!, type, nil, 0) - } - guard initialized else { - return nil - } - return buffer.baseAddress!.move() - } +#if !hasFeature(Embedded) + return withUnsafePointer(to: T.self) { typeAddress in + Self._load(using: accessor, withTypeAt: typeAddress, withHint: hint) } +#else + let typeAddress = UnsafeRawPointer(bitPattern: UInt(T.testContentKind.rawValue)).unsafelyUnwrapped + return Self._load(using: accessor, withTypeAt: typeAddress, withHint: hint) +#endif } } @@ -188,7 +209,11 @@ extension TestContentRecord: Sendable where Context: Sendable {} extension TestContentRecord: CustomStringConvertible { public var description: String { +#if !hasFeature(Embedded) let typeName = String(describing: Self.self) +#else + let typeName = "TestContentRecord" +#endif switch _recordStorage { case let .atAddress(recordAddress): let recordAddress = imageAddress.map { imageAddress in diff --git a/Tests/TestingMacrosTests/ConditionMacroTests.swift b/Tests/TestingMacrosTests/ConditionMacroTests.swift index 07d84b0f8..cd1333941 100644 --- a/Tests/TestingMacrosTests/ConditionMacroTests.swift +++ b/Tests/TestingMacrosTests/ConditionMacroTests.swift @@ -383,6 +383,30 @@ struct ConditionMacroTests { #expect(diagnostic.message.contains("is redundant")) } +#if ExperimentalExitTestValueCapture + @Test("#expect(exitsWith:) produces a diagnostic for a bad capture", + arguments: [ + "#expectExitTest(exitsWith: x) { [weak a] in }": + "Specifier 'weak' cannot be used with captured value 'a'", + "#expectExitTest(exitsWith: x) { [a] in }": + "Type of captured value 'a' is ambiguous", + "#expectExitTest(exitsWith: x) { [a = b] in }": + "Type of captured value 'a' is ambiguous", + ] + ) + func exitTestCaptureDiagnostics(input: String, expectedMessage: String) throws { + try ExitTestExpectMacro.$isValueCapturingEnabled.withValue(true) { + let (_, diagnostics) = try parse(input) + + #expect(diagnostics.count > 0) + for diagnostic in diagnostics { + #expect(diagnostic.diagMessage.severity == .error) + #expect(diagnostic.message == expectedMessage) + } + } + } +#endif + @Test( "Capture list on an exit test produces a diagnostic", arguments: [ @@ -391,12 +415,14 @@ struct ConditionMacroTests { ] ) func exitTestCaptureListProducesDiagnostic(input: String, expectedMessage: String) throws { - let (_, diagnostics) = try parse(input) + try ExitTestExpectMacro.$isValueCapturingEnabled.withValue(false) { + let (_, diagnostics) = try parse(input) - #expect(diagnostics.count > 0) - for diagnostic in diagnostics { - #expect(diagnostic.diagMessage.severity == .error) - #expect(diagnostic.message == expectedMessage) + #expect(diagnostics.count > 0) + for diagnostic in diagnostics { + #expect(diagnostic.diagMessage.severity == .error) + #expect(diagnostic.message == expectedMessage) + } } } diff --git a/Tests/TestingTests/AttachmentTests.swift b/Tests/TestingTests/AttachmentTests.swift index 126633776..0281b4091 100644 --- a/Tests/TestingTests/AttachmentTests.swift +++ b/Tests/TestingTests/AttachmentTests.swift @@ -8,11 +8,11 @@ // See https://swift.org/CONTRIBUTORS.txt for Swift project authors // -@testable @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing +@testable @_spi(ForToolsIntegrationOnly) import Testing private import _TestingInternals #if canImport(Foundation) import Foundation -@_spi(Experimental) import _Testing_Foundation +import _Testing_Foundation #endif #if canImport(CoreGraphics) import CoreGraphics diff --git a/Tests/TestingTests/DiscoveryTests.swift b/Tests/TestingTests/DiscoveryTests.swift index a730f8b53..8ec185813 100644 --- a/Tests/TestingTests/DiscoveryTests.swift +++ b/Tests/TestingTests/DiscoveryTests.swift @@ -59,7 +59,7 @@ struct DiscoveryTests { #endif #if !SWT_NO_DYNAMIC_LINKING && hasFeature(SymbolLinkageMarkers) - struct MyTestContent: Testing.DiscoverableAsTestContent { + struct MyTestContent: DiscoverableAsTestContent { typealias TestContentAccessorHint = UInt32 var value: UInt32 @@ -94,9 +94,11 @@ struct DiscoveryTests { 0xABCD1234, 0, { outValue, type, hint, _ in +#if !hasFeature(Embedded) guard type.load(as: Any.Type.self) == MyTestContent.self else { return false } +#endif if let hint, hint.load(as: TestContentAccessorHint.self) != expectedHint { return false } diff --git a/Tests/TestingTests/ExitTestTests.swift b/Tests/TestingTests/ExitTestTests.swift index bc3425e0a..896784f22 100644 --- a/Tests/TestingTests/ExitTestTests.swift +++ b/Tests/TestingTests/ExitTestTests.swift @@ -380,6 +380,83 @@ private import _TestingInternals #expect((ExitTest.current != nil) as Bool) } } + +#if ExperimentalExitTestValueCapture + @Test("Capture list") + func captureList() async { + let i = 123 + let s = "abc" as Any + await #expect(exitsWith: .success) { [i = i as Int, s = s as! String, t = (s as Any) as? String?] in + #expect(i == 123) + #expect(s == "abc") + #expect(t == "abc") + } + } + + @Test("Capture list (very long encoded form)") + func longCaptureList() async { + let count = 1 * 1024 * 1024 + let buffer = Array(repeatElement(0 as UInt8, count: count)) + await #expect(exitsWith: .success) { [count = count as Int, buffer = buffer as [UInt8]] in + #expect(buffer.count == count) + } + } + + struct CapturableSuite: Codable { + var property = 456 + + @Test("self in capture list") + func captureListWithSelf() async { + await #expect(exitsWith: .success) { [self, x = self] in + #expect(self.property == 456) + #expect(x.property == 456) + } + } + } + + class CapturableBaseClass: @unchecked Sendable, Codable { + init() {} + + required init(from decoder: any Decoder) throws {} + func encode(to encoder: any Encoder) throws {} + } + + final class CapturableDerivedClass: CapturableBaseClass, @unchecked Sendable { + let x: Int + + init(x: Int) { + self.x = x + super.init() + } + + required init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + self.x = try container.decode(Int.self) + super.init() + } + + override func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(x) + } + } + + @Test("Capturing an instance of a subclass") + func captureSubclass() async { + let instance = CapturableDerivedClass(x: 123) + await #expect(exitsWith: .success) { [instance = instance as CapturableBaseClass] in + #expect((instance as AnyObject) is CapturableBaseClass) + // However, because the static type of `instance` is not Derived, we won't + // be able to cast it to Derived. + #expect(!((instance as AnyObject) is CapturableDerivedClass)) + } + await #expect(exitsWith: .success) { [instance = instance as CapturableDerivedClass] in + #expect((instance as AnyObject) is CapturableBaseClass) + #expect((instance as AnyObject) is CapturableDerivedClass) + #expect(instance.x == 123) + } + } +#endif } // MARK: - Fixtures diff --git a/Tests/TestingTests/IssueTests.swift b/Tests/TestingTests/IssueTests.swift index d22bf9fba..6ea1a5827 100644 --- a/Tests/TestingTests/IssueTests.swift +++ b/Tests/TestingTests/IssueTests.swift @@ -1010,6 +1010,8 @@ final class IssueTests: XCTestCase { return } XCTAssertFalse(issue.isKnown) + XCTAssertEqual(issue.severity, .error) + XCTAssertTrue(issue.isFailure) guard case .unconditional = issue.kind else { XCTFail("Unexpected issue kind \(issue.kind)") return @@ -1021,6 +1023,26 @@ final class IssueTests: XCTestCase { Issue.record("Custom message") }.run(configuration: configuration) } + + func testWarning() async throws { + var configuration = Configuration() + configuration.eventHandler = { event, _ in + guard case let .issueRecorded(issue) = event.kind else { + return + } + XCTAssertFalse(issue.isKnown) + XCTAssertEqual(issue.severity, .warning) + XCTAssertFalse(issue.isFailure) + guard case .unconditional = issue.kind else { + XCTFail("Unexpected issue kind \(issue.kind)") + return + } + } + + await Test { + Issue.record("Custom message", severity: .warning) + }.run(configuration: configuration) + } #if !SWT_NO_UNSTRUCTURED_TASKS func testFailWithoutCurrentTest() async throws { @@ -1048,6 +1070,7 @@ final class IssueTests: XCTestCase { return } XCTAssertFalse(issue.isKnown) + XCTAssertEqual(issue.severity, .error) guard case let .errorCaught(error) = issue.kind else { XCTFail("Unexpected issue kind \(issue.kind)") return @@ -1060,6 +1083,27 @@ final class IssueTests: XCTestCase { Issue.record(MyError(), "Custom message") }.run(configuration: configuration) } + + func testWarningBecauseOfError() async throws { + var configuration = Configuration() + configuration.eventHandler = { event, _ in + guard case let .issueRecorded(issue) = event.kind else { + return + } + XCTAssertFalse(issue.isKnown) + XCTAssertEqual(issue.severity, .warning) + guard case let .errorCaught(error) = issue.kind else { + XCTFail("Unexpected issue kind \(issue.kind)") + return + } + XCTAssertTrue(error is MyError) + } + + await Test { + Issue.record(MyError(), severity: .warning) + Issue.record(MyError(), "Custom message", severity: .warning) + }.run(configuration: configuration) + } func testErrorPropertyValidForThrownErrors() async throws { var configuration = Configuration() diff --git a/Tests/TestingTests/KnownIssueTests.swift b/Tests/TestingTests/KnownIssueTests.swift index 448b8e35d..733fbbf01 100644 --- a/Tests/TestingTests/KnownIssueTests.swift +++ b/Tests/TestingTests/KnownIssueTests.swift @@ -10,7 +10,7 @@ #if canImport(XCTest) import XCTest -@testable @_spi(ForToolsIntegrationOnly) import Testing +@testable @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing final class KnownIssueTests: XCTestCase { func testIssueIsKnownPropertyIsSetCorrectly() async { @@ -26,6 +26,7 @@ final class KnownIssueTests: XCTestCase { issueRecorded.fulfill() XCTAssertTrue(issue.isKnown) + XCTAssertFalse(issue.isFailure) } await Test { diff --git a/Tests/TestingTests/MiscellaneousTests.swift b/Tests/TestingTests/MiscellaneousTests.swift index 1f18f20a9..b4b12a217 100644 --- a/Tests/TestingTests/MiscellaneousTests.swift +++ b/Tests/TestingTests/MiscellaneousTests.swift @@ -139,6 +139,11 @@ struct SendableTests: Sendable { @Suite("Named Sendable test type", .hidden) struct NamedSendableTests: Sendable {} +// This is meant to help detect unqualified usages of the `Actor` protocol from +// Swift's `_Concurrency` module in macro expansion code, since it's possible +// for another module to declare a type with that name. +private class Actor {} + #if !SWT_NO_GLOBAL_ACTORS @Suite(.hidden) @MainActor