diff --git a/.editorconfig b/.editorconfig index f9e5f815a..30a99e77b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,3 +1,13 @@ +## +## This source file is part of the Swift.org open source project +## +## Copyright (c) 2024-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 +## + # EditorConfig documentation: https://editorconfig.org root = true diff --git a/.github/ISSUE_TEMPLATE/01-bug-report.yml b/.github/ISSUE_TEMPLATE/01-bug-report.yml index ec821ae82..9e7cc171f 100644 --- a/.github/ISSUE_TEMPLATE/01-bug-report.yml +++ b/.github/ISSUE_TEMPLATE/01-bug-report.yml @@ -9,7 +9,7 @@ name: πŸͺ² Report a bug description: > Report a deviation from expected or documented behavior. -labels: [bug, triage-needed] +labels: [bug] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/02-change-request.yml b/.github/ISSUE_TEMPLATE/02-change-request.yml index 4647a4560..fc29f3c44 100644 --- a/.github/ISSUE_TEMPLATE/02-change-request.yml +++ b/.github/ISSUE_TEMPLATE/02-change-request.yml @@ -9,7 +9,7 @@ name: 🌟 Request a change description: > Request a feature, API, improvement, or other change. -labels: [enhancement, triage-needed] +labels: [enhancement] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 3add3b3e3..3d01084aa 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -9,7 +9,7 @@ blank_issues_enabled: true contact_links: - name: 🌐 Discuss an idea - url: https://forums.swift.org/c/related-projects/swift-testing + url: https://forums.swift.org/c/development/swift-testing/103 about: > Share an idea with the Swift Testing community. - name: πŸ“„ Formally propose a change @@ -18,10 +18,10 @@ contact_links: Formally propose an addition, removal, or change to the APIs or features of Swift Testing. - name: πŸ™‹ Ask a question - url: https://forums.swift.org/c/related-projects/swift-testing + url: https://forums.swift.org/c/swift-users/15 about: > - Ask a question about or get help with Swift Testing. Beginner questions - welcome! + Ask a question or get help by starting a new Forum topic with the 'swift-testing' tag. + Beginner questions welcome! - name: πŸͺ² Report an issue with Swift Package Manager url: https://github.com/swiftlang/swift-package-manager/issues/new/choose about: > diff --git a/.github/workflows/main_using_main.yml b/.github/workflows/main_using_main.yml new file mode 100644 index 000000000..dbf27c0f4 --- /dev/null +++ b/.github/workflows/main_using_main.yml @@ -0,0 +1,25 @@ +name: main branch, main toolchain + +permissions: + contents: read + +on: + push: + branches: + - 'main' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + tests: + name: Test + uses: swiftlang/github-workflows/.github/workflows/swift_package_test.yml@main + with: + linux_swift_versions: '["nightly-main"]' + linux_os_versions: '["amazonlinux2", "jammy"]' + windows_swift_versions: '["nightly-main"]' + enable_macos_checks: true + macos_exclude_xcode_versions: '[{"xcode_version": "16.2"}, {"xcode_version": "16.3"}, {"xcode_version": "16.4"}]' + enable_wasm_sdk_build: true diff --git a/.github/workflows/main_using_release.yml b/.github/workflows/main_using_release.yml new file mode 100644 index 000000000..b861be1f8 --- /dev/null +++ b/.github/workflows/main_using_release.yml @@ -0,0 +1,25 @@ +name: main branch, 6.2 toolchain + +permissions: + contents: read + +on: + push: + branches: + - 'main' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + tests: + name: Test + uses: swiftlang/github-workflows/.github/workflows/swift_package_test.yml@main + with: + linux_swift_versions: '["nightly-6.2"]' + linux_os_versions: '["amazonlinux2", "jammy"]' + windows_swift_versions: '["nightly-6.2"]' + enable_macos_checks: true + macos_exclude_xcode_versions: '[{"xcode_version": "16.2"}, {"xcode_version": "16.3"}, {"xcode_version": "16.4"}]' + enable_wasm_sdk_build: true diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml new file mode 100644 index 000000000..086b2226e --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,35 @@ +name: Pull request + +permissions: + contents: read + +on: + pull_request: + types: [opened, reopened, synchronize] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + tests: + name: Test + uses: swiftlang/github-workflows/.github/workflows/swift_package_test.yml@main + with: + linux_swift_versions: '["nightly-main", "nightly-6.2"]' + linux_os_versions: '["amazonlinux2", "jammy"]' + windows_swift_versions: '["nightly-main", "nightly-6.2"]' + enable_macos_checks: true + macos_exclude_xcode_versions: '[{"xcode_version": "16.2"}, {"xcode_version": "16.3"}, {"xcode_version": "16.4"}]' + enable_ios_checks: true + ios_host_exclude_xcode_versions: '[{"xcode_version": "16.2"}, {"xcode_version": "16.3"}, {"xcode_version": "16.4"}]' + enable_wasm_sdk_build: true + enable_android_sdk_build: true + soundness: + name: Soundness + uses: swiftlang/github-workflows/.github/workflows/soundness.yml@main + with: + license_header_check_project_name: "Swift" + docs_check_enabled: false + format_check_enabled: false + api_breakage_check_enabled: false diff --git a/.license_header_template b/.license_header_template new file mode 100644 index 000000000..d99c3c0f0 --- /dev/null +++ b/.license_header_template @@ -0,0 +1,9 @@ +@@ +@@ This source file is part of the Swift.org open source project +@@ +@@ Copyright (c) YEARS 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 +@@ diff --git a/.licenseignore b/.licenseignore new file mode 100644 index 000000000..b73b15c97 --- /dev/null +++ b/.licenseignore @@ -0,0 +1,4 @@ +Package.swift +**/*.xctestplan +**/*.xcscheme +**/*.swiftoverlay diff --git a/CMakeLists.txt b/CMakeLists.txt index 38aeda617..3714e8c87 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,8 +3,8 @@ # Copyright (c) 2024 Apple Inc. and the Swift project authors # Licensed under Apache License v2.0 with Runtime Library Exception # -# See http://swift.org/LICENSE.txt for license information -# See http://swift.org/CONTRIBUTORS.txt for Swift project authors +# See https://swift.org/LICENSE.txt for license information +# See https://swift.org/CONTRIBUTORS.txt for Swift project authors cmake_minimum_required(VERSION 3.19.6...3.29) @@ -19,6 +19,8 @@ if(POLICY CMP0157) endif() endif() +set(SWT_SOURCE_ROOT_DIR ${CMAKE_SOURCE_DIR}) + project(SwiftTesting LANGUAGES CXX Swift) diff --git a/CODEOWNERS b/CODEOWNERS index e2590577e..24d168f0e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,11 +1,11 @@ # # 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 # See https://swift.org/CONTRIBUTORS.txt for Swift project authors # -* @stmontgomery @grynspan @briancroom @suzannaratcliff +* @stmontgomery @grynspan @briancroom @jerryjrchen diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c63bd0ce5..44c16d305 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,11 +48,10 @@ and install a toolchain. #### Installing a toolchain -1. Download a toolchain. A recent **6.0 development snapshot** toolchain is - required to build the testing library. Visit - [swift.org](http://swift.org/install) and download the most recent toolchain - from the section titled **release/6.0** under **Development Snapshots** on - the page for your platform. +1. Download a toolchain. A recent **development snapshot** toolchain is required + to build the testing library. Visit [swift.org](https://swift.org/install), + select your platform, and download the most recent toolchain from the section + titled **release/6.x** under **Development Snapshots**. Be aware that development snapshot toolchains aren't intended for day-to-day development and may contain defects that affect the programs built with them. diff --git a/Dockerfile b/Dockerfile index d18db2add..5776af6e7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,17 @@ -# This source file is part of the Swift.org open source project -# -# Copyright (c) 2023 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 +## +## This source file is part of the Swift.org open source project +## +## Copyright (c) 2023 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 +## FROM swiftlang/swift:nightly-main-jammy # Set up the current build user in the same way done in the Swift.org CI system: -# https://github.com/swiftlang/swift-docker/blob/main/swift-ci/master/ubuntu/22.04/Dockerfile +# https://github.com/swiftlang/swift-docker/blob/main/swift-ci/main/ubuntu/22.04/Dockerfile RUN groupadd -g 998 build-user && \ useradd -m -r -u 998 -g build-user build-user diff --git a/Documentation/CMake.md b/Documentation/CMake.md index e846f5641..37b9e1d4e 100644 --- a/Documentation/CMake.md +++ b/Documentation/CMake.md @@ -59,21 +59,38 @@ endif() ## Add an entry point You must include a source file in your test executable target with a -`@main` entry point. The following example uses the SwiftPM entry point: +`@main` entry point. The example main below requires the experimental +`Extern` feature. The function `swt_abiv0_getEntryPoint` is exported +from the swift-testing dylib. As such, its declaration could instead +be written in a C header file with its own `module.modulemap`, or +the runtime address could be obtained via +[`dlsym()`](https://pubs.opengroup.org/onlinepubs/9799919799/functions/dlsym.html) or +[`GetProcAddress()`](https://learn.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-getprocaddress). ```swift -import Testing +typealias EntryPoint = @convention(thin) @Sendable (_ configurationJSON: UnsafeRawBufferPointer?, _ recordHandler: @escaping @Sendable (_ recordJSON: UnsafeRawBufferPointer) -> Void) async throws -> Bool + +@_extern(c, "swt_abiv0_getEntryPoint") +func swt_abiv0_getEntryPoint() -> UnsafeRawPointer @main struct Runner { - static func main() async { - await Testing.__swiftPMEntryPoint() as Never + static func main() async throws { + nonisolated(unsafe) let configurationJSON: UnsafeRawBufferPointer? = nil + let recordHandler: @Sendable (UnsafeRawBufferPointer) -> Void = { _ in } + + let entryPoint = unsafeBitCast(swt_abiv0_getEntryPoint(), to: EntryPoint.self) + + if try await entryPoint(configurationJSON, recordHandler) { + exit(EXIT_SUCCESS) + } else { + exit(EXIT_FAILURE) + } } } ``` -> [!WARNING] -> The entry point is expected to change to an entry point designed for other -> build systems prior to the initial stable release of Swift Testing. +For more information on the input configuration and output records of the ABI entry +point, refer to the [ABI documentation](ABI/JSON.md). ## Integrate with CTest diff --git a/Documentation/EnvironmentVariables.md b/Documentation/EnvironmentVariables.md new file mode 100644 index 000000000..a73ffdc65 --- /dev/null +++ b/Documentation/EnvironmentVariables.md @@ -0,0 +1,66 @@ + + +# Environment variables in Swift Testing + +This document lists the environment variables that Swift Testing currently uses. +This list is meant for use by developers working on Swift Testing. + +Those environment variables marked with `*` are defined by components outside +Swift Testing. In general, environment variables that Swift Testing defines have +names prefixed with `SWT_`. + +> [!WARNING] +> This document is not an API contract. The set of environment variables Swift +> Testing uses may change at any time. + +## Console output + +| Variable Name | Value Type | Notes | +|-|:-:|-| +| `COLORTERM`\* | `String` | Used to determine if the current terminal supports 24-bit color. Common across UNIX-like platforms. | +| `NO_COLOR`[\*](https://no-color.org) | `Any?` | If set to any value, disables color output regardless of terminal capabilities. | +| `SWT_ENABLE_EXPERIMENTAL_CONSOLE_OUTPUT` | `Bool` | Used to enable or disable experimental console output. | +| `SWT_SF_SYMBOLS_ENABLED` | `Bool` | Used to explicitly enable or disable SF Symbols support on macOS. | +| `TERM`[\*](https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/V1_chap08.html) | `String` | Used to determine if the current terminal supports 4- or 8-bit color. Common across UNIX-like platforms. | + +## Error handling + +| Variable Name | Value Type | Notes | +|-|:-:|-| +| `SWT_FOUNDATION_ERROR_BACKTRACING_ENABLED` | `Bool` | Used to explicitly enable or disable error backtrace capturing when an instance of `NSError` or `CFError` is created on Apple platforms. | +| `SWT_SWIFT_ERROR_BACKTRACING_ENABLED` | `Bool` | Used to explicitly enable or disable error backtrace capturing when a Swift error is thrown. | + +## Event streams + +| Variable Name | Value Type | Notes | +|-|:-:|-| +| `SWT_EXPERIMENTAL_EVENT_STREAM_FIELDS_ENABLED` | `Bool` | Used to explicitly enable or disable experimental fields in the JSON event stream. | +| `SWT_PRETTY_PRINT_JSON` | `Bool` | Used to enable pretty-printed JSON output to the event stream (for debugging purposes). | + +## Exit tests + +| Variable Name | Value Type | Notes | +|-|:-:|-| +| `SWT_BACKCHANNEL` | `CInt`/`HANDLE` | A file descriptor (handle on Windows) to which the exit test's events are written. | +| `SWT_CAPTURED_VALUES` | `CInt`/`HANDLE` | A file descriptor (handle on Windows) containing captured values passed to the exit test. | +| `SWT_CLOSEFROM` | `CInt` | Used on OpenBSD to emulate `posix_spawn_file_actions_addclosefrom_np()`. | +| `SWT_EXIT_TEST_ID` | `String` (JSON) | Specifies which exit test to run. | +| `XCTestBundlePath`\* | `String` | Used on Apple platforms to determine if Xcode is hosting the test run. | + +## Miscellaneous + +| Variable Name | Value Type | Notes | +|-|:-:|-| +| `CFFIXED_USER_HOME`\* | `String` | Used on Apple platforms to determine the user's home directory. | +| `HOME`[\*](https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/V1_chap08.html) | `String` | Used to determine the user's home directory. | +| `SIMULATOR_RUNTIME_BUILD_VERSION`\* | `String` | Used when running in the iOS (etc.) Simulator to determine the simulator's version. | +| `SIMULATOR_RUNTIME_VERSION`\* | `String` | Used when running in the iOS (etc.) Simulator to determine the simulator's version. | +| `SWT_USE_LEGACY_TEST_DISCOVERY` | `Bool` | Used to explicitly enable or disable legacy test discovery. | diff --git a/Documentation/Porting.md b/Documentation/Porting.md index 6e83e0eb0..4773e2823 100644 --- a/Documentation/Porting.md +++ b/Documentation/Porting.md @@ -67,6 +67,9 @@ platform-specific attention. > conflicting requirements (for example, attempting to enable support for pipes > without also enabling support for file I/O.) You should be able to resolve > these issues by updating `Package.swift` and/or `CompilerSettings.cmake`. +> +> Don't forget to add your platform to the `BuildSettingCondition/whenApple(_:)` +> function in `Package.swift`. Most platform dependencies can be resolved through the use of platform-specific API. For example, Swift Testing uses the C11 standard [`timespec`](https://en.cppreference.com/w/c/chrono/timespec) @@ -169,8 +172,10 @@ to load that information: + } + let sb = SectionBounds( + imageAddress: UnsafeRawPointer(bitPattern: UInt(refNum)), -+ start: handle.pointee!, -+ size: GetHandleSize(handle) ++ buffer: UnsafeRawBufferPointer( ++ start: handle.pointee, ++ count: GetHandleSize(handle) ++ ) + ) + result.append(sb) + } while noErr == GetNextResourceFile(refNum, &refNum)) diff --git a/Documentation/StyleGuide.md b/Documentation/StyleGuide.md index 006cf9c51..b9c565e04 100644 --- a/Documentation/StyleGuide.md +++ b/Documentation/StyleGuide.md @@ -71,10 +71,10 @@ to the code called by the initialization expression causing the inferred type of its property to change unknowingly, which could break clients. Properties with lower access levels may have an inferred type. -Exported C and C++ symbols that are exported should be given the prefix `swt_` -and should otherwise be named using the same lowerCamelCase naming rules as in -Swift. Use the `SWT_EXTERN` macro to ensure that symbols are consistently -visible in C, C++, and Swift. For example: +C and C++ symbols that are used by the testing library should be given the +prefix `swt_` and should otherwise be named using the same lowerCamelCase naming +rules as in Swift. Use the `SWT_EXTERN` macro to ensure that symbols are +consistently visible in C, C++, and Swift. For example: ```c SWT_EXTERN bool swt_isDebugModeEnabled(void); @@ -82,6 +82,15 @@ SWT_EXTERN bool swt_isDebugModeEnabled(void); SWT_EXTERN void swt_setDebugModeEnabled(bool isEnabled); ``` +> [!NOTE] +> If a symbol is meant to be **publicly visible** and can be called by modules +> other than Swift Testing, use the prefix `swift_testing_` instead of `swt_` +> for consistency with the Swift standard library: +> +> ```c +> SWT_EXTERN void swift_testing_debugIfNeeded(void); +> ``` + C and C++ types should be given the prefix `SWT` and should otherwise be named using the same UpperCamelCase naming rules as in Swift. For example: diff --git a/Package.swift b/Package.swift index 44116b6d1..a82241527 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.1 +// swift-tools-version: 6.2 // // This source file is part of the Swift.org open source project @@ -27,7 +27,7 @@ let buildingForDevelopment = (git?.currentTag == nil) /// 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. +/// 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 { @@ -84,6 +84,19 @@ let package = Package( ) #endif + result += [ + .library( + name: "_Testing_ExperimentalImageAttachments", + targets: [ + "_Testing_AppKit", + "_Testing_CoreGraphics", + "_Testing_CoreImage", + "_Testing_UIKit", + "_Testing_WinSDK", + ] + ) + ] + result.append( .library( name: "_TestDiscovery", @@ -92,18 +105,30 @@ let package = Package( ) ) +#if DEBUG + // Build _TestingInterop for debugging/testing purposes only. It is + // important that clients do not link to this product/target. + result += [ + .library( + name: "_TestingInterop_DO_NOT_USE", + targets: ["_TestingInterop_DO_NOT_USE"] + ) + ] +#endif + 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: "602.0.0-latest"), + // swift-syntax periodically publishes a new tag with a suffix of the format + // "-prerelease-YYYY-MM-DD". We always want to use the most recent tag + // associated with a particular Swift version, without needing to hardcode + // an exact tag and manually keep it up-to-date. Specifying the suffix + // "-latest" on this dependency is a workaround which causes Swift package + // manager to use the lexicographically highest-sorted tag with the + // specified semantic version, meaning the most recent "prerelease" tag will + // always be used. + .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "603.0.0-latest"), ], targets: [ @@ -125,10 +150,32 @@ let package = Package( name: "TestingTests", dependencies: [ "Testing", + "_Testing_AppKit", "_Testing_CoreGraphics", + "_Testing_CoreImage", "_Testing_Foundation", + "_Testing_UIKit", + "_Testing_WinSDK", + "MemorySafeTestingTests", ], - swiftSettings: .packageSettings + swiftSettings: .packageSettings, + linkerSettings: [ + .linkedLibrary("util", .when(platforms: [.openbsd])) + ] + ), + + // Use a plain `.target` instead of a `.testTarget` to avoid the unnecessary + // overhead of having a separate test target for this module. Conceptually, + // the content in this module is no different than content which would + // typically be placed in the `TestingTests` target, except this content + // needs the (module-wide) strict memory safety feature to be enabled. + .target( + name: "MemorySafeTestingTests", + dependencies: [ + "Testing", + ], + path: "Tests/_MemorySafeTestingTests", + swiftSettings: .packageSettings + [.strictMemorySafety()] ), .macro( @@ -173,14 +220,45 @@ let package = Package( cxxSettings: .packageSettings, swiftSettings: .packageSettings + .enableLibraryEvolution() ), + .target( + // Build _TestingInterop for debugging/testing purposes only. It is + // important that clients do not link to this product/target. + name: "_TestingInterop_DO_NOT_USE", + dependencies: ["_TestingInternals",], + path: "Sources/_TestingInterop", + exclude: ["CMakeLists.txt"], + cxxSettings: .packageSettings, + swiftSettings: .packageSettings + ), // Cross-import overlays (not supported by Swift Package Manager) + .target( + name: "_Testing_AppKit", + dependencies: [ + "Testing", + "_Testing_CoreGraphics", + ], + path: "Sources/Overlays/_Testing_AppKit", + exclude: ["CMakeLists.txt"], + swiftSettings: .packageSettings + .enableLibraryEvolution() + ), .target( name: "_Testing_CoreGraphics", dependencies: [ "Testing", ], path: "Sources/Overlays/_Testing_CoreGraphics", + exclude: ["CMakeLists.txt"], + swiftSettings: .packageSettings + .enableLibraryEvolution() + ), + .target( + name: "_Testing_CoreImage", + dependencies: [ + "Testing", + "_Testing_CoreGraphics", + ], + path: "Sources/Overlays/_Testing_CoreImage", + exclude: ["CMakeLists.txt"], swiftSettings: .packageSettings + .enableLibraryEvolution() ), .target( @@ -193,7 +271,27 @@ let package = Package( // The Foundation module only has Library Evolution enabled on Apple // platforms, and since this target's module publicly imports Foundation, // it can only enable Library Evolution itself on those platforms. - swiftSettings: .packageSettings + .enableLibraryEvolution(applePlatformsOnly: true) + swiftSettings: .packageSettings + .enableLibraryEvolution(.whenApple()) + ), + .target( + name: "_Testing_UIKit", + dependencies: [ + "Testing", + "_Testing_CoreGraphics", + "_Testing_CoreImage", + ], + path: "Sources/Overlays/_Testing_UIKit", + exclude: ["CMakeLists.txt"], + swiftSettings: .packageSettings + .enableLibraryEvolution() + ), + .target( + name: "_Testing_WinSDK", + dependencies: [ + "Testing", + ], + path: "Sources/Overlays/_Testing_WinSDK", + exclude: ["CMakeLists.txt"], + swiftSettings: .packageSettings + .enableLibraryEvolution() ), // Utility targets: These are utilities intended for use when developing @@ -229,11 +327,11 @@ extension BuildSettingCondition { /// Swift. /// /// - Parameters: - /// - nonEmbeddedCondition: The value to return if the target is not - /// Embedded Swift. If `nil`, the build condition evaluates to `false`. + /// - 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. + /// 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() { @@ -248,6 +346,21 @@ extension BuildSettingCondition { nil } } + + /// A build setting condition representing all Apple or non-Apple platforms. + /// + /// - Parameters: + /// - isApple: Whether or not the result represents Apple platforms. + /// + /// - Returns: A build setting condition that evaluates to `isApple` for Apple + /// platforms. + static func whenApple(_ isApple: Bool = true) -> Self { + if isApple { + .when(platforms: [.macOS, .iOS, .macCatalyst, .watchOS, .tvOS, .visionOS]) + } else { + .when(platforms: [.linux, .custom("freebsd"), .openbsd, .windows, .wasi, .android]) + } + } } extension Array where Element == PackageDescription.SwiftSetting { @@ -277,41 +390,26 @@ 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"), + .enableUpcomingFeature("InferIsolatedConformances"), // 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_TARGET_OS_APPLE", .whenApple()), .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_SNAPSHOT_TYPES", .whenEmbedded(or: .whenApple(false))), .define("SWT_NO_DYNAMIC_LINKING", .whenEmbedded(or: .when(platforms: [.wasi]))), .define("SWT_NO_PIPES", .whenEmbedded(or: .when(platforms: [.wasi]))), + .define("SWT_NO_FOUNDATION_FILE_COORDINATION", .whenEmbedded(or: .whenApple(false))), + .define("SWT_NO_IMAGE_ATTACHMENTS", .whenEmbedded(or: .when(platforms: [.linux, .custom("freebsd"), .openbsd, .wasi, .android]))), .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 } @@ -329,25 +427,22 @@ extension Array where Element == PackageDescription.SwiftSetting { .enableExperimentalFeature("AvailabilityMacro=_regexAPI:macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0"), .enableExperimentalFeature("AvailabilityMacro=_swiftVersionAPI:macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0"), .enableExperimentalFeature("AvailabilityMacro=_typedThrowsAPI:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0"), + .enableExperimentalFeature("AvailabilityMacro=_castingWithNonCopyableGenerics:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0"), .enableExperimentalFeature("AvailabilityMacro=_distantFuture:macOS 99.0, iOS 99.0, watchOS 99.0, tvOS 99.0, visionOS 99.0"), ] } - /// Create a Swift setting which enables Library Evolution, optionally - /// constraining it to only Apple platforms. + /// Create a Swift setting which enables Library Evolution. /// /// - Parameters: - /// - applePlatformsOnly: Whether to constrain this setting to only Apple - /// platforms. - static func enableLibraryEvolution(applePlatformsOnly: Bool = false) -> Self { + /// - condition: A build setting condition to apply to this setting. + /// + /// - Returns: A Swift setting that enables Library Evolution. + static func enableLibraryEvolution(_ condition: BuildSettingCondition? = nil) -> Self { var result = [PackageDescription.SwiftSetting]() if buildingForDevelopment { - var condition: BuildSettingCondition? - if applePlatformsOnly { - condition = .when(platforms: [.macOS, .iOS, .macCatalyst, .watchOS, .tvOS, .visionOS]) - } result.append(.unsafeFlags(["-enable-library-evolution"], condition)) } @@ -364,24 +459,25 @@ extension Array where Element == PackageDescription.CXXSetting { result += [ .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_SNAPSHOT_TYPES", .whenEmbedded(or: .whenApple(false))), .define("SWT_NO_DYNAMIC_LINKING", .whenEmbedded(or: .when(platforms: [.wasi]))), .define("SWT_NO_PIPES", .whenEmbedded(or: .when(platforms: [.wasi]))), + .define("SWT_NO_FOUNDATION_FILE_COORDINATION", .whenEmbedded(or: .whenApple(false))), .define("SWT_NO_LEGACY_TEST_DISCOVERY", .whenEmbedded()), .define("SWT_NO_LIBDISPATCH", .whenEmbedded()), ] - // Capture the testing library's version as a C++ string constant. + // Capture the testing library's commit info as C++ constants. if let git { - let testingLibraryVersion = if let tag = git.currentTag { - tag - } else if git.hasUncommittedChanges { - "\(git.currentCommit) (modified)" - } else { - git.currentCommit + result.append(.define("SWT_TESTING_LIBRARY_COMMIT_HASH", to: #""\#(git.currentCommit)""#)) + if git.hasUncommittedChanges { + result.append(.define("SWT_TESTING_LIBRARY_COMMIT_MODIFIED", to: "1")) } - result.append(.define("SWT_TESTING_LIBRARY_VERSION", to: #""\#(testingLibraryVersion)""#)) + } else if let gitHubSHA = Context.environment["GITHUB_SHA"] { + // When building in GitHub Actions, the git command may fail to get us the + // commit hash, so check if GitHub shared it with us instead. + result.append(.define("SWT_TESTING_LIBRARY_COMMIT_HASH", to: #""\#(gitHubSHA)""#)) } return result diff --git a/README.md b/README.md index 8df465217..1a7e6416e 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ See https://swift.org/CONTRIBUTORS.txt for Swift project authors Swift Testing is a package with expressive and intuitive APIs that make testing your Swift code a breeze. +[![CI status badge for main branch using main toolchain](https://github.com/swiftlang/swift-testing/actions/workflows/main_using_main.yml/badge.svg?branch=main&event=push)](https://github.com/swiftlang/swift-testing/actions/workflows/main_using_main.yml) +[![CI status badge for main branch using 6.2 toolchain](https://github.com/swiftlang/swift-testing/actions/workflows/main_using_release.yml/badge.svg?branch=main&event=push)](https://github.com/swiftlang/swift-testing/actions/workflows/main_using_release.yml) + ## Feature overview ### Clear, expressive API @@ -23,6 +26,8 @@ Swift expressions and operators, and captures the evaluated values so you can quickly understand what went wrong when a test fails. ```swift +import Testing + @Test func helloWorld() { let greeting = "Hello, world!" #expect(greeting == "Hello") // Expectation failed: (greeting β†’ "Hello, world!") == "Hello" @@ -84,25 +89,31 @@ func mentionedContinents(videoName: String) async throws { ### Cross-platform support -Swift Testing works on all major platforms supported by Swift, including Apple -platforms, Linux, and Windows, so your tests can behave more consistently when -moving between platforms. It’s developed as open source and discussed on the -[Swift Forums](https://forums.swift.org/c/development/swift-testing/103) so the -very best ideas, from anywhere, can help shape the future of testing in Swift. +Swift Testing is included in officially-supported Swift toolchains, including +those for Apple platforms, Linux, and Windows. To use the library, import the +`Testing` module: + +```swift +import Testing +``` + +You don't need to declare a package dependency to use Swift Testing. It's +developed as open source and discussed on the +[Swift Forums](https://forums.swift.org/c/development/swift-testing/103) +so the 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.1)** | **CI Status (main)** | **Support Status** | -|---|:-:|:-:|---| -| **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.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-external.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 | +| **Platform** | **Support Status** | +|-|-| +| Apple platforms | Supported | +| Linux | Supported | +| Windows | Supported | +| FreeBSD, OpenBSD | Experimental | +| Wasm | Experimental | +| Android | Experimental | ### Works with XCTest diff --git a/Sources/CMakeLists.txt b/Sources/CMakeLists.txt index 8106eb2a3..4fc0847b7 100644 --- a/Sources/CMakeLists.txt +++ b/Sources/CMakeLists.txt @@ -3,8 +3,8 @@ # Copyright (c) 2024 Apple Inc. and the Swift project authors # Licensed under Apache License v2.0 with Runtime Library Exception # -# See http://swift.org/LICENSE.txt for license information -# See http://swift.org/CONTRIBUTORS.txt for Swift project authors +# See https://swift.org/LICENSE.txt for license information +# See https://swift.org/CONTRIBUTORS.txt for Swift project authors set(SwiftTesting_MACRO "" CACHE STRING "Path to SwiftTesting macro plugin, or '' for automatically building it") @@ -66,7 +66,7 @@ if(SwiftTesting_MACRO STREQUAL "") if(NOT SwiftTesting_BuildMacrosAsExecutables) if(CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") set(SwiftTesting_MACRO_PATH "${SwiftTesting_MACRO_INSTALL_PREFIX}/lib/swift/host/plugins/testing/libTestingMacros.dylib") - elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux" or CMAKE_HOST_SYSTEM_NAME STREQUAL "FreeBSD") + elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux" OR CMAKE_HOST_SYSTEM_NAME STREQUAL "FreeBSD") set(SwiftTesting_MACRO_PATH "${SwiftTesting_MACRO_INSTALL_PREFIX}/lib/swift/host/plugins/libTestingMacros.so") elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows") set(SwiftTesting_MACRO_PATH "${SwiftTesting_MACRO_INSTALL_PREFIX}/bin/TestingMacros.dll") @@ -104,6 +104,7 @@ endif() include(AvailabilityDefinitions) include(CompilerSettings) add_subdirectory(_TestDiscovery) +add_subdirectory(_TestingInterop) add_subdirectory(_TestingInternals) add_subdirectory(Overlays) add_subdirectory(Testing) diff --git a/Sources/Overlays/CMakeLists.txt b/Sources/Overlays/CMakeLists.txt index 2120d680f..434b4d3ec 100644 --- a/Sources/Overlays/CMakeLists.txt +++ b/Sources/Overlays/CMakeLists.txt @@ -1,9 +1,14 @@ # This source file is part of the Swift.org open source project # -# Copyright (c) 2024 Apple Inc. and the Swift project authors +# Copyright (c) 2024–2025 Apple Inc. and the Swift project authors # Licensed under Apache License v2.0 with Runtime Library Exception # -# See http://swift.org/LICENSE.txt for license information -# See http://swift.org/CONTRIBUTORS.txt for Swift project authors +# See https://swift.org/LICENSE.txt for license information +# See https://swift.org/CONTRIBUTORS.txt for Swift project authors +add_subdirectory(_Testing_AppKit) +add_subdirectory(_Testing_CoreGraphics) +add_subdirectory(_Testing_CoreImage) add_subdirectory(_Testing_Foundation) +add_subdirectory(_Testing_UIKit) +add_subdirectory(_Testing_WinSDK) diff --git a/Sources/Overlays/_Testing_AppKit/Attachments/NSImage+AttachableAsImage.swift b/Sources/Overlays/_Testing_AppKit/Attachments/NSImage+AttachableAsImage.swift new file mode 100644 index 000000000..0db26feb6 --- /dev/null +++ b/Sources/Overlays/_Testing_AppKit/Attachments/NSImage+AttachableAsImage.swift @@ -0,0 +1,87 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024–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 +// + +#if SWT_TARGET_OS_APPLE && canImport(AppKit) +public import AppKit +public import _Testing_CoreGraphics + +extension NSImageRep { + /// AppKit's bundle. + private static let _appKitBundle: Bundle = Bundle(for: NSImageRep.self) + + /// Whether or not this image rep's class is effectively thread-safe and can + /// be treated as if it conforms to `Sendable`. + fileprivate var isEffectivelySendable: Bool { + if isMember(of: NSImageRep.self) || isKind(of: NSCustomImageRep.self) { + // NSImageRep itself is an abstract class. NSCustomImageRep includes an + // arbitrary rendering block that may not be concurrency-safe in Swift. + return false + } + + // Treat all other classes declared in AppKit as safe. We can't reason about + // classes declared in other bundles, so treat them all as if they're unsafe. + return Bundle(for: Self.self) == Self._appKitBundle + } +} + +// MARK: - + +/// @Metadata { +/// @Available(Swift, introduced: 6.3) +/// } +extension NSImage: AttachableAsImage, AttachableAsCGImage { + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } + package var attachableCGImage: CGImage { + get throws { + let ctm = AffineTransform(scale: attachmentScaleFactor) as NSAffineTransform + guard let result = cgImage(forProposedRect: nil, context: nil, hints: [.ctm: ctm]) else { + throw ImageAttachmentError.couldNotCreateCGImage + } + return result + } + } + + package var attachmentScaleFactor: CGFloat { + let maxRepWidth = representations.lazy + .map { CGFloat($0.pixelsWide) / $0.size.width } + .filter { $0 > 0.0 } + .max() + return maxRepWidth ?? 1.0 + } + + public func _copyAttachableValue() -> Self { + // If this image is of an NSImage subclass, we cannot reliably make a deep + // copy of it because we don't know what its `init(data:)` implementation + // might do. Try to make a copy (using NSCopying), but if that doesn't work + // then just return `self` verbatim. + // + // Third-party NSImage subclasses are presumably rare in the wild, so + // hopefully this case doesn't pop up too often. + guard isMember(of: NSImage.self) else { + return self.copy() as? Self ?? self + } + + // Check whether the image contains any representations that we don't think + // are safe. If it does, then make a "safe" copy. + let allImageRepsAreSafe = representations.allSatisfy(\.isEffectivelySendable) + if !allImageRepsAreSafe, let safeCopy = tiffRepresentation.flatMap(Self.init(data:)) { + // Create a "safe" copy of this image by flattening it to TIFF and then + // creating a new NSImage instance from it. + return safeCopy + } + + // This image appears to be safe to copy directly. (This call should never + // fail since we already know `self` is a direct instance of `NSImage`.) + return unsafeDowncast(self.copy() as AnyObject, to: Self.self) + } +} +#endif diff --git a/Sources/Overlays/_Testing_AppKit/CMakeLists.txt b/Sources/Overlays/_Testing_AppKit/CMakeLists.txt new file mode 100644 index 000000000..4ce37cfb7 --- /dev/null +++ b/Sources/Overlays/_Testing_AppKit/CMakeLists.txt @@ -0,0 +1,23 @@ +# This source file is part of the Swift.org open source project +# +# Copyright (c) 2024–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 + +if (CMAKE_SYSTEM_NAME STREQUAL "Darwin") + add_library(_Testing_AppKit + Attachments/NSImage+AttachableAsImage.swift + ReexportTesting.swift) + + target_link_libraries(_Testing_AppKit PUBLIC + Testing + _Testing_CoreGraphics) + + target_compile_options(_Testing_AppKit PRIVATE + -enable-library-evolution + -emit-module-interface -emit-module-interface-path $/_Testing_AppKit.swiftinterface) + + _swift_testing_install_target(_Testing_AppKit) +endif() diff --git a/Sources/Overlays/_Testing_AppKit/ReexportTesting.swift b/Sources/Overlays/_Testing_AppKit/ReexportTesting.swift new file mode 100644 index 000000000..2e76ecd44 --- /dev/null +++ b/Sources/Overlays/_Testing_AppKit/ReexportTesting.swift @@ -0,0 +1,12 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024–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 +// + +@_exported public import Testing +@_exported public import _Testing_CoreGraphics diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/AttachableAsCGImage.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/AttachableAsCGImage.swift index 14df843c6..0fbcac010 100644 --- a/Sources/Overlays/_Testing_CoreGraphics/Attachments/AttachableAsCGImage.swift +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/AttachableAsCGImage.swift @@ -1,7 +1,7 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Copyright (c) 2024–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 @@ -9,27 +9,21 @@ // #if SWT_TARGET_OS_APPLE && canImport(CoreGraphics) -public import CoreGraphics -private import ImageIO +package import CoreGraphics +package import ImageIO +private import UniformTypeIdentifiers +#if canImport(UniformTypeIdentifiers_Private) +@_spi(Private) private import UniformTypeIdentifiers +#endif /// A protocol describing images that can be converted to instances of -/// ``Testing/Attachment``. -/// -/// Instances of types conforming to this protocol do not themselves conform to -/// ``Testing/Attachable``. Instead, the testing library provides additional -/// initializers on ``Testing/Attachment`` that take instances of such types and -/// handle converting them to image data when needed. -/// -/// The following system-provided image types conform to this protocol and can -/// be attached to a test: +/// [`Attachment`](https://developer.apple.com/documentation/testing/attachment) +/// and which can be represented as instances of [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage). /// -/// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage) -/// -/// You do not generally need to add your own conformances to this protocol. If -/// you have an image in another format that needs to be attached to a test, -/// first convert it to an instance of one of the types above. -@_spi(Experimental) -public protocol AttachableAsCGImage { +/// This protocol is not part of the public interface of the testing library. It +/// encapsulates Apple-specific logic for image attachments. +@available(_uttypesAPI, *) +package protocol AttachableAsCGImage: AttachableAsImage { /// An instance of `CGImage` representing this image. /// /// - Throws: Any error that prevents the creation of an image. @@ -41,9 +35,9 @@ public protocol AttachableAsCGImage { /// `CGImagePropertyOrientation`. The default value of this property is /// `.up`. /// - /// This property is not part of the public interface of the testing - /// library. It may be removed in a future update. - var _attachmentOrientation: UInt32 { get } + /// This property is not part of the public interface of the testing library. + /// It may be removed in a future update. + var attachmentOrientation: CGImagePropertyOrientation { get } /// The scale factor of the image. /// @@ -51,39 +45,81 @@ public protocol AttachableAsCGImage { /// originates from a Retina Display screenshot or similar. The default value /// of this property is `1.0`. /// - /// This property is not part of the public interface of the testing - /// library. It may be removed in a future update. - var _attachmentScaleFactor: CGFloat { get } - - /// Make a copy of this instance to pass to an attachment. - /// - /// - Returns: A copy of `self`, or `self` if no copy is needed. - /// - /// Several system image types do not conform to `Sendable`; use this - /// function to make copies of such images that will not be shared outside - /// of an attachment and so can be generally safely stored. - /// - /// The default implementation of this function when `Self` conforms to - /// `Sendable` simply returns `self`. - /// - /// This function is not part of the public interface of the testing library. + /// This property is not part of the public interface of the testing library. /// It may be removed in a future update. - func _makeCopyForAttachment() -> Self + var attachmentScaleFactor: CGFloat { get } } +/// All type identifiers supported by Image I/O. +@available(_uttypesAPI, *) +private let _supportedTypeIdentifiers = Set(CGImageDestinationCopyTypeIdentifiers() as? [String] ?? []) + +/// All content types supported by Image I/O. +@available(_uttypesAPI, *) +private let _supportedContentTypes = { +#if canImport(UniformTypeIdentifiers_Private) + UTType._types(identifiers: _supportedTypeIdentifiers).values +#else + _supportedTypeIdentifiers.compactMap(UTType.init(_:)) +#endif +}() + +@available(_uttypesAPI, *) extension AttachableAsCGImage { - public var _attachmentOrientation: UInt32 { - CGImagePropertyOrientation.up.rawValue + package var attachmentOrientation: CGImagePropertyOrientation { + .up } - public var _attachmentScaleFactor: CGFloat { + package var attachmentScaleFactor: CGFloat { 1.0 } -} -extension AttachableAsCGImage where Self: Sendable { - public func _makeCopyForAttachment() -> Self { - self + public func withUnsafeBytes(as imageFormat: AttachableImageFormat, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + let data = NSMutableData() + + // Convert the image to a CGImage. + let attachableCGImage = try attachableCGImage + + // Determine the base content type to use. We do a naΓ―ve case-sensitive + // string comparison on the identifier first as it's faster than querying + // the corresponding UTType instances (because it doesn't need to touch the + // Launch Services database). The common cases where the developer passes + // no image format or passes .png/.jpeg are covered by the fast path. + var contentType = imageFormat.contentType + if !_supportedTypeIdentifiers.contains(contentType.identifier) { + guard let baseType = _supportedContentTypes.first(where: contentType.conforms(to:)) else { + throw ImageAttachmentError.unsupportedImageFormat(contentType.identifier) + } + contentType = baseType + } + + // Create the image destination. + guard let dest = CGImageDestinationCreateWithData(data as CFMutableData, contentType.identifier as CFString, 1, nil) else { + throw ImageAttachmentError.couldNotCreateImageDestination + } + + // Configure the properties of the image conversion operation. + let orientation = attachmentOrientation + let scaleFactor = attachmentScaleFactor + let properties: [CFString: Any] = [ + kCGImageDestinationLossyCompressionQuality: CGFloat(imageFormat.encodingQuality), + kCGImagePropertyOrientation: orientation, + kCGImagePropertyDPIWidth: 72.0 * scaleFactor, + kCGImagePropertyDPIHeight: 72.0 * scaleFactor, + ] + + // Perform the image conversion. + CGImageDestinationAddImage(dest, attachableCGImage, properties as CFDictionary) + guard CGImageDestinationFinalize(dest) else { + throw ImageAttachmentError.couldNotConvertImage + } + + // Pass the bits of the image out to the body. Note that we have an + // NSMutableData here so we have to use slightly different API than we would + // with an instance of Data. + return try withExtendedLifetime(data) { + try body(UnsafeRawBufferPointer(start: data.bytes, count: data.length)) + } } } #endif diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/AttachableImageFormat+UTType.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/AttachableImageFormat+UTType.swift new file mode 100644 index 000000000..1adcdaea4 --- /dev/null +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/AttachableImageFormat+UTType.swift @@ -0,0 +1,131 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024–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 +// + +#if SWT_TARGET_OS_APPLE && canImport(CoreGraphics) +public import UniformTypeIdentifiers + +/// @Metadata { +/// @Available(Swift, introduced: 6.3) +/// } +@available(_uttypesAPI, *) +extension AttachableImageFormat { + /// The content type corresponding to this image format. + /// + /// For example, if this image format equals ``png``, the value of this + /// property equals [`UTType.png`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/png). + /// + /// The value of this property always conforms to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image). + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } + public var contentType: UTType { + kind.contentType + } + + /// Initialize an instance of this type with the given content type and + /// encoding quality. + /// + /// - Parameters: + /// - contentType: The image format to use when encoding images. + /// - encodingQuality: The encoding quality to use when encoding images. For + /// the lowest supported quality, pass `0.0`. For the highest supported + /// quality, pass `1.0`. + /// + /// If the target image format does not support variable-quality encoding, + /// the value of the `encodingQuality` argument is ignored. + /// + /// If `contentType` does not conform to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image), + /// the result is undefined. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } + public init(contentType: UTType, encodingQuality: Float = 1.0) { + switch contentType { + case .png: + self.init(kind: .png, encodingQuality: encodingQuality) + case .jpeg: + self.init(kind: .jpeg, encodingQuality: encodingQuality) + default: + precondition( + contentType.conforms(to: .image), + "An image cannot be attached as an instance of type '\(contentType.identifier)'. Use a type that conforms to 'public.image' instead." + ) + self.init(kind: .systemValue(contentType), encodingQuality: encodingQuality) + } + } + + /// Construct an instance of this type with the given path extension and + /// encoding quality. + /// + /// - Parameters: + /// - pathExtension: A path extension corresponding to the image format to + /// use when encoding images. + /// - encodingQuality: The encoding quality to use when encoding images. For + /// the lowest supported quality, pass `0.0`. For the highest supported + /// quality, pass `1.0`. + /// + /// If the target image format does not support variable-quality encoding, + /// the value of the `encodingQuality` argument is ignored. + /// + /// If `pathExtension` does not correspond to a recognized image format, this + /// initializer returns `nil`: + /// + /// - On Apple platforms, the content type corresponding to `pathExtension` + /// must conform to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image). + /// - On Windows, there must be a corresponding subclass of [`IWICBitmapEncoder`](https://learn.microsoft.com/en-us/windows/win32/api/wincodec/nn-wincodec-iwicbitmapencoder) + /// registered with Windows Imaging Component. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } + public init?(pathExtension: String, encodingQuality: Float = 1.0) { + let pathExtension = pathExtension.drop { $0 == "." } + + guard let contentType = UTType(filenameExtension: String(pathExtension), conformingTo: .image), + contentType.isDeclared else { + return nil + } + + self.init(contentType: contentType, encodingQuality: encodingQuality) + } +} + +// MARK: - CustomStringConvertible, CustomDebugStringConvertible + +@available(_uttypesAPI, *) +extension AttachableImageFormat.Kind: CustomStringConvertible, CustomDebugStringConvertible { + /// The content type corresponding to this image format. + fileprivate var contentType: UTType { + switch self { + case .png: + return .png + case .jpeg: + return .jpeg + case let .systemValue(contentType): + return contentType as! UTType + } + } + + package var description: String { + let contentType = contentType + return contentType.localizedDescription ?? contentType.identifier + } + + package var debugDescription: String { + let contentType = contentType + if let localizedDescription = contentType.localizedDescription { + return "\(localizedDescription) (\(contentType.identifier))" + } + return contentType.identifier + } +} +#endif diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift deleted file mode 100644 index ed1e6a2ee..000000000 --- a/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// 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 -// - -#if SWT_TARGET_OS_APPLE && canImport(CoreGraphics) -@_spi(Experimental) public import Testing - -public import UniformTypeIdentifiers - -extension Attachment { - /// Initialize an instance of this type that encloses the given image. - /// - /// - Parameters: - /// - attachableValue: The value that will be attached to the output of - /// the test run. - /// - preferredName: The preferred name of the attachment when writing it - /// to a test report or to disk. If `nil`, the testing library attempts - /// to derive a reasonable filename for the attached value. - /// - contentType: The image format with which to encode `attachableValue`. - /// If this type does not conform to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image), - /// the result is undefined. Pass `nil` to let the testing library decide - /// which image format to use. - /// - encodingQuality: The encoding quality to use when encoding the image. - /// If the image format used for encoding (specified by the `contentType` - /// argument) does not support variable-quality encoding, the value of - /// this argument is ignored. - /// - sourceLocation: The source location of the call to this initializer. - /// This value is used when recording issues associated with the - /// attachment. - /// - /// This is the designated initializer for this type when attaching an image - /// that conforms to ``AttachableAsCGImage``. - fileprivate init( - attachableValue: T, - named preferredName: String?, - contentType: (any Sendable)?, - encodingQuality: Float, - 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. - /// - /// - Parameters: - /// - attachableValue: The value that will be attached to the output of - /// the test run. - /// - preferredName: The preferred name of the attachment when writing it - /// to a test report or to disk. If `nil`, the testing library attempts - /// to derive a reasonable filename for the attached value. - /// - contentType: The image format with which to encode `attachableValue`. - /// If this type does not conform to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image), - /// the result is undefined. Pass `nil` to let the testing library decide - /// which image format to use. - /// - encodingQuality: The encoding quality to use when encoding the image. - /// If the image format used for encoding (specified by the `contentType` - /// argument) does not support variable-quality encoding, the value of - /// this argument is ignored. - /// - sourceLocation: The source location of the call to this initializer. - /// This value is used when recording issues associated with the - /// attachment. - /// - /// The following system-provided image types conform to the - /// ``AttachableAsCGImage`` protocol and can be attached to a test: - /// - /// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage) - @_spi(Experimental) - @available(_uttypesAPI, *) - public init( - _ attachableValue: T, - named preferredName: String? = nil, - as contentType: UTType?, - encodingQuality: Float = 1.0, - sourceLocation: SourceLocation = #_sourceLocation - ) where AttachableValue == _AttachableImageWrapper { - self.init(attachableValue: attachableValue, named: preferredName, contentType: contentType, encodingQuality: encodingQuality, sourceLocation: sourceLocation) - } - - /// Initialize an instance of this type that encloses the given image. - /// - /// - Parameters: - /// - attachableValue: The value that will be attached to the output of - /// the test run. - /// - preferredName: The preferred name of the attachment when writing it - /// to a test report or to disk. If `nil`, the testing library attempts - /// to derive a reasonable filename for the attached value. - /// - encodingQuality: The encoding quality to use when encoding the image. - /// If the image format used for encoding (specified by the `contentType` - /// argument) does not support variable-quality encoding, the value of - /// this argument is ignored. - /// - sourceLocation: The source location of the call to this initializer. - /// This value is used when recording issues associated with the - /// attachment. - /// - /// The following system-provided image types conform to the - /// ``AttachableAsCGImage`` protocol and can be attached to a test: - /// - /// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage) - @_spi(Experimental) - public init( - _ attachableValue: T, - named preferredName: String? = nil, - encodingQuality: Float = 1.0, - sourceLocation: SourceLocation = #_sourceLocation - ) where AttachableValue == _AttachableImageWrapper { - self.init(attachableValue: attachableValue, named: preferredName, contentType: nil, encodingQuality: encodingQuality, sourceLocation: sourceLocation) - } -} -#endif diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/CGImage+AttachableAsCGImage.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/CGImage+AttachableAsImage.swift similarity index 55% rename from Sources/Overlays/_Testing_CoreGraphics/Attachments/CGImage+AttachableAsCGImage.swift rename to Sources/Overlays/_Testing_CoreGraphics/Attachments/CGImage+AttachableAsImage.swift index 944798d39..dedff803b 100644 --- a/Sources/Overlays/_Testing_CoreGraphics/Attachments/CGImage+AttachableAsCGImage.swift +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/CGImage+AttachableAsImage.swift @@ -1,7 +1,7 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Copyright (c) 2024–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 @@ -11,9 +11,14 @@ #if SWT_TARGET_OS_APPLE && canImport(CoreGraphics) public import CoreGraphics -@_spi(Experimental) -extension CGImage: AttachableAsCGImage { - public var attachableCGImage: CGImage { +/// @Metadata { +/// @Available(Swift, introduced: 6.3) +/// } +extension CGImage: AttachableAsImage, AttachableAsCGImage { + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } + package var attachableCGImage: CGImage { self } } diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/ImageAttachmentError.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/ImageAttachmentError.swift deleted file mode 100644 index f957888b7..000000000 --- a/Sources/Overlays/_Testing_CoreGraphics/Attachments/ImageAttachmentError.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// 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 -// - -#if SWT_TARGET_OS_APPLE && canImport(CoreGraphics) -/// A type representing an error that can occur when attaching an image. -package enum ImageAttachmentError: Error, CustomStringConvertible { - /// The image could not be converted to an instance of `CGImage`. - case couldNotCreateCGImage - - /// The image destination could not be created. - case couldNotCreateImageDestination - - /// The image could not be converted. - case couldNotConvertImage - - public var description: String { - switch self { - case .couldNotCreateCGImage: - "Could not create the corresponding Core Graphics image." - case .couldNotCreateImageDestination: - "Could not create the Core Graphics image destination to encode this image." - case .couldNotConvertImage: - "Could not convert the image to the specified format." - } - } -} -#endif diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageWrapper+AttachableWrapper.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageWrapper+AttachableWrapper.swift new file mode 100644 index 000000000..3db932096 --- /dev/null +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageWrapper+AttachableWrapper.swift @@ -0,0 +1,69 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024–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 +// + +#if SWT_TARGET_OS_APPLE && canImport(CoreGraphics) +private import CoreGraphics + +private import UniformTypeIdentifiers + +/// @Metadata { +/// @Available(Swift, introduced: 6.3) +/// } +@available(_uttypesAPI, *) +extension _AttachableImageWrapper: Attachable, AttachableWrapper where Image: AttachableAsImage { + /// Get the image format to use when encoding an image, substituting a + /// concrete type for `UTType.image` in particular. + /// + /// - Parameters: + /// - preferredName: The preferred name of the image for which a type is + /// needed. + /// + /// - Returns: An instance of ``AttachableImageFormat`` referring to a + /// concrete image type. + /// + /// This function is not part of the public interface of the testing library. + private func _imageFormat(forPreferredName preferredName: String) -> AttachableImageFormat { + if let imageFormat, case let contentType = imageFormat.contentType, contentType != .image { + // The developer explicitly specified a type. + return imageFormat + } + + // The developer didn't specify a concrete type, so try to derive one from + // the preferred name's path extension. + let pathExtension = (preferredName as NSString).pathExtension + if !pathExtension.isEmpty, + let contentType = UTType(filenameExtension: pathExtension, conformingTo: .image), + contentType.isDeclared { + return AttachableImageFormat(contentType: contentType) + } + + // We couldn't derive a concrete type from the path extension, so pick + // between PNG and JPEG based on the encoding quality. + let encodingQuality = imageFormat?.encodingQuality ?? 1.0 + return encodingQuality < 1.0 ? .jpeg : .png + } + + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } + public func withUnsafeBytes(for attachment: borrowing Attachment<_AttachableImageWrapper>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + let imageFormat = _imageFormat(forPreferredName: attachment.preferredName) + return try wrappedValue.withUnsafeBytes(as: imageFormat, body) + } + + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } + public borrowing func preferredName(for attachment: borrowing Attachment<_AttachableImageWrapper>, basedOn suggestedName: String) -> String { + let imageFormat = _imageFormat(forPreferredName: suggestedName) + return (suggestedName as NSString).appendingPathExtension(for: imageFormat.contentType) + } +} +#endif diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageWrapper.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageWrapper.swift deleted file mode 100644 index 7aa1fd139..000000000 --- a/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageWrapper.swift +++ /dev/null @@ -1,178 +0,0 @@ -// -// 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 -// - -#if SWT_TARGET_OS_APPLE && canImport(CoreGraphics) -public import Testing -private import CoreGraphics - -private import ImageIO -import UniformTypeIdentifiers - -/// ## Why can't images directly conform to Attachable? -/// -/// Three reasons: -/// -/// 1. Several image classes are not marked `Sendable`, which means that as far -/// as Swift is concerned, they cannot be safely passed to Swift Testing's -/// event handler (primarily because `Event` is `Sendable`.) So we would have -/// to eagerly serialize them, which is unnecessarily expensive if we know -/// they're actually concurrency-safe. -/// 2. We would have no place to store metadata such as the encoding quality -/// (although in the future we may introduce a "metadata" associated type to -/// `Attachable` that could store that info.) -/// 3. `Attachable` has a requirement with `Self` in non-parameter, non-return -/// position. As far as Swift is concerned, a non-final class cannot satisfy -/// such a requirement, and all image types we care about are non-final -/// classes. Thus, the compiler will steadfastly refuse to allow non-final -/// classes to conform to the `Attachable` protocol. We could get around this -/// by changing the signature of `withUnsafeBytes()` so that the -/// generic parameter to `Attachment` is not `Self`, but that would defeat -/// much of the purpose of making `Attachment` generic in the first place. -/// (And no, the language does not let us write `where T: Self` anywhere -/// useful.) - -/// A wrapper type for image types such as `CGImage` and `NSImage` that can be -/// attached indirectly. -/// -/// You do not need to use this type directly. Instead, initialize an instance -/// of ``Attachment`` using an instance of an image type that conforms to -/// ``AttachableAsCGImage``. The following system-provided image types conform -/// to the ``AttachableAsCGImage`` protocol and can be attached to a test: -/// -/// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage) -@_spi(Experimental) -public struct _AttachableImageWrapper: Sendable where Image: AttachableAsCGImage { - /// The underlying image. - /// - /// `CGImage` and `UIImage` are sendable, but `NSImage` is not. `NSImage` - /// instances can be created from closures that are run at rendering time. - /// The AppKit cross-import overlay is responsible for ensuring that any - /// instances of this type it creates hold "safe" `NSImage` instances. - nonisolated(unsafe) var image: Image - - /// The encoding quality to use when encoding the represented image. - var encodingQuality: Float - - /// Storage for ``contentType``. - private var _contentType: (any Sendable)? - - /// The content type to use when encoding the image. - /// - /// The testing library uses this property to determine which image format to - /// encode the associated image as when it is attached to a test. - /// - /// If the value of this property does not conform to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image), - /// the result is undefined. - @available(_uttypesAPI, *) - var contentType: UTType { - get { - if let contentType = _contentType as? UTType { - return contentType - } else { - return encodingQuality < 1.0 ? .jpeg : .png - } - } - set { - precondition( - newValue.conforms(to: .image), - "An image cannot be attached as an instance of type '\(newValue.identifier)'. Use a type that conforms to 'public.image' instead." - ) - _contentType = newValue - } - } - - /// The content type to use when encoding the image, substituting a concrete - /// type for `UTType.image`. - /// - /// This property is not part of the public interface of the testing library. - @available(_uttypesAPI, *) - var computedContentType: UTType { - if let contentType = _contentType as? UTType, contentType != .image { - contentType - } else { - encodingQuality < 1.0 ? .jpeg : .png - } - } - - /// The type identifier (as a `CFString`) corresponding to this instance's - /// ``computedContentType`` property. - /// - /// The value of this property is used by ImageIO when serializing an image. - /// - /// This property is not part of the public interface of the testing library. - /// It is used by ImageIO below. - var typeIdentifier: CFString { - if #available(_uttypesAPI, *) { - computedContentType.identifier as CFString - } else { - encodingQuality < 1.0 ? kUTTypeJPEG : kUTTypePNG - } - } - - init(image: Image, encodingQuality: Float, contentType: (any Sendable)?) { - self.image = image._makeCopyForAttachment() - self.encodingQuality = encodingQuality - if #available(_uttypesAPI, *), let contentType = contentType as? UTType { - self.contentType = contentType - } - } -} - -// MARK: - - -extension _AttachableImageWrapper: AttachableWrapper { - public var wrappedValue: Image { - image - } - - public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { - let data = NSMutableData() - - // Convert the image to a CGImage. - let attachableCGImage = try image.attachableCGImage - - // Create the image destination. - guard let dest = CGImageDestinationCreateWithData(data as CFMutableData, typeIdentifier, 1, nil) else { - throw ImageAttachmentError.couldNotCreateImageDestination - } - - // Configure the properties of the image conversion operation. - let orientation = image._attachmentOrientation - let scaleFactor = image._attachmentScaleFactor - let properties: [CFString: Any] = [ - kCGImageDestinationLossyCompressionQuality: CGFloat(encodingQuality), - kCGImagePropertyOrientation: orientation, - kCGImagePropertyDPIWidth: 72.0 * scaleFactor, - kCGImagePropertyDPIHeight: 72.0 * scaleFactor, - ] - - // Perform the image conversion. - CGImageDestinationAddImage(dest, attachableCGImage, properties as CFDictionary) - guard CGImageDestinationFinalize(dest) else { - throw ImageAttachmentError.couldNotConvertImage - } - - // Pass the bits of the image out to the body. Note that we have an - // NSMutableData here so we have to use slightly different API than we would - // with an instance of Data. - return try withExtendedLifetime(data) { - try body(UnsafeRawBufferPointer(start: data.bytes, count: data.length)) - } - } - - public borrowing func preferredName(for attachment: borrowing Attachment, basedOn suggestedName: String) -> String { - if #available(_uttypesAPI, *) { - return (suggestedName as NSString).appendingPathExtension(for: computedContentType) - } - - return suggestedName - } -} -#endif diff --git a/Sources/Overlays/_Testing_CoreGraphics/CMakeLists.txt b/Sources/Overlays/_Testing_CoreGraphics/CMakeLists.txt new file mode 100644 index 000000000..e408fa517 --- /dev/null +++ b/Sources/Overlays/_Testing_CoreGraphics/CMakeLists.txt @@ -0,0 +1,25 @@ +# This source file is part of the Swift.org open source project +# +# Copyright (c) 2024–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 + +if(APPLE) + add_library(_Testing_CoreGraphics + Attachments/AttachableAsCGImage.swift + Attachments/_AttachableImageWrapper+AttachableWrapper.swift + Attachments/AttachableImageFormat+UTType.swift + Attachments/CGImage+AttachableAsImage.swift + ReexportTesting.swift) + + target_link_libraries(_Testing_CoreGraphics PUBLIC + Testing) + + target_compile_options(_Testing_CoreGraphics PRIVATE + -enable-library-evolution + -emit-module-interface -emit-module-interface-path $/_Testing_CoreGraphics.swiftinterface) + + _swift_testing_install_target(_Testing_CoreGraphics) +endif() diff --git a/Sources/Overlays/_Testing_CoreGraphics/ReexportTesting.swift b/Sources/Overlays/_Testing_CoreGraphics/ReexportTesting.swift index 5b28faa77..be2275d11 100644 --- a/Sources/Overlays/_Testing_CoreGraphics/ReexportTesting.swift +++ b/Sources/Overlays/_Testing_CoreGraphics/ReexportTesting.swift @@ -1,11 +1,11 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Copyright (c) 2024–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 // -@_exported @_spi(Experimental) @_spi(ForToolsIntegrationOnly) public import Testing +@_exported public import Testing diff --git a/Sources/Overlays/_Testing_CoreImage/Attachments/CIImage+AttachableAsImage.swift b/Sources/Overlays/_Testing_CoreImage/Attachments/CIImage+AttachableAsImage.swift new file mode 100644 index 000000000..130e7e831 --- /dev/null +++ b/Sources/Overlays/_Testing_CoreImage/Attachments/CIImage+AttachableAsImage.swift @@ -0,0 +1,38 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024–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 +// + +#if SWT_TARGET_OS_APPLE && canImport(CoreImage) +public import CoreImage +public import _Testing_CoreGraphics + +/// @Metadata { +/// @Available(Swift, introduced: 6.3) +/// } +extension CIImage: AttachableAsImage, AttachableAsCGImage { + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } + package var attachableCGImage: CGImage { + get throws { + guard let result = CIContext().createCGImage(self, from: extent) else { + throw ImageAttachmentError.couldNotCreateCGImage + } + return result + } + } + + public func _copyAttachableValue() -> Self { + // CIImage is documented as thread-safe, but does not conform to Sendable. + // It conforms to NSCopying but does not actually copy itself, so there's no + // point in calling copy(). + self + } +} +#endif diff --git a/Sources/Overlays/_Testing_CoreImage/CMakeLists.txt b/Sources/Overlays/_Testing_CoreImage/CMakeLists.txt new file mode 100644 index 000000000..0295fedc7 --- /dev/null +++ b/Sources/Overlays/_Testing_CoreImage/CMakeLists.txt @@ -0,0 +1,23 @@ +# This source file is part of the Swift.org open source project +# +# Copyright (c) 2024–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 + +if(APPLE) + add_library(_Testing_CoreImage + Attachments/CIImage+AttachableAsImage.swift + ReexportTesting.swift) + + target_link_libraries(_Testing_CoreImage PUBLIC + Testing + _Testing_CoreGraphics) + + target_compile_options(_Testing_CoreImage PRIVATE + -enable-library-evolution + -emit-module-interface -emit-module-interface-path $/_Testing_CoreImage.swiftinterface) + + _swift_testing_install_target(_Testing_CoreImage) +endif() diff --git a/Sources/Overlays/_Testing_CoreImage/ReexportTesting.swift b/Sources/Overlays/_Testing_CoreImage/ReexportTesting.swift new file mode 100644 index 000000000..2e76ecd44 --- /dev/null +++ b/Sources/Overlays/_Testing_CoreImage/ReexportTesting.swift @@ -0,0 +1,12 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024–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 +// + +@_exported public import Testing +@_exported public import _Testing_CoreGraphics diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift index 46a1e11e6..d82c6a6c6 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable+NSSecureCoding.swift @@ -20,6 +20,7 @@ public import Foundation /// @Metadata { /// @Available(Swift, introduced: 6.2) +/// @Available(Xcode, introduced: 26.0) /// } extension Attachable where Self: Encodable & NSSecureCoding { @_documentation(visibility: private) diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift index 683888801..747024de3 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+Encodable.swift @@ -56,6 +56,7 @@ func withUnsafeBytes(encoding attachableValue: borrowing E, for attachment /// @Metadata { /// @Available(Swift, introduced: 6.2) +/// @Available(Xcode, introduced: 26.0) /// } extension Attachable where Self: Encodable { /// Encode this value into a buffer using either [`PropertyListEncoder`](https://developer.apple.com/documentation/foundation/propertylistencoder) @@ -92,6 +93,7 @@ extension Attachable where Self: Encodable { /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } 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 4acbf4960..95002be0a 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachable+NSSecureCoding.swift @@ -18,6 +18,7 @@ public import Foundation /// @Metadata { /// @Available(Swift, introduced: 6.2) +/// @Available(Xcode, introduced: 26.0) /// } extension Attachable where Self: NSSecureCoding { /// Encode this object using [`NSKeyedArchiver`](https://developer.apple.com/documentation/foundation/nskeyedarchiver) @@ -52,6 +53,7 @@ extension Attachable where Self: NSSecureCoding { /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } 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 83c3909be..e072bf76d 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Attachment+URL.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Attachment+URL.swift @@ -17,21 +17,6 @@ private import WinSDK #endif #if !SWT_NO_FILE_IO -extension URL { - /// The file system path of the URL, equivalent to `path`. - var fileSystemPath: String { -#if os(Windows) - // BUG: `path` includes a leading slash which makes it invalid on Windows. - // SEE: https://github.com/swiftlang/swift-foundation/pull/964 - let path = path - if path.starts(with: /\/[A-Za-z]:\//) { - return String(path.dropFirst()) - } -#endif - return path - } -} - extension Attachment where AttachableValue == _AttachableURLWrapper { #if SWT_TARGET_OS_APPLE /// An operation queue to use for asynchronously reading data from disk. @@ -51,8 +36,29 @@ extension Attachment where AttachableValue == _AttachableURLWrapper { /// /// - Throws: Any error that occurs attempting to read from `url`. /// + /// Use this initializer to create an instance of ``Attachment`` that + /// represents a local file or directory: + /// + /// ```swift + /// let url = try await FoodTruck.saveMenu(as: .pdf) + /// let attachment = try await Attachment(contentsOf: url) + /// Attachment.record(attachment) + /// ``` + /// + /// When you call this initializer and pass it the URL of a file, it reads or + /// maps the contents of that file into memory. When you call this initializer + /// and pass it the URL of a directory, it creates a temporary zip file of the + /// directory before reading or mapping it into memory. These operations may + /// take some time, so this initializer suspends the calling task until they + /// are complete. + /// + /// - Important: This initializer supports creating attachments from file URLs + /// only. If you pass it a URL other than a file URL, such as an HTTPS URL, + /// the testing library throws an error. + /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } public init( contentsOf url: URL, @@ -70,7 +76,7 @@ extension Attachment where AttachableValue == _AttachableURLWrapper { let url = url.resolvingSymlinksInPath() let isDirectory = try url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory! -#if SWT_TARGET_OS_APPLE +#if SWT_TARGET_OS_APPLE && !SWT_NO_FOUNDATION_FILE_COORDINATION let data: Data = try await withCheckedThrowingContinuation { continuation in let fileCoordinator = NSFileCoordinator() let fileAccessIntent = NSFileAccessIntent.readingIntent(with: url, options: [.forUploading]) @@ -115,14 +121,14 @@ private let _archiverPath: String? = { return nil } - return withUnsafeTemporaryAllocation(of: wchar_t.self, capacity: Int(bufferCount)) { buffer -> String? in + return withUnsafeTemporaryAllocation(of: CWideChar.self, capacity: Int(bufferCount)) { buffer -> String? in let bufferCount = GetSystemDirectoryW(buffer.baseAddress!, UINT(buffer.count)) guard bufferCount > 0 && bufferCount < buffer.count else { return nil } return _archiverName.withCString(encodedAs: UTF16.self) { archiverName -> String? in - var result: UnsafeMutablePointer? + var result: UnsafeMutablePointer? let flags = ULONG(PATHCCH_ALLOW_LONG_PATHS.rawValue) guard S_OK == PathAllocCombine(buffer.baseAddress!, archiverName, flags, &result) else { @@ -165,25 +171,31 @@ private func _compressContentsOfDirectory(at directoryURL: URL) async throws -> // knows how to write PKZIP archives, while Windows inherited FreeBSD's tar // tool in Windows 10 Build 17063 (per https://techcommunity.microsoft.com/blog/containers/tar-and-curl-come-to-windows/382409). // - // On Linux (which does not have FreeBSD's version of tar(1)), we can use - // zip(1) instead. + // On Linux and OpenBSD (which do not have FreeBSD's version of tar(1)), we + // can use zip(1) instead. This tool compresses paths relative to the current + // working directory, and posix_spawn_file_actions_addchdir_np() is not always + // available for us to call (not present on OpenBSD, requires glibc β‰₯ 2.28 on + // Linux), so we'll spawn a shell that calls cd before calling zip(1). // // OpenBSD's tar(1) does not support writing PKZIP archives, and /usr/bin/zip // tool is an optional install, so we check if it's present before trying to // execute it. +#if os(Linux) || os(OpenBSD) + let archiverPath = "/bin/sh" #if os(Linux) - let archiverPath = "/usr/bin/zip" -#elseif SWT_TARGET_OS_APPLE || os(FreeBSD) - let archiverPath = "/usr/bin/tar" -#elseif os(OpenBSD) - let archiverPath = "/usr/local/bin/zip" + let trueArchiverPath = "/usr/bin/zip" +#else + let trueArchiverPath = "/usr/local/bin/zip" var isDirectory = false - if !FileManager.default.fileExists(atPath: archiverPath, isDirectory: &isDirectory) || isDirectory { + if !FileManager.default.fileExists(atPath: trueArchiverPath, isDirectory: &isDirectory) || isDirectory { throw CocoaError(.fileNoSuchFile, userInfo: [ NSLocalizedDescriptionKey: "The 'zip' package is not installed.", - NSFilePathErrorKey: archiverPath + NSFilePathErrorKey: trueArchiverPath ]) } +#endif +#elseif SWT_TARGET_OS_APPLE || os(FreeBSD) + let archiverPath = "/usr/bin/tar" #elseif os(Windows) guard let archiverPath = _archiverPath else { throw CocoaError(.fileWriteUnknown, userInfo: [ @@ -196,20 +208,15 @@ private func _compressContentsOfDirectory(at directoryURL: URL) async throws -> throw CocoaError(.featureUnsupported, userInfo: [NSLocalizedDescriptionKey: "This platform does not support attaching directories to tests."]) #endif - try await withCheckedThrowingContinuation { continuation in - let process = Process() - - process.executableURL = URL(fileURLWithPath: archiverPath, isDirectory: false) - - let sourcePath = directoryURL.fileSystemPath - let destinationPath = temporaryURL.fileSystemPath + let sourcePath = directoryURL.path + let destinationPath = temporaryURL.path + let arguments = { #if os(Linux) || os(OpenBSD) // The zip command constructs relative paths from the current working // directory rather than from command-line arguments. - process.arguments = [destinationPath, "--recurse-paths", "."] - process.currentDirectoryURL = directoryURL + ["-c", #"cd "$0" && "$1" "$2" --recurse-paths ."#, sourcePath, trueArchiverPath, destinationPath] #elseif SWT_TARGET_OS_APPLE || os(FreeBSD) - process.arguments = ["--create", "--auto-compress", "--directory", sourcePath, "--file", destinationPath, "."] + ["--create", "--auto-compress", "--directory", sourcePath, "--file", destinationPath, "."] #elseif os(Windows) // The Windows version of bsdtar can handle relative paths for other archive // formats, but produces empty archives when inferring the zip format with @@ -218,30 +225,15 @@ private func _compressContentsOfDirectory(at directoryURL: URL) async throws -> // An alternative may be to use PowerShell's Compress-Archive command, // however that comes with a security risk as we'd be responsible for two // levels of command-line argument escaping. - process.arguments = ["--create", "--auto-compress", "--file", destinationPath, sourcePath] + ["--create", "--auto-compress", "--file", destinationPath, sourcePath] #endif + }() - process.standardOutput = nil - process.standardError = nil - - process.terminationHandler = { process in - let terminationReason = process.terminationReason - let terminationStatus = process.terminationStatus - if terminationReason == .exit && terminationStatus == EXIT_SUCCESS { - continuation.resume() - } else { - let error = CocoaError(.fileWriteUnknown, userInfo: [ - NSLocalizedDescriptionKey: "The directory at '\(sourcePath)' could not be compressed (\(terminationStatus)).", - ]) - continuation.resume(throwing: error) - } - } - - do { - try process.run() - } catch { - continuation.resume(throwing: error) - } + let exitStatus = try await spawnExecutableAtPathAndWait(archiverPath, arguments: arguments) + guard case .exitCode(EXIT_SUCCESS) = exitStatus else { + throw CocoaError(.fileWriteUnknown, userInfo: [ + NSLocalizedDescriptionKey: "The directory at '\(sourcePath)' could not be compressed (\(exitStatus)).", + ]) } return try Data(contentsOf: temporaryURL, options: [.mappedIfSafe]) diff --git a/Sources/Overlays/_Testing_Foundation/Attachments/Data+Attachable.swift b/Sources/Overlays/_Testing_Foundation/Attachments/Data+Attachable.swift index ce7b719a9..56f058da3 100644 --- a/Sources/Overlays/_Testing_Foundation/Attachments/Data+Attachable.swift +++ b/Sources/Overlays/_Testing_Foundation/Attachments/Data+Attachable.swift @@ -14,10 +14,12 @@ public import Foundation /// @Metadata { /// @Available(Swift, introduced: 6.2) +/// @Available(Xcode, introduced: 26.0) /// } extension Data: Attachable { /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { try withUnsafeBytes(body) diff --git a/Sources/Overlays/_Testing_Foundation/CMakeLists.txt b/Sources/Overlays/_Testing_Foundation/CMakeLists.txt index 9343960ab..7da6f773d 100644 --- a/Sources/Overlays/_Testing_Foundation/CMakeLists.txt +++ b/Sources/Overlays/_Testing_Foundation/CMakeLists.txt @@ -3,8 +3,8 @@ # Copyright (c) 2024 Apple Inc. and the Swift project authors # Licensed under Apache License v2.0 with Runtime Library Exception # -# See http://swift.org/LICENSE.txt for license information -# See http://swift.org/CONTRIBUTORS.txt for Swift project authors +# See https://swift.org/LICENSE.txt for license information +# See https://swift.org/CONTRIBUTORS.txt for Swift project authors add_library(_Testing_Foundation Attachments/_AttachableURLWrapper.swift diff --git a/Sources/Overlays/_Testing_UIKit/Attachments/UIImage+AttachableAsImage.swift b/Sources/Overlays/_Testing_UIKit/Attachments/UIImage+AttachableAsImage.swift new file mode 100644 index 000000000..6e9ca6838 --- /dev/null +++ b/Sources/Overlays/_Testing_UIKit/Attachments/UIImage+AttachableAsImage.swift @@ -0,0 +1,70 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024–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 +// + +#if SWT_TARGET_OS_APPLE && canImport(UIKit) +public import UIKit +public import _Testing_CoreGraphics + +package import ImageIO +#if canImport(UIKitCore_Private) +private import UIKitCore_Private +#endif + +/// @Metadata { +/// @Available(Swift, introduced: 6.3) +/// } +extension UIImage: AttachableAsImage, AttachableAsCGImage { + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } + package var attachableCGImage: CGImage { + get throws { +#if canImport(UIKitCore_Private) + // _UIImageGetCGImageRepresentation() is an internal UIKit function that + // flattens any (most) UIImage instances to a CGImage. BUG: rdar://155449485 + if let cgImage = _UIImageGetCGImageRepresentation(self)?.takeUnretainedValue() { + return cgImage + } +#else + // NOTE: This API is marked to-be-deprecated so we'll need to eventually + // switch to UIGraphicsImageRenderer, but that type is not available on + // watchOS. BUG: rdar://155452406 + UIGraphicsBeginImageContextWithOptions(size, true, scale) + defer { + UIGraphicsEndImageContext() + } + draw(at: .zero) + if let cgImage = UIGraphicsGetImageFromCurrentImageContext()?.cgImage { + return cgImage + } +#endif + throw ImageAttachmentError.couldNotCreateCGImage + } + } + + package var attachmentOrientation: CGImagePropertyOrientation { + switch imageOrientation { + case .up: .up + case .down: .down + case .left: .left + case .right: .right + case .upMirrored: .upMirrored + case .downMirrored: .downMirrored + case .leftMirrored: .leftMirrored + case .rightMirrored: .rightMirrored + @unknown default: .up + } + } + + package var attachmentScaleFactor: CGFloat { + scale + } +} +#endif diff --git a/Sources/Overlays/_Testing_UIKit/CMakeLists.txt b/Sources/Overlays/_Testing_UIKit/CMakeLists.txt new file mode 100644 index 000000000..908824704 --- /dev/null +++ b/Sources/Overlays/_Testing_UIKit/CMakeLists.txt @@ -0,0 +1,23 @@ +# This source file is part of the Swift.org open source project +# +# Copyright (c) 2024–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 + +if(APPLE) + add_library(_Testing_UIKit + Attachments/UIImage+AttachableAsImage.swift + ReexportTesting.swift) + + target_link_libraries(_Testing_UIKit PUBLIC + Testing + _Testing_CoreGraphics) + + target_compile_options(_Testing_UIKit PRIVATE + -enable-library-evolution + -emit-module-interface -emit-module-interface-path $/_Testing_UIKit.swiftinterface) + + _swift_testing_install_target(_Testing_UIKit) +endif() diff --git a/Sources/Overlays/_Testing_UIKit/ReexportTesting.swift b/Sources/Overlays/_Testing_UIKit/ReexportTesting.swift new file mode 100644 index 000000000..2e76ecd44 --- /dev/null +++ b/Sources/Overlays/_Testing_UIKit/ReexportTesting.swift @@ -0,0 +1,12 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024–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 +// + +@_exported public import Testing +@_exported public import _Testing_CoreGraphics diff --git a/Sources/Overlays/_Testing_WinSDK/Attachments/AttachableAsIWICBitmapSource.swift b/Sources/Overlays/_Testing_WinSDK/Attachments/AttachableAsIWICBitmapSource.swift new file mode 100644 index 000000000..e459986e7 --- /dev/null +++ b/Sources/Overlays/_Testing_WinSDK/Attachments/AttachableAsIWICBitmapSource.swift @@ -0,0 +1,208 @@ +// +// 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 +// + +#if os(Windows) +private import Testing +public import WinSDK + +/// A protocol describing images that can be converted to instances of +/// [`Attachment`](https://developer.apple.com/documentation/testing/attachment) +/// and which can be represented as instances of [`IWICBitmapSource`](https://learn.microsoft.com/en-us/windows/win32/api/wincodec/nn-wincodec-iwicbitmapsource) +/// by address. +/// +/// This protocol is not part of the public interface of the testing library. +public protocol _AttachableByAddressAsIWICBitmapSource { + /// Create a WIC bitmap source representing an instance of this type at the + /// given address. + /// + /// - Parameters: + /// - imageAddress: The address of the instance of this type. + /// - factory: A WIC imaging factory that can be used to create additional + /// WIC objects. + /// + /// - Returns: A pointer to a new WIC bitmap source representing this image. + /// The caller is responsible for releasing this image when done with it. + /// + /// - Throws: Any error that prevented the creation of the WIC bitmap. + /// + /// This function is not part of the public interface of the testing library. + /// It may be removed in a future update. + static func _copyAttachableIWICBitmapSource( + from imageAddress: UnsafeMutablePointer, + using factory: UnsafeMutablePointer + ) throws -> UnsafeMutablePointer + + /// Make a copy of the instance of this type at the given address. + /// + /// - Parameters: + /// - imageAddress: The address of the instance of this type that should be + /// copied. + /// + /// - Returns: A copy of `imageAddress`, or `imageAddress` if this type does + /// not support a copying operation. + /// + /// The testing library uses this function to take ownership of image + /// resources that test authors pass to it. If possible, make a copy of or add + /// a reference to the value at `imageAddress`. If this type does not support + /// making copies, return `imageAddress` verbatim. + /// + /// This function is not part of the public interface of the testing library. + /// It may be removed in a future update. + static func _copyAttachableValue(at imageAddress: UnsafeMutablePointer) -> UnsafeMutablePointer + + /// Manually deinitialize any resources at the given address. + /// + /// - Parameters: + /// - imageAddress: The address of the instance of this type. + /// + /// The implementation of this function is responsible for balancing a + /// previous call to `_copyAttachableValue(at:)` by cleaning up any resources + /// (such as handles or COM objects) associated with the value at + /// `imageAddress`. The testing library automatically invokes this function as + /// needed. If `_copyAttachableValue(at:)` threw an error, the testing library + /// does not call this function. + /// + /// This function is not responsible for releasing the image returned from + /// `_copyAttachableIWICBitmapSource(from:using:)`. + /// + /// This function is not part of the public interface of the testing library. + /// It may be removed in a future update. + static func _deinitializeAttachableValue(at imageAddress: UnsafeMutablePointer) +} + +/// A protocol describing images that can be converted to instances of +/// [`Attachment`](https://developer.apple.com/documentation/testing/attachment) +/// and which can be represented as instances of [`IWICBitmapSource`](https://learn.microsoft.com/en-us/windows/win32/api/wincodec/nn-wincodec-iwicbitmapsource). +/// +/// This protocol is not part of the public interface of the testing library. It +/// encapsulates Windows-specific logic for image attachments. +package protocol AttachableAsIWICBitmapSource: AttachableAsImage { + /// Create a WIC bitmap representing an instance of this type. + /// + /// - Parameters: + /// - factory: A WIC imaging factory that can be used to create additional + /// WIC objects. + /// + /// - Returns: A pointer to a new WIC bitmap representing this image. The + /// caller is responsible for releasing this image when done with it. + /// + /// - Throws: Any error that prevented the creation of the WIC bitmap. + func copyAttachableIWICBitmapSource( + using factory: UnsafeMutablePointer + ) throws -> UnsafeMutablePointer +} + +extension AttachableAsIWICBitmapSource { + public func withUnsafeBytes(as imageFormat: AttachableImageFormat, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + // Create an in-memory stream to write the image data to. Note that Windows + // documentation recommends SHCreateMemStream() instead, but that function + // does not provide a mechanism to access the underlying memory directly. + var stream: UnsafeMutablePointer? + let rCreateStream = CreateStreamOnHGlobal(nil, true, &stream) + guard S_OK == rCreateStream, let stream else { + throw ImageAttachmentError.comObjectCreationFailed(IStream.self, rCreateStream) + } + defer { + _ = stream.pointee.lpVtbl.pointee.Release(stream) + } + + // Get an imaging factory to create the WIC bitmap and encoder. + let factory = try IWICImagingFactory.create() + defer { + _ = factory.pointee.lpVtbl.pointee.Release(factory) + } + + // Create the bitmap and downcast it to an IWICBitmapSource for later use. + let bitmap = try copyAttachableIWICBitmapSource(using: factory) + defer { + _ = bitmap.pointee.lpVtbl.pointee.Release(bitmap) + } + + // Create the encoder. + let encoder = try withUnsafePointer(to: IID_IWICBitmapEncoder) { IID_IWICBitmapEncoder in + var encoderCLSID = imageFormat.encoderCLSID + var encoder: UnsafeMutableRawPointer? + let rCreate = CoCreateInstance( + &encoderCLSID, + nil, + DWORD(CLSCTX_INPROC_SERVER.rawValue), + IID_IWICBitmapEncoder, + &encoder + ) + guard rCreate == S_OK, let encoder = encoder?.assumingMemoryBound(to: IWICBitmapEncoder.self) else { + throw ImageAttachmentError.comObjectCreationFailed(IWICBitmapEncoder.self, rCreate) + } + return encoder + } + defer { + _ = encoder.pointee.lpVtbl.pointee.Release(encoder) + } + _ = encoder.pointee.lpVtbl.pointee.Initialize(encoder, stream, WICBitmapEncoderNoCache) + + // Create the frame into which the bitmap will be composited. + var frame: UnsafeMutablePointer? + var propertyBag: UnsafeMutablePointer? + let rCreateFrame = encoder.pointee.lpVtbl.pointee.CreateNewFrame(encoder, &frame, &propertyBag) + guard rCreateFrame == S_OK, let frame, let propertyBag else { + throw ImageAttachmentError.comObjectCreationFailed(IWICBitmapFrameEncode.self, rCreateFrame) + } + defer { + _ = frame.pointee.lpVtbl.pointee.Release(frame) + _ = propertyBag.pointee.lpVtbl.pointee.Release(propertyBag) + } + + // Set properties. The only property we currently set is image quality. + do { + try propertyBag.write(imageFormat.encodingQuality, named: "ImageQuality") + } catch ImageAttachmentError.propertyBagWritingFailed(_, HRESULT(bitPattern: 0x80004005)) { + // E_FAIL: This property is not supported for the current encoder/format. + // Eat this error silently as it's not useful to the test author. + } + _ = frame.pointee.lpVtbl.pointee.Initialize(frame, propertyBag) + + // Write the image! + let rWrite = frame.pointee.lpVtbl.pointee.WriteSource(frame, bitmap, nil) + guard rWrite == S_OK else { + throw ImageAttachmentError.imageWritingFailed(rWrite) + } + + // Commit changes through the various layers. + var rCommit = frame.pointee.lpVtbl.pointee.Commit(frame) + guard rCommit == S_OK else { + throw ImageAttachmentError.imageWritingFailed(rCommit) + } + rCommit = encoder.pointee.lpVtbl.pointee.Commit(encoder) + guard rCommit == S_OK else { + throw ImageAttachmentError.imageWritingFailed(rCommit) + } + rCommit = stream.pointee.lpVtbl.pointee.Commit(stream, DWORD(STGC_DEFAULT.rawValue)) + guard rCommit == S_OK else { + throw ImageAttachmentError.imageWritingFailed(rCommit) + } + + // Extract the serialized image and pass it back to the caller. We hold the + // HGLOBAL locked while calling `body`, but nothing else should have a + // reference to it. + var global: HGLOBAL? + let rGetGlobal = GetHGlobalFromStream(stream, &global) + guard S_OK == rGetGlobal else { + throw ImageAttachmentError.globalFromStreamFailed(rGetGlobal) + } + guard let baseAddress = GlobalLock(global) else { + throw Win32Error(rawValue: GetLastError()) + } + defer { + GlobalUnlock(global) + } + let byteCount = GlobalSize(global) + return try body(UnsafeRawBufferPointer(start: baseAddress, count: Int(byteCount))) + } +} +#endif diff --git a/Sources/Overlays/_Testing_WinSDK/Attachments/AttachableImageFormat+CLSID.swift b/Sources/Overlays/_Testing_WinSDK/Attachments/AttachableImageFormat+CLSID.swift new file mode 100644 index 000000000..6ea0dc0ad --- /dev/null +++ b/Sources/Overlays/_Testing_WinSDK/Attachments/AttachableImageFormat+CLSID.swift @@ -0,0 +1,333 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024–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 +// + +#if os(Windows) +public import WinSDK + +/// @Metadata { +/// @Available(Swift, introduced: 6.3) +/// } +extension AttachableImageFormat { + private static let _encoderPathExtensionsByCLSID = Result { + var result = [CLSID.Wrapper: [String]]() + + // Create an imaging factory. + let factory = try IWICImagingFactory.create() + defer { + _ = factory.pointee.lpVtbl.pointee.Release(factory) + } + + // Create a COM enumerator over the encoders known to WIC. + var enumerator: UnsafeMutablePointer? + let rCreate = factory.pointee.lpVtbl.pointee.CreateComponentEnumerator( + factory, + DWORD(WICEncoder.rawValue), + DWORD(WICComponentEnumerateDefault.rawValue), + &enumerator + ) + guard rCreate == S_OK, let enumerator else { + throw ImageAttachmentError.comObjectCreationFailed(IEnumUnknown.self, rCreate) + } + defer { + _ = enumerator.pointee.lpVtbl.pointee.Release(enumerator) + } + + // Loop through the iterator and extract the path extensions and CLSID of + // each encoder we find. + while true { + var nextObject: UnsafeMutablePointer? + guard S_OK == enumerator.pointee.lpVtbl.pointee.Next(enumerator, 1, &nextObject, nil), let nextObject else { + // End of loop. + break + } + defer { + _ = nextObject.pointee.lpVtbl.pointee.Release(nextObject) + } + + // Cast the enumerated object to the correct/expected type. + let info = try withUnsafePointer(to: IID_IWICBitmapEncoderInfo) { IID_IWICBitmapEncoderInfo in + var info: UnsafeMutableRawPointer? + let rQuery = nextObject.pointee.lpVtbl.pointee.QueryInterface(nextObject, IID_IWICBitmapEncoderInfo, &info) + guard rQuery == S_OK, let info else { + throw ImageAttachmentError.queryInterfaceFailed(IWICBitmapEncoderInfo.self, rQuery) + } + return info.assumingMemoryBound(to: IWICBitmapEncoderInfo.self) + } + defer { + _ = info.pointee.lpVtbl.pointee.Release(info) + } + + var clsid = CLSID() + guard S_OK == info.pointee.lpVtbl.pointee.GetCLSID(info, &clsid) else { + continue + } + let extensions = _pathExtensions(for: info) + result[CLSID.Wrapper(clsid)] = extensions + } + + return result + } + + /// Get the set of path extensions corresponding to the image format + /// represented by a WIC bitmap encoder info object. + /// + /// - Parameters: + /// - info: The WIC bitmap encoder info object of interest. + /// + /// - Returns: An array of zero or more path extensions. The case of the + /// resulting strings is unspecified. + private static func _pathExtensions(for info: UnsafeMutablePointer) -> [String] { + // Figure out the size of the buffer we need. (Microsoft does not specify if + // the size is in wide characters or bytes.) + var charCount = UINT(0) + var rGet = info.pointee.lpVtbl.pointee.GetFileExtensions(info, 0, nil, &charCount) + guard rGet == S_OK else { + return [] + } + + // Allocate the necessary buffer and populate it. + let buffer = UnsafeMutableBufferPointer.allocate(capacity: Int(charCount)) + defer { + buffer.deallocate() + } + rGet = info.pointee.lpVtbl.pointee.GetFileExtensions(info, UINT(buffer.count), buffer.baseAddress!, &charCount) + guard rGet == S_OK else { + return [] + } + + // Convert the buffer to a Swift string for further manipulation. + guard let extensions = String.decodeCString(buffer.baseAddress!, as: UTF16.self)?.result else { + return [] + } + + return extensions + .split(separator: ",") + .map { ext in + if ext.starts(with: ".") { + ext.dropFirst(1) + } else { + ext + } + }.map(String.init) + } + + /// Get the `CLSID` value of the WIC image encoder corresponding to the same + /// image format as the given path extension. + /// + /// - Parameters: + /// - pathExtension: The path extension (as a wide C string) for which a + /// `CLSID` value is needed. + /// + /// - Returns: An instance of `CLSID` referring to a a WIC image encoder, or + /// `nil` if one could not be determined. + private static func _computeEncoderCLSID(forPathExtension pathExtension: UnsafePointer) -> CLSID? { + let encoderPathExtensionsByCLSID = (try? _encoderPathExtensionsByCLSID.get()) ?? [:] + return encoderPathExtensionsByCLSID + .first { _, extensions in + extensions.contains { encoderExt in + encoderExt.withCString(encodedAs: UTF16.self) { encoderExt in + 0 == _wcsicmp(pathExtension, encoderExt) + } + } + }.map { $0.key.rawValue } + } + + /// Get the `CLSID` value of the WIC image encoder corresponding to the same + /// image format as the path extension on the given attachment filename. + /// + /// - Parameters: + /// - preferredName: The preferred name of the image for which a `CLSID` + /// value is needed. + /// + /// - Returns: An instance of `CLSID` referring to a a WIC image encoder, or + /// `nil` if one could not be determined. + static func computeEncoderCLSID(forPreferredName preferredName: String) -> CLSID? { + preferredName.withCString(encodedAs: UTF16.self) { (preferredName) -> CLSID? in + // Get the path extension on the preferred name, if any. + var dot: PCWSTR? + guard S_OK == PathCchFindExtension(preferredName, wcslen(preferredName) + 1, &dot), let dot, dot[0] != 0 else { + return nil + } + return _computeEncoderCLSID(forPathExtension: dot + 1) + } + } + + /// Append the path extension preferred by WIC for the image format + /// corresponding to the given `CLSID` value or the given filename. + /// + /// - Parameters: + /// - clsid: The `CLSID` value representing the image format of interest. + /// - preferredName: The preferred name of the image for which a type is + /// needed. + /// + /// - Returns: A copy of `preferredName`, possibly modified to include a path + /// extension appropriate for `CLSID`. + static func appendPathExtension(for clsid: CLSID, to preferredName: String) -> String { + // If there's already a CLSID associated with the filename, and it matches + // the one passed to us, no changes are needed. + if let existingCLSID = computeEncoderCLSID(forPreferredName: preferredName), CLSID.Wrapper(clsid) == CLSID.Wrapper(existingCLSID) { + return preferredName + } + + // Find the preferred path extension for the encoder with the given CLSID. + let encoderPathExtensionsByCLSID = (try? _encoderPathExtensionsByCLSID.get()) ?? [:] + if let ext = encoderPathExtensionsByCLSID[CLSID.Wrapper(clsid)]?.first { + return "\(preferredName).\(ext)" + } + + // Couldn't find anything better. Return the preferred name unmodified. + return preferredName + } + + /// The `CLSID` value of the Windows Imaging Component (WIC) encoder class + /// that corresponds to this image format. + /// + /// For example, if this image format equals ``png``, the value of this + /// property equals [`CLSID_WICPngEncoder`](https://learn.microsoft.com/en-us/windows/win32/wic/-wic-guids-clsids#wic-guids-and-clsids). + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } +#if compiler(>=6.3) && !SWT_FIXED_84466 + @_spi(_) +#endif + public var encoderCLSID: CLSID { + kind.encoderCLSID + } + + /// Construct an instance of this type with the `CLSID` value of a Windows + /// Imaging Component (WIC) encoder class and the desired encoding quality. + /// + /// - Parameters: + /// - encoderCLSID: The `CLSID` value of the Windows Imaging Component + /// encoder class to use when encoding images. + /// - encodingQuality: The encoding quality to use when encoding images. For + /// the lowest supported quality, pass `0.0`. For the highest supported + /// quality, pass `1.0`. + /// + /// If the target image encoder does not support variable-quality encoding, + /// the value of the `encodingQuality` argument is ignored. + /// + /// If `clsid` does not represent an image encoder class supported by WIC, the + /// result is undefined. For a list of image encoder classes supported by WIC, + /// see the documentation for the [`IWICBitmapEncoder`](https://learn.microsoft.com/en-us/windows/win32/api/wincodec/nn-wincodec-iwicbitmapencoder) + /// class. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } +#if compiler(>=6.3) && !SWT_FIXED_84466 + @_spi(_) +#endif + public init(encoderCLSID: CLSID, encodingQuality: Float = 1.0) { + let encoderCLSID = CLSID.Wrapper(encoderCLSID) + let kind: Kind = if encoderCLSID == CLSID.Wrapper(CLSID_WICPngEncoder) { + .png + } else if encoderCLSID == CLSID.Wrapper(CLSID_WICJpegEncoder) { + .jpeg + } else { + .systemValue(encoderCLSID) + } + self.init(kind: kind, encodingQuality: encodingQuality) + } + + /// Construct an instance of this type with the given path extension and + /// encoding quality. + /// + /// - Parameters: + /// - pathExtension: A path extension corresponding to the image format to + /// use when encoding images. + /// - encodingQuality: The encoding quality to use when encoding images. For + /// the lowest supported quality, pass `0.0`. For the highest supported + /// quality, pass `1.0`. + /// + /// If the target image format does not support variable-quality encoding, + /// the value of the `encodingQuality` argument is ignored. + /// + /// If `pathExtension` does not correspond to a recognized image format, this + /// initializer returns `nil`: + /// + /// - On Apple platforms, the content type corresponding to `pathExtension` + /// must conform to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image). + /// - On Windows, there must be a corresponding subclass of [`IWICBitmapEncoder`](https://learn.microsoft.com/en-us/windows/win32/api/wincodec/nn-wincodec-iwicbitmapencoder) + /// registered with Windows Imaging Component. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } + public init?(pathExtension: String, encodingQuality: Float = 1.0) { + let pathExtension = pathExtension.drop { $0 == "." } + + let encoderCLSID = pathExtension.withCString(encodedAs: UTF16.self) { pathExtension in + Self._computeEncoderCLSID(forPathExtension: pathExtension) + } + guard let encoderCLSID else { + return nil + } + self.init(encoderCLSID: encoderCLSID, encodingQuality: encodingQuality) + } +} + +// MARK: - CustomStringConvertible, CustomDebugStringConvertible + +extension AttachableImageFormat.Kind: CustomStringConvertible, CustomDebugStringConvertible { + /// The `CLSID` value of the Windows Imaging Component (WIC) encoder class + /// that corresponds to this image format. + fileprivate var encoderCLSID: CLSID { + switch self { + case .png: + CLSID_WICPngEncoder + case .jpeg: + CLSID_WICJpegEncoder + case let .systemValue(clsid): + (clsid as! CLSID.Wrapper).rawValue + } + } + + /// Get a description of the given `CLSID` value. + /// + /// - Parameters: + /// - clsid: The `CLSID` value to describe. + /// + /// - Returns: A description of `clsid`. + private static func _description(of clsid: CLSID) -> String { + var clsid = clsid + var buffer: RPC_WSTR? + if RPC_S_OK == UuidToStringW(&clsid, &buffer) { + defer { + RpcStringFreeW(&buffer) + } + if let result = String.decodeCString(buffer, as: UTF16.self)?.result { + return result + } + } + return String(describing: clsid) + } + + package var description: String { + let clsid = encoderCLSID + let encoderPathExtensionsByCLSID = (try? AttachableImageFormat._encoderPathExtensionsByCLSID.get()) ?? [:] + if let ext = encoderPathExtensionsByCLSID[CLSID.Wrapper(clsid)]?.first { + return "\(ext.uppercased()) format" + } + return Self._description(of: clsid) + } + + package var debugDescription: String { + let clsid = encoderCLSID + let clsidDescription = Self._description(of: clsid) + let encoderPathExtensionsByCLSID = (try? AttachableImageFormat._encoderPathExtensionsByCLSID.get()) ?? [:] + if let ext = encoderPathExtensionsByCLSID[CLSID.Wrapper(clsid)]?.first { + return "\(ext.uppercased()) format (\(clsidDescription))" + } + return clsidDescription + } +} +#endif diff --git a/Sources/Overlays/_Testing_WinSDK/Attachments/HBITMAP+AttachableAsIWICBitmapSource.swift b/Sources/Overlays/_Testing_WinSDK/Attachments/HBITMAP+AttachableAsIWICBitmapSource.swift new file mode 100644 index 000000000..0982234cc --- /dev/null +++ b/Sources/Overlays/_Testing_WinSDK/Attachments/HBITMAP+AttachableAsIWICBitmapSource.swift @@ -0,0 +1,38 @@ +// +// 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 +// + +#if os(Windows) +public import WinSDK + +extension HBITMAP__: _AttachableByAddressAsIWICBitmapSource { + public static func _copyAttachableIWICBitmapSource( + from imageAddress: UnsafeMutablePointer, + using factory: UnsafeMutablePointer + ) throws -> UnsafeMutablePointer { + var bitmap: UnsafeMutablePointer? + let rCreate = factory.pointee.lpVtbl.pointee.CreateBitmapFromHBITMAP(factory, imageAddress, nil, WICBitmapUsePremultipliedAlpha, &bitmap) + guard rCreate == S_OK, let bitmap else { + throw ImageAttachmentError.comObjectCreationFailed(IWICBitmap.self, rCreate) + } + return try bitmap.cast(to: IWICBitmapSource.self) + } + + public static func _copyAttachableValue(at imageAddress: UnsafeMutablePointer) -> UnsafeMutablePointer { + // The only reasonable failure mode for `CopyImage()` is allocation failure, + // and Swift treats allocation failures as fatal. Hence, we do not check for + // `nil` on return. + CopyImage(imageAddress, UINT(IMAGE_BITMAP), 0, 0, 0).assumingMemoryBound(to: Self.self) + } + + public static func _deinitializeAttachableValue(at imageAddress: UnsafeMutablePointer) { + DeleteObject(imageAddress) + } +} +#endif diff --git a/Sources/Overlays/_Testing_WinSDK/Attachments/HICON+AttachableAsIWICBitmapSource.swift b/Sources/Overlays/_Testing_WinSDK/Attachments/HICON+AttachableAsIWICBitmapSource.swift new file mode 100644 index 000000000..4e6addfa3 --- /dev/null +++ b/Sources/Overlays/_Testing_WinSDK/Attachments/HICON+AttachableAsIWICBitmapSource.swift @@ -0,0 +1,38 @@ +// +// 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 +// + +#if os(Windows) +public import WinSDK + +extension HICON__: _AttachableByAddressAsIWICBitmapSource { + public static func _copyAttachableIWICBitmapSource( + from imageAddress: UnsafeMutablePointer, + using factory: UnsafeMutablePointer + ) throws -> UnsafeMutablePointer { + var bitmap: UnsafeMutablePointer? + let rCreate = factory.pointee.lpVtbl.pointee.CreateBitmapFromHICON(factory, imageAddress, &bitmap) + guard rCreate == S_OK, let bitmap else { + throw ImageAttachmentError.comObjectCreationFailed(IWICBitmap.self, rCreate) + } + return try bitmap.cast(to: IWICBitmapSource.self) + } + + public static func _copyAttachableValue(at imageAddress: UnsafeMutablePointer) -> UnsafeMutablePointer { + // The only reasonable failure mode for `CopyIcon()` is allocation failure, + // and Swift treats allocation failures as fatal. Hence, we do not check for + // `nil` on return. + CopyIcon(imageAddress) + } + + public static func _deinitializeAttachableValue(at imageAddress: UnsafeMutablePointer) { + DestroyIcon(imageAddress) + } +} +#endif diff --git a/Sources/Overlays/_Testing_WinSDK/Attachments/IWICBitmapSource+AttachableAsIWICBitmapSource.swift b/Sources/Overlays/_Testing_WinSDK/Attachments/IWICBitmapSource+AttachableAsIWICBitmapSource.swift new file mode 100644 index 000000000..733b72bb7 --- /dev/null +++ b/Sources/Overlays/_Testing_WinSDK/Attachments/IWICBitmapSource+AttachableAsIWICBitmapSource.swift @@ -0,0 +1,112 @@ +// +// 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 +// + +#if os(Windows) +public import WinSDK + +/// - Important: The casts in this file to `IUnknown` are safe insofar as we use +/// them to access fixed members of the COM vtable. The casts would become +/// unsafe if we allowed the resulting pointers to escape _and_ if any of the +/// types we use them on have multiple non-virtual inheritance to `IUnknown`. + +/// A protocol that identifies a type as a COM subclass of `IWICBitmapSource`. +/// +/// Because COM class inheritance is not visible in Swift, we must manually +/// apply conformances to this protocol to each COM type that inherits from +/// `IWICBitmapSource`. +/// +/// Because this protocol is not `public`, we must also explicitly restate +/// conformance to the public protocol `_AttachableByAddressAsIWICBitmapSource` +/// even though this protocol refines that one. This protocol refines +/// `_AttachableByAddressAsIWICBitmapSource` because otherwise the compiler will +/// not allow us to declare `public` members in its extension that provides the +/// implementation of `_AttachableByAddressAsIWICBitmapSource` below. +/// +/// This protocol is not part of the public interface of the testing library. It +/// allows us to reuse code across all subclasses of `IWICBitmapSource`. +protocol IWICBitmapSourceProtocol: _AttachableByAddressAsIWICBitmapSource {} + +extension IWICBitmapSource: _AttachableByAddressAsIWICBitmapSource, IWICBitmapSourceProtocol {} +extension IWICBitmap: _AttachableByAddressAsIWICBitmapSource, IWICBitmapSourceProtocol {} +extension IWICBitmapClipper: _AttachableByAddressAsIWICBitmapSource, IWICBitmapSourceProtocol {} +extension IWICBitmapFlipRotator: _AttachableByAddressAsIWICBitmapSource, IWICBitmapSourceProtocol {} +extension IWICBitmapFrameDecode: _AttachableByAddressAsIWICBitmapSource, IWICBitmapSourceProtocol {} +extension IWICBitmapScaler: _AttachableByAddressAsIWICBitmapSource, IWICBitmapSourceProtocol {} +extension IWICColorTransform: _AttachableByAddressAsIWICBitmapSource, IWICBitmapSourceProtocol {} +extension IWICFormatConverter: _AttachableByAddressAsIWICBitmapSource, IWICBitmapSourceProtocol {} +extension IWICPlanarFormatConverter: _AttachableByAddressAsIWICBitmapSource, IWICBitmapSourceProtocol {} + +// MARK: - Upcasting conveniences + +extension UnsafeMutablePointer where Pointee: IWICBitmapSourceProtocol { + /// Upcast this WIC bitmap to a WIC bitmap source (its parent type). + /// + /// - Returns: `self`, cast to the parent type via `QueryInterface()`. The + /// caller is responsible for releasing the resulting object. + /// + /// - Throws: Any error that occurs while calling `QueryInterface()`. In + /// practice, this function is not expected to throw an error as it should + /// always be possible to cast a valid instance of `IWICBitmap` to + /// `IWICBitmapSource`. + /// + /// - Important: This function consumes a reference to `self` even if the cast + /// fails. + consuming func cast(to _: IWICBitmapSource.Type) throws -> UnsafeMutablePointer { + try self.withMemoryRebound(to: IUnknown.self, capacity: 1) { `self` in + defer { + _ = self.pointee.lpVtbl.pointee.Release(self) + } + + return try withUnsafePointer(to: IID_IWICBitmapSource) { IID_IWICBitmapSource in + var bitmapSource: UnsafeMutableRawPointer? + let rQuery = self.pointee.lpVtbl.pointee.QueryInterface(self, IID_IWICBitmapSource, &bitmapSource) + guard rQuery == S_OK, let bitmapSource else { + throw ImageAttachmentError.queryInterfaceFailed(IWICBitmapSource.self, rQuery) + } + return bitmapSource.assumingMemoryBound(to: IWICBitmapSource.self) + } + } + } +} + +// MARK: - _AttachableByAddressAsIWICBitmapSource implementation + +extension IWICBitmapSourceProtocol { + public static func _copyAttachableIWICBitmapSource( + from imageAddress: UnsafeMutablePointer, + using factory: UnsafeMutablePointer + ) throws -> UnsafeMutablePointer { + try _copyAttachableValue(at: imageAddress).cast(to: IWICBitmapSource.self) + } + + public static func _copyAttachableValue(at imageAddress: UnsafeMutablePointer) -> UnsafeMutablePointer { + imageAddress.withMemoryRebound(to: IUnknown.self, capacity: 1) { imageAddress in + _ = imageAddress.pointee.lpVtbl.pointee.AddRef(imageAddress) + } + return imageAddress + } + + public static func _deinitializeAttachableValue(at imageAddress: UnsafeMutablePointer) { + imageAddress.withMemoryRebound(to: IUnknown.self, capacity: 1) { imageAddress in + _ = imageAddress.pointee.lpVtbl.pointee.Release(imageAddress) + } + } +} + +extension IWICBitmapSource { + public static func _copyAttachableIWICBitmapSource( + from imageAddress: UnsafeMutablePointer, + using factory: UnsafeMutablePointer + ) throws -> UnsafeMutablePointer { + _ = imageAddress.pointee.lpVtbl.pointee.AddRef(imageAddress) + return imageAddress + } +} +#endif diff --git a/Sources/Overlays/_Testing_WinSDK/Attachments/UnsafeMutablePointer+AttachableAsIWICBitmapSource.swift b/Sources/Overlays/_Testing_WinSDK/Attachments/UnsafeMutablePointer+AttachableAsIWICBitmapSource.swift new file mode 100644 index 000000000..a7487c1cd --- /dev/null +++ b/Sources/Overlays/_Testing_WinSDK/Attachments/UnsafeMutablePointer+AttachableAsIWICBitmapSource.swift @@ -0,0 +1,30 @@ +// +// 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 +// + +#if os(Windows) +package import WinSDK + +/// @Metadata { +/// @Available(Swift, introduced: 6.3) +/// } +extension UnsafeMutablePointer: AttachableAsImage, AttachableAsIWICBitmapSource where Pointee: _AttachableByAddressAsIWICBitmapSource { + package func copyAttachableIWICBitmapSource(using factory: UnsafeMutablePointer) throws -> UnsafeMutablePointer { + try Pointee._copyAttachableIWICBitmapSource(from: self, using: factory) + } + + public func _copyAttachableValue() -> Self { + Pointee._copyAttachableValue(at: self) + } + + public func _deinitializeAttachableValue() { + Pointee._deinitializeAttachableValue(at: self) + } +} +#endif diff --git a/Sources/Overlays/_Testing_WinSDK/Attachments/_AttachableImageWrapper+AttachableWrapper.swift b/Sources/Overlays/_Testing_WinSDK/Attachments/_AttachableImageWrapper+AttachableWrapper.swift new file mode 100644 index 000000000..fe148a9f0 --- /dev/null +++ b/Sources/Overlays/_Testing_WinSDK/Attachments/_AttachableImageWrapper+AttachableWrapper.swift @@ -0,0 +1,60 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +#if os(Windows) +private import WinSDK + +/// @Metadata { +/// @Available(Swift, introduced: 6.3) +/// } +extension _AttachableImageWrapper: Attachable, AttachableWrapper where Image: AttachableAsImage { + /// Get the image format to use when encoding an image. + /// + /// - Parameters: + /// - preferredName: The preferred name of the image for which a type is + /// needed. + /// + /// - Returns: An instance of ``AttachableImageFormat`` referring to a + /// concrete image type. + /// + /// This function is not part of the public interface of the testing library. + private func _imageFormat(forPreferredName preferredName: String) -> AttachableImageFormat { + if let imageFormat { + // The developer explicitly specified a type. + return imageFormat + } + + if let clsid = AttachableImageFormat.computeEncoderCLSID(forPreferredName: preferredName) { + return AttachableImageFormat(encoderCLSID: clsid) + } + + // We couldn't derive a concrete type from the path extension, so pick + // between PNG and JPEG based on the encoding quality. + let encodingQuality = imageFormat?.encodingQuality ?? 1.0 + return encodingQuality < 1.0 ? .jpeg : .png + } + + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } + public func withUnsafeBytes(for attachment: borrowing Attachment<_AttachableImageWrapper>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + let imageFormat = _imageFormat(forPreferredName: attachment.preferredName) + return try wrappedValue.withUnsafeBytes(as: imageFormat, body) + } + + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } + public borrowing func preferredName(for attachment: borrowing Attachment<_AttachableImageWrapper>, basedOn suggestedName: String) -> String { + let imageFormat = _imageFormat(forPreferredName: suggestedName) + return AttachableImageFormat.appendPathExtension(for: imageFormat.encoderCLSID, to: suggestedName) + } +} +#endif diff --git a/Sources/Overlays/_Testing_WinSDK/CMakeLists.txt b/Sources/Overlays/_Testing_WinSDK/CMakeLists.txt new file mode 100644 index 000000000..1b56f0a8d --- /dev/null +++ b/Sources/Overlays/_Testing_WinSDK/CMakeLists.txt @@ -0,0 +1,31 @@ +# This source file is part of the Swift.org open source project +# +# Copyright (c) 2024–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 + +if (CMAKE_SYSTEM_NAME STREQUAL "Windows") + add_library(_Testing_WinSDK + Attachments/_AttachableImageWrapper+AttachableWrapper.swift + Attachments/AttachableAsIWICBitmapSource.swift + Attachments/AttachableImageFormat+CLSID.swift + Attachments/HBITMAP+AttachableAsIWICBitmapSource.swift + Attachments/HICON+AttachableAsIWICBitmapSource.swift + Attachments/IWICBitmapSource+AttachableAsIWICBitmapSource.swift + Attachments/UnsafeMutablePointer+AttachableAsIWICBitmapSource.swift + Support/Additions/GUIDAdditions.swift + Support/Additions/IPropertyBag2Additions.swift + Support/Additions/IWICImagingFactoryAdditions.swift + ReexportTesting.swift) + + target_link_libraries(_Testing_WinSDK PUBLIC + Testing) + + target_compile_options(_Testing_WinSDK PRIVATE + -enable-library-evolution + -emit-module-interface -emit-module-interface-path $/_Testing_WinSDK.swiftinterface) + + _swift_testing_install_target(_Testing_WinSDK) +endif() diff --git a/Sources/Overlays/_Testing_WinSDK/ReexportTesting.swift b/Sources/Overlays/_Testing_WinSDK/ReexportTesting.swift new file mode 100644 index 000000000..da5b41a1b --- /dev/null +++ b/Sources/Overlays/_Testing_WinSDK/ReexportTesting.swift @@ -0,0 +1,11 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +@_exported public import Testing diff --git a/Sources/Overlays/_Testing_WinSDK/Support/Additions/GUIDAdditions.swift b/Sources/Overlays/_Testing_WinSDK/Support/Additions/GUIDAdditions.swift new file mode 100644 index 000000000..06985ed4c --- /dev/null +++ b/Sources/Overlays/_Testing_WinSDK/Support/Additions/GUIDAdditions.swift @@ -0,0 +1,55 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +#if os(Windows) +internal import WinSDK + +extension GUID { + /// A type that wraps `GUID` instances and conforms to various Swift + /// protocols. + /// + /// - Bug: This type will become obsolete once we can use the `Equatable` and + /// `Hashable` conformances added to the WinSDK module in Swift 6.3. +#if compiler(>=6.3.1) && DEBUG + @available(*, deprecated, message: "GUID.Wrapper is no longer needed and can be removed.") +#endif + struct Wrapper: Sendable, RawRepresentable { + var rawValue: GUID + } +} + +// MARK: - + +extension GUID.Wrapper: Equatable, Hashable, CustomStringConvertible { + init(_ rawValue: GUID) { + self.init(rawValue: rawValue) + } + +#if compiler(<6.3.1) + private var _uint128Value: UInt128 { + withUnsafeBytes(of: rawValue) { buffer in + buffer.baseAddress!.loadUnaligned(as: UInt128.self) + } + } + + static func ==(lhs: Self, rhs: Self) -> Bool { + lhs._uint128Value == rhs._uint128Value + } + + func hash(into hasher: inout Hasher) { + hasher.combine(_uint128Value) + } + + var description: String { + String(describing: rawValue) + } +#endif +} +#endif diff --git a/Sources/Overlays/_Testing_WinSDK/Support/Additions/IPropertyBag2Additions.swift b/Sources/Overlays/_Testing_WinSDK/Support/Additions/IPropertyBag2Additions.swift new file mode 100644 index 000000000..307e25778 --- /dev/null +++ b/Sources/Overlays/_Testing_WinSDK/Support/Additions/IPropertyBag2Additions.swift @@ -0,0 +1,40 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +#if os(Windows) +internal import WinSDK + +extension UnsafeMutablePointer { + /// Write a floating-point value to this property bag with the given name, + /// + /// - Parameters: + /// - value: The value to write. + /// - propertyName: The name of the property. + /// + /// - Throws: If any error occurred writing the property. + func write(_ value: Float, named propertyName: String) throws { + let rWrite = propertyName.withCString(encodedAs: UTF16.self) { propertyName in + var option = PROPBAG2() + option.pstrName = .init(mutating: propertyName) + + return withUnsafeTemporaryAllocation(of: VARIANT.self, capacity: 1) { variant in + let variant = variant.baseAddress! + VariantInit(variant) + variant.pointee.vt = .init(VT_R4.rawValue) + variant.pointee.fltVal = value + return self.pointee.lpVtbl.pointee.Write(self, 1, &option, variant) + } + } + guard rWrite == S_OK else { + throw ImageAttachmentError.propertyBagWritingFailed(propertyName, rWrite) + } + } +} +#endif diff --git a/Sources/Overlays/_Testing_WinSDK/Support/Additions/IWICImagingFactoryAdditions.swift b/Sources/Overlays/_Testing_WinSDK/Support/Additions/IWICImagingFactoryAdditions.swift new file mode 100644 index 000000000..acacc80b1 --- /dev/null +++ b/Sources/Overlays/_Testing_WinSDK/Support/Additions/IWICImagingFactoryAdditions.swift @@ -0,0 +1,40 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +#if os(Windows) +internal import WinSDK + +extension IWICImagingFactory { + /// Create an imaging factory. + /// + /// - Returns: A pointer to a new instance of this type. The caller is + /// responsible for releasing this object when done with it. + /// + /// - Throws: Any error that occurred while creating the object. + static func create() throws -> UnsafeMutablePointer { + try withUnsafePointer(to: CLSID_WICImagingFactory) { CLSID_WICImagingFactory in + try withUnsafePointer(to: IID_IWICImagingFactory) { IID_IWICImagingFactory in + var factory: UnsafeMutableRawPointer? + let rCreate = CoCreateInstance( + CLSID_WICImagingFactory, + nil, + DWORD(CLSCTX_INPROC_SERVER.rawValue), + IID_IWICImagingFactory, + &factory + ) + guard rCreate == S_OK, let factory = factory?.assumingMemoryBound(to: Self.self) else { + throw ImageAttachmentError.comObjectCreationFailed(Self.self, rCreate) + } + return factory + } + } + } +} +#endif diff --git a/Sources/Testing/ABI/ABI.Record.swift b/Sources/Testing/ABI/ABI.Record.swift index 74ac7f9aa..40a8d4bc3 100644 --- a/Sources/Testing/ABI/ABI.Record.swift +++ b/Sources/Testing/ABI/ABI.Record.swift @@ -36,6 +36,10 @@ extension ABI { guard let event = EncodedEvent(encoding: event, in: eventContext, messages: messages) else { return nil } + if !V.includesExperimentalFields && event.kind.rawValue.first == "_" { + // Don't encode experimental event kinds. + return nil + } kind = .event(event) } } @@ -66,7 +70,7 @@ extension ABI.Record: Codable { init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let versionNumber = try container.decode(Int.self, forKey: .version) + let versionNumber = try container.decode(VersionNumber.self, forKey: .version) if versionNumber != V.versionNumber { throw DecodingError.dataCorrupted( DecodingError.Context( diff --git a/Sources/Testing/ABI/ABI.swift b/Sources/Testing/ABI/ABI.swift index 3106d2a72..7a33970fc 100644 --- a/Sources/Testing/ABI/ABI.swift +++ b/Sources/Testing/ABI/ABI.swift @@ -18,7 +18,7 @@ extension ABI { /// A protocol describing the types that represent different ABI versions. protocol Version: Sendable { /// The numeric representation of this ABI version. - static var versionNumber: Int { get } + static var versionNumber: VersionNumber { get } #if canImport(Foundation) && (!SWT_NO_FILE_IO || !SWT_NO_ABI_ENTRY_POINT) /// Create an event handler that encodes events as JSON and forwards them to @@ -44,6 +44,87 @@ extension ABI { /// The current supported ABI version (ignoring any experimental versions.) typealias CurrentVersion = v0 + + /// The highest defined and supported ABI version (including any experimental + /// versions.) + typealias HighestVersion = v6_3 + +#if !hasFeature(Embedded) + /// Get the type representing a given ABI version. + /// + /// - Parameters: + /// - versionNumber: The ABI version number for which a concrete type is + /// needed. + /// + /// - Returns: A type conforming to ``ABI/Version`` that represents the given + /// ABI version, or `nil` if no such type exists. + static func version(forVersionNumber versionNumber: VersionNumber = ABI.CurrentVersion.versionNumber) -> (any Version.Type)? { + if versionNumber > ABI.HighestVersion.versionNumber { + // If the caller requested an ABI version higher than the current Swift + // compiler version and it's not an ABI version we've explicitly defined, + // then we assume we don't know what they're talking about and return nil. + // + // Note that it is possible for the Swift compiler version to be lower + // than the highest defined ABI version (e.g. if you use a 6.2 toolchain + // to build this package's release/6.3 branch with a 6.3 ABI defined.) + // + // Note also that building an old version of Swift Testing with a newer + // compiler may produce incorrect results here. We don't generally support + // that configuration though. + if versionNumber > swiftCompilerVersion { + return nil + } + } + + return switch versionNumber { + case ABI.v6_3.versionNumber...: + ABI.v6_3.self + case ABI.v0.versionNumber...: + ABI.v0.self +#if !SWT_NO_SNAPSHOT_TYPES + case ABI.Xcode16.versionNumber: + // Legacy support for Xcode 16. Support for this undocumented version will + // be removed in a future update. Do not use it. + ABI.Xcode16.self +#endif + default: + nil + } + } +#endif +} + +/// The value of the environment variable flag which enables experimental event +/// stream fields, if any. +private let _shouldIncludeExperimentalFlags = Environment.flag(named: "SWT_EXPERIMENTAL_EVENT_STREAM_FIELDS_ENABLED") + +extension ABI.Version { + /// Whether or not experimental fields should be included when using this + /// ABI version. + /// + /// The value of this property is `true` if any of the following conditions + /// are satisfied: + /// + /// - The version number is less than 6.3. This is to preserve compatibility + /// with existing clients before the inclusion of experimental fields became + /// opt-in starting in 6.3. + /// - The version number is greater than or equal to 6.3 and the environment + /// variable flag `SWT_EXPERIMENTAL_EVENT_STREAM_FIELDS_ENABLED` is set to a + /// true value. + /// - The version number is greater than or equal to that of ``ABI/ExperimentalVersion``. + /// + /// Otherwise, the value of this property is `false`. + static var includesExperimentalFields: Bool { + switch versionNumber { + case ABI.ExperimentalVersion.versionNumber...: + true + case ABI.v6_3.versionNumber...: + _shouldIncludeExperimentalFlags == true + default: + // Maintain behavior for pre-6.3 versions. + true + } + } } // MARK: - Concrete ABI versions @@ -54,28 +135,36 @@ extension ABI { /// /// - Warning: This type will be removed in a future update. enum Xcode16: Sendable, Version { - static var versionNumber: Int { - -1 + static var versionNumber: VersionNumber { + VersionNumber(-1, 0) } } #endif /// A namespace and type for ABI version 0 symbols. public enum v0: Sendable, Version { - static var versionNumber: Int { - 0 + static var versionNumber: VersionNumber { + VersionNumber(0, 0) } } - /// A namespace and type for ABI version 1 symbols. + /// A namespace and type for ABI version 6.3 symbols. /// /// @Metadata { - /// @Available("Swift Testing ABI", introduced: 1) + /// @Available(Swift, introduced: 6.3) /// } @_spi(Experimental) - public enum v1: Sendable, Version { - static var versionNumber: Int { - 1 + public enum v6_3: Sendable, Version { + static var versionNumber: VersionNumber { + VersionNumber(6, 3) + } + } + + /// A namespace and type representing the ABI version whose symbols are + /// considered experimental. + enum ExperimentalVersion: Sendable, Version { + static var versionNumber: VersionNumber { + VersionNumber(99, 0) } } } diff --git a/Sources/Testing/ABI/Encoded/ABI.EncodedAttachment.swift b/Sources/Testing/ABI/Encoded/ABI.EncodedAttachment.swift index 7668f778a..013e129f6 100644 --- a/Sources/Testing/ABI/Encoded/ABI.EncodedAttachment.swift +++ b/Sources/Testing/ABI/Encoded/ABI.EncodedAttachment.swift @@ -8,6 +8,10 @@ // See https://swift.org/CONTRIBUTORS.txt for Swift project authors // +#if canImport(Foundation) +private import Foundation +#endif + extension ABI { /// A type implementing the JSON encoding of ``Attachment`` for the ABI entry /// point and event stream output. @@ -15,14 +19,42 @@ extension ABI { /// This type is not part of the public interface of the testing library. It /// assists in converting values to JSON; clients that consume this JSON are /// expected to write their own decoders. - /// - /// - Warning: Attachments are not yet part of the JSON schema. struct EncodedAttachment: Sendable where V: ABI.Version { /// The path where the attachment was written. var path: String? + /// The preferred name of the attachment. + /// + /// - Warning: Attachments' preferred names are not yet part of the JSON + /// schema. + var _preferredName: String? + + /// The raw content of the attachment, if available. + /// + /// The value of this property is set if the attachment was not first saved + /// to a file. It may also be `nil` if an error occurred while trying to get + /// the original attachment's serialized representation. + /// + /// - Warning: Inline attachment content is not yet part of the JSON schema. + var _bytes: Bytes? + init(encoding attachment: borrowing Attachment, in eventContext: borrowing Event.Context) { path = attachment.fileSystemPath + + if V.includesExperimentalFields { + _preferredName = attachment.preferredName + + if path == nil { + _bytes = try? attachment.withUnsafeBytes { bytes in + return Bytes(rawValue: [UInt8](bytes)) + } + } + } + } + + /// A structure representing the bytes of an attachment. + struct Bytes: Sendable, RawRepresentable { + var rawValue: [UInt8] } } } @@ -30,3 +62,82 @@ extension ABI { // MARK: - Codable extension ABI.EncodedAttachment: Codable {} + +extension ABI.EncodedAttachment.Bytes: Codable { + func encode(to encoder: any Encoder) throws { +#if canImport(Foundation) + // If possible, encode this structure as Base64 data. + try rawValue.withUnsafeBytes { rawValue in + let data = Data(bytesNoCopy: .init(mutating: rawValue.baseAddress!), count: rawValue.count, deallocator: .none) + var container = encoder.singleValueContainer() + try container.encode(data) + } +#else + // Otherwise, it's an array of integers. + var container = encoder.singleValueContainer() + try container.encode(rawValue) +#endif + } + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + +#if canImport(Foundation) + // If possible, decode a whole Foundation Data object. + if let data = try? container.decode(Data.self) { + self.init(rawValue: [UInt8](data)) + return + } +#endif + + // Fall back to trying to decode an array of integers. + let bytes = try container.decode([UInt8].self) + self.init(rawValue: bytes) + } +} + +// MARK: - Attachable + +extension ABI.EncodedAttachment: Attachable { + var estimatedAttachmentByteCount: Int? { + _bytes?.rawValue.count + } + + /// An error type that is thrown when ``ABI/EncodedAttachment`` cannot satisfy + /// a request for the underlying attachment's bytes. + fileprivate struct BytesUnavailableError: Error {} + + borrowing func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + if let bytes = _bytes?.rawValue { + return try bytes.withUnsafeBytes(body) + } + +#if !SWT_NO_FILE_IO + guard let path else { + throw BytesUnavailableError() + } +#if canImport(Foundation) + // Leverage Foundation's file-mapping logic since we're using Data anyway. + let url = URL(fileURLWithPath: path, isDirectory: false) + let bytes = try Data(contentsOf: url, options: [.mappedIfSafe]) +#else + let fileHandle = try FileHandle(forReadingAtPath: path) + let bytes = try fileHandle.readToEnd() +#endif + return try bytes.withUnsafeBytes(body) +#else + // Cannot read the attachment from disk on this platform. + throw BytesUnavailableError() +#endif + } + + borrowing func preferredName(for attachment: borrowing Attachment, basedOn suggestedName: String) -> String { + _preferredName ?? suggestedName + } +} + +extension ABI.EncodedAttachment.BytesUnavailableError: CustomStringConvertible { + var description: String { + "The attachment's content could not be deserialized." + } +} diff --git a/Sources/Testing/ABI/Encoded/ABI.EncodedEvent.swift b/Sources/Testing/ABI/Encoded/ABI.EncodedEvent.swift index 73e7db2ac..3bfd6ff36 100644 --- a/Sources/Testing/ABI/Encoded/ABI.EncodedEvent.swift +++ b/Sources/Testing/ABI/Encoded/ABI.EncodedEvent.swift @@ -29,8 +29,10 @@ extension ABI { case issueRecorded case valueAttached case testCaseEnded + case testCaseCancelled = "_testCaseCancelled" case testEnded case testSkipped + case testCancelled = "_testCancelled" case runEnded } @@ -64,6 +66,38 @@ extension ABI { /// - Warning: Test cases are not yet part of the JSON schema. var _testCase: EncodedTestCase? + /// The comments the test author provided for this event, if any. + /// + /// The value of this property contains the comments related to the primary + /// user action that caused this event to be generated. + /// + /// Some kinds of events have additional associated comments. For example, + /// when using ``withKnownIssue(_:isIntermittent:sourceLocation:_:)``, there + /// can be separate comments for the "underlying" issue versus the known + /// issue matcher, and either can be `nil`. In such cases, the secondary + /// comment(s) are represented via a distinct property depending on the kind + /// of that event. + /// + /// - Warning: Comments at this level are not yet part of the JSON schema. + var _comments: [String]? + + /// A source location associated with this event, if any. + /// + /// The value of this property represents the source location most closely + /// related to the primary user action that caused this event to be + /// generated. + /// + /// Some kinds of events have additional associated source locations. For + /// example, when using ``withKnownIssue(_:isIntermittent:sourceLocation:_:)``, + /// there can be separate source locations for the "underlying" issue versus + /// the known issue matcher. In such cases, the secondary source location(s) + /// are represented via a distinct property depending on the kind of that + /// event. + /// + /// - Warning: Source locations at this level of the JSON schema are not yet + /// part of said JSON schema. + var _sourceLocation: SourceLocation? + init?(encoding event: borrowing Event, in eventContext: borrowing Event.Context, messages: borrowing [Event.HumanReadableOutputRecorder.Message]) { switch event.kind { case .runStarted: @@ -86,10 +120,14 @@ extension ABI { return nil } kind = .testCaseEnded + case .testCaseCancelled: + kind = .testCaseCancelled case .testEnded: kind = .testEnded case .testSkipped: kind = .testSkipped + case .testCancelled: + kind = .testCancelled case .runEnded: kind = .runEnded default: @@ -98,8 +136,27 @@ extension ABI { instant = EncodedInstant(encoding: event.instant) self.messages = messages.map(EncodedMessage.init) testID = event.testID.map(EncodedTest.ID.init) - if eventContext.test?.isParameterized == true { - _testCase = eventContext.testCase.map(EncodedTestCase.init) + + // Experimental fields + if V.includesExperimentalFields { + switch event.kind { + case let .issueRecorded(recordedIssue): + _comments = recordedIssue.comments.map(\.rawValue) + _sourceLocation = recordedIssue.sourceLocation + case let .valueAttached(attachment): + _sourceLocation = attachment.sourceLocation + case let .testCaseCancelled(skipInfo), + let .testSkipped(skipInfo), + let .testCancelled(skipInfo): + _comments = Array(skipInfo.comment).map(\.rawValue) + _sourceLocation = skipInfo.sourceLocation + default: + break + } + + if eventContext.test?.isParameterized == true { + _testCase = eventContext.testCase.map(EncodedTestCase.init) + } } } } diff --git a/Sources/Testing/ABI/Encoded/ABI.EncodedIssue.swift b/Sources/Testing/ABI/Encoded/ABI.EncodedIssue.swift index 0ea218cc8..c593a68a5 100644 --- a/Sources/Testing/ABI/Encoded/ABI.EncodedIssue.swift +++ b/Sources/Testing/ABI/Encoded/ABI.EncodedIssue.swift @@ -26,8 +26,21 @@ extension ABI { /// The severity of this issue. /// - /// - Warning: Severity is not yet part of the JSON schema. - var _severity: Severity + /// Prior to 6.3, this is nil. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } + var severity: Severity? + + /// If the issue is a failing issue. + /// + /// Prior to 6.3, this is nil. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } + var isFailure: Bool? /// Whether or not this issue is known to occur. var isKnown: Bool @@ -46,17 +59,27 @@ extension ABI { var _error: EncodedError? init(encoding issue: borrowing Issue, in eventContext: borrowing Event.Context) { - _severity = switch issue.severity { - case .warning: .warning - case .error: .error - } + // >= v0 isKnown = issue.isKnown sourceLocation = issue.sourceLocation - if let backtrace = issue.sourceContext.backtrace { - _backtrace = EncodedBacktrace(encoding: backtrace, in: eventContext) + + // >= v6.3 + if V.versionNumber >= ABI.v6_3.versionNumber { + severity = switch issue.severity { + case .warning: .warning + case .error: .error + } + isFailure = issue.isFailure } - if let error = issue.error { - _error = EncodedError(encoding: error, in: eventContext) + + // Experimental fields + if V.includesExperimentalFields { + if let backtrace = issue.sourceContext.backtrace { + _backtrace = EncodedBacktrace(encoding: backtrace, in: eventContext) + } + if let error = issue.error { + _error = EncodedError(encoding: error, in: eventContext) + } } } } diff --git a/Sources/Testing/ABI/Encoded/ABI.EncodedTest.swift b/Sources/Testing/ABI/Encoded/ABI.EncodedTest.swift index cda558f83..43a1b615b 100644 --- a/Sources/Testing/ABI/Encoded/ABI.EncodedTest.swift +++ b/Sources/Testing/ABI/Encoded/ABI.EncodedTest.swift @@ -76,10 +76,6 @@ extension ABI { /// The tags associated with the test. /// /// - Warning: Tags are not yet part of the JSON schema. - /// - /// @Metadata { - /// @Available("Swift Testing ABI", introduced: 1) - /// } var _tags: [String]? init(encoding test: borrowing Test) { @@ -87,18 +83,19 @@ extension ABI { kind = .suite } else { kind = .function - let testIsParameterized = test.isParameterized - isParameterized = testIsParameterized - if testIsParameterized { - _testCases = test.uncheckedTestCases?.map(EncodedTestCase.init(encoding:)) - } + isParameterized = test.isParameterized } name = test.name displayName = test.displayName sourceLocation = test.sourceLocation id = ID(encoding: test.id) - if V.versionNumber >= ABI.v1.versionNumber { + // Experimental fields + if V.includesExperimentalFields { + if isParameterized == true { + _testCases = test.uncheckedTestCases?.map(EncodedTestCase.init(encoding:)) + } + let tags = test.tags if !tags.isEmpty { _tags = tags.map(String.init(describing:)) diff --git a/Sources/Testing/ABI/EntryPoints/ABIEntryPoint.swift b/Sources/Testing/ABI/EntryPoints/ABIEntryPoint.swift index f3f50a1be..2ff10c964 100644 --- a/Sources/Testing/ABI/EntryPoints/ABIEntryPoint.swift +++ b/Sources/Testing/ABI/EntryPoints/ABIEntryPoint.swift @@ -50,7 +50,7 @@ extension ABI.v0 { let args = try configurationJSON.map { configurationJSON in try JSON.decode(__CommandLineArguments_v0.self, from: configurationJSON) } - let eventHandler = try eventHandlerForStreamingEvents(version: args?.eventStreamVersion, encodeAsJSONLines: false, forwardingTo: recordHandler) + let eventHandler = try eventHandlerForStreamingEvents(withVersionNumber: args?.eventStreamVersionNumber, encodeAsJSONLines: false, forwardingTo: recordHandler) switch await Testing.entryPoint(passing: args, eventHandler: eventHandler) { case EXIT_SUCCESS, EXIT_NO_TESTS_FOUND: @@ -67,50 +67,12 @@ extension ABI.v0 { /// /// - Returns: The value of ``ABI/v0/entryPoint-swift.type.property`` cast to an /// untyped pointer. +/// +/// - Note: This function's name is prefixed with `swt_` instead of +/// `swift_testing_` for binary compatibility reasons. Future ABI entry point +/// functions should use the `swift_testing_` prefix instead. @_cdecl("swt_abiv0_getEntryPoint") @usableFromInline func abiv0_getEntryPoint() -> UnsafeRawPointer { unsafeBitCast(ABI.v0.entryPoint, to: UnsafeRawPointer.self) } - -#if !SWT_NO_SNAPSHOT_TYPES -// MARK: - Xcode 16 compatibility - -extension ABI.Xcode16 { - /// An older signature for ``ABI/v0/EntryPoint-swift.typealias`` used by - /// Xcode 16. - /// - /// - Warning: This type will be removed in a future update. - @available(*, deprecated, message: "Use ABI.v0.EntryPoint instead.") - typealias EntryPoint = @Sendable ( - _ argumentsJSON: UnsafeRawBufferPointer?, - _ recordHandler: @escaping @Sendable (_ recordJSON: UnsafeRawBufferPointer) -> Void - ) async throws -> CInt -} - -/// An older signature for ``ABI/v0/entryPoint-swift.type.property`` used by -/// Xcode 16. -/// -/// - Warning: This function will be removed in a future update. -@available(*, deprecated, message: "Use ABI.v0.entryPoint (swt_abiv0_getEntryPoint()) instead.") -@_cdecl("swt_copyABIEntryPoint_v0") -@usableFromInline func copyABIEntryPoint_v0() -> UnsafeMutableRawPointer { - let result = UnsafeMutablePointer.allocate(capacity: 1) - result.initialize { configurationJSON, recordHandler in - var args = try configurationJSON.map { configurationJSON in - try JSON.decode(__CommandLineArguments_v0.self, from: configurationJSON) - } - if args?.eventStreamVersion == nil { - args?.eventStreamVersion = ABI.Xcode16.versionNumber - } - let eventHandler = try eventHandlerForStreamingEvents(version: args?.eventStreamVersion, encodeAsJSONLines: false, forwardingTo: recordHandler) - - var exitCode = await Testing.entryPoint(passing: args, eventHandler: eventHandler) - if exitCode == EXIT_NO_TESTS_FOUND { - exitCode = EXIT_SUCCESS - } - return exitCode - } - return .init(result) -} -#endif #endif diff --git a/Sources/Testing/ABI/EntryPoints/EntryPoint.swift b/Sources/Testing/ABI/EntryPoints/EntryPoint.swift index 7a2e63003..6163e7bd1 100644 --- a/Sources/Testing/ABI/EntryPoints/EntryPoint.swift +++ b/Sources/Testing/ABI/EntryPoints/EntryPoint.swift @@ -54,12 +54,29 @@ func entryPoint(passing args: __CommandLineArguments_v0?, eventHandler: Event.Ha #if !SWT_NO_FILE_IO // Configure the event recorder to write events to stderr. if configuration.verbosity > .min { - let eventRecorder = Event.ConsoleOutputRecorder(options: .for(.stderr)) { string in - try? FileHandle.stderr.write(string) - } - configuration.eventHandler = { [oldEventHandler = configuration.eventHandler] event, context in - eventRecorder.record(event, in: context) - oldEventHandler(event, context) + // Check for experimental console output flag + if Environment.flag(named: "SWT_ENABLE_EXPERIMENTAL_CONSOLE_OUTPUT") == true { + // Use experimental AdvancedConsoleOutputRecorder + var advancedOptions = Event.AdvancedConsoleOutputRecorder.Options() + advancedOptions.base = .for(.stderr) + + let eventRecorder = Event.AdvancedConsoleOutputRecorder(options: advancedOptions) { string in + try? FileHandle.stderr.write(string) + } + + configuration.eventHandler = { [oldEventHandler = configuration.eventHandler] event, context in + eventRecorder.record(event, in: context) + oldEventHandler(event, context) + } + } else { + // Use the standard console output recorder (default behavior) + let eventRecorder = Event.ConsoleOutputRecorder(options: .for(.stderr)) { string in + try? FileHandle.stderr.write(string) + } + configuration.eventHandler = { [oldEventHandler = configuration.eventHandler] event, context in + eventRecorder.record(event, in: context) + oldEventHandler(event, context) + } } } #endif @@ -193,6 +210,9 @@ public struct __CommandLineArguments_v0: Sendable { /// The value of the `--parallel` or `--no-parallel` argument. public var parallel: Bool? + /// The maximum number of test tasks to run in parallel. + public var experimentalMaximumParallelizationWidth: Int? + /// The value of the `--symbolicate-backtraces` argument. public var symbolicateBacktraces: String? @@ -255,16 +275,40 @@ public struct __CommandLineArguments_v0: Sendable { /// whichever occurs first. public var eventStreamOutputPath: String? - /// The version of the event stream schema to use when writing events to - /// ``eventStreamOutput``. + /// The value of the `--event-stream-version` or `--experimental-event-stream-version` + /// argument, representing the version of the event stream schema to use when + /// writing events to ``eventStreamOutput``. + /// + /// This property is internal because its type is internal. External users of + /// this structure can use the ``eventStreamSchemaVersion`` property to get or + /// set the value of this property. + var eventStreamVersionNumber: VersionNumber? + + /// The value of the `--event-stream-version` or `--experimental-event-stream-version` + /// argument, representing the version of the event stream schema to use when + /// writing events to ``eventStreamOutput``. /// - /// The corresponding stable schema is used to encode events to the event - /// stream. ``ABI/Record`` is used if the value of this property is `0` or - /// higher. + /// The value of this property is a 1- or 3-component version string such as + /// `"0"` or `"1.2.3"`. The corresponding stable schema is used to encode + /// events to the event stream. ``ABI/Record`` is used if the value of this + /// property is `"0.0.0"` or higher. The testing library compares components + /// individually, so `"1.2"` is less than `"1.20"`. /// /// If the value of this property is `nil`, the testing library assumes that - /// the newest available schema should be used. - public var eventStreamVersion: Int? + /// the current supported (non-experimental) version should be used. + public var eventStreamSchemaVersion: String? { + get { + eventStreamVersionNumber.map { String(describing: $0) } + } + set { + eventStreamVersionNumber = newValue.flatMap { newValue in + guard let newValue = VersionNumber(newValue) else { + preconditionFailure("Invalid event stream version number '\(newValue)'. Specify a version number of the form 'major.minor.patch'.") + } + return newValue + } + } + } /// The value(s) of the `--filter` argument. public var filter: [String]? @@ -287,13 +331,6 @@ public struct __CommandLineArguments_v0: Sendable { /// The value of the `--attachments-path` argument. public var attachmentsPath: String? - - /// Whether or not the experimental warning issue severity feature should be - /// enabled. - /// - /// This property is intended for use in testing the testing library itself. - /// It is not parsed as a command-line argument. - var isWarningIssueRecordedEventEnabled: Bool? } extension __CommandLineArguments_v0: Codable { @@ -302,6 +339,7 @@ extension __CommandLineArguments_v0: Codable { enum CodingKeys: String, CodingKey { case listTests case parallel + case experimentalMaximumParallelizationWidth case symbolicateBacktraces case verbose case veryVerbose @@ -309,7 +347,7 @@ extension __CommandLineArguments_v0: Codable { case _verbosity = "verbosity" case xunitOutput case eventStreamOutputPath - case eventStreamVersion + case eventStreamVersionNumber = "eventStreamVersion" case filter case skip case repetitions @@ -318,6 +356,45 @@ extension __CommandLineArguments_v0: Codable { } } +extension RandomAccessCollection { + /// Get the value of the command line argument with the given name. + /// + /// - Parameters: + /// - label: The label or name of the argument, e.g. `"--attachments-path"`. + /// - index: The index where `label` should be found, or `nil` to search the + /// entire collection. + /// + /// - Returns: The value of the argument named by `label` at `index`. If no + /// value is available, or if `index` is not `nil` and the argument at + /// `index` is not named `label`, returns `nil`. + /// + /// This function handles arguments of the form `--label value` and + /// `--label=value`. Other argument syntaxes are not supported. + fileprivate func argumentValue(forLabel label: String, at index: Index? = nil) -> String? { + guard let index else { + return indices.lazy + .compactMap { argumentValue(forLabel: label, at: $0) } + .first + } + + let element = self[index] + if element == label { + let nextIndex = self.index(after: index) + if nextIndex < endIndex { + return self[nextIndex] + } + } else { + // Find an element equal to something like "--foo=bar" and split it. + let prefix = "\(label)=" + if element.hasPrefix(prefix), let equalsIndex = element.firstIndex(of: "=") { + return String(element[equalsIndex...].dropFirst()) + } + } + + return nil + } +} + /// Initialize this instance given a sequence of command-line arguments passed /// from Swift Package Manager. /// @@ -332,10 +409,6 @@ func parseCommandLineArguments(from args: [String]) throws -> __CommandLineArgum // Do not consider the executable path AKA argv[0]. let args = args.dropFirst() - func isLastArgument(at index: [String].Index) -> Bool { - args.index(after: index) >= args.endIndex - } - #if !SWT_NO_FILE_IO #if canImport(Foundation) // Configuration for the test run passed in as a JSON file (experimental) @@ -345,9 +418,7 @@ func parseCommandLineArguments(from args: [String]) throws -> __CommandLineArgum // NOTE: While the output event stream is opened later, it is necessary to // open the configuration file early (here) in order to correctly construct // the resulting __CommandLineArguments_v0 instance. - if let configurationIndex = args.firstIndex(of: "--configuration-path") ?? args.firstIndex(of: "--experimental-configuration-path"), - !isLastArgument(at: configurationIndex) { - let path = args[args.index(after: configurationIndex)] + if let path = args.argumentValue(forLabel: "--configuration-path") ?? args.argumentValue(forLabel: "--experimental-configuration-path") { let file = try FileHandle(forReadingAtPath: path) let configurationJSON = try file.readToEnd() result = try configurationJSON.withUnsafeBufferPointer { configurationJSON in @@ -360,45 +431,49 @@ func parseCommandLineArguments(from args: [String]) throws -> __CommandLineArgum } // Event stream output - if let eventOutputIndex = args.firstIndex(of: "--event-stream-output-path") ?? args.firstIndex(of: "--experimental-event-stream-output"), - !isLastArgument(at: eventOutputIndex) { - result.eventStreamOutputPath = args[args.index(after: eventOutputIndex)] + if let path = args.argumentValue(forLabel: "--event-stream-output-path") ?? args.argumentValue(forLabel: "--experimental-event-stream-output") { + result.eventStreamOutputPath = path } + // Event stream version do { - var eventOutputVersionIndex: Array.Index? + var versionString: String? var allowExperimental = false - eventOutputVersionIndex = args.firstIndex(of: "--event-stream-version") - if eventOutputVersionIndex == nil { - eventOutputVersionIndex = args.firstIndex(of: "--experimental-event-stream-version") - if eventOutputVersionIndex != nil { + versionString = args.argumentValue(forLabel: "--event-stream-version") + if versionString == nil { + versionString = args.argumentValue(forLabel: "--experimental-event-stream-version") + if versionString != nil { allowExperimental = true } } - if let eventOutputVersionIndex, !isLastArgument(at: eventOutputVersionIndex) { - result.eventStreamVersion = Int(args[args.index(after: eventOutputVersionIndex)]) + if let versionString { + // If the caller specified a version that could not be parsed, treat it as + // an invalid argument. + guard let eventStreamVersion = VersionNumber(versionString) else { + let argument = allowExperimental ? "--experimental-event-stream-version" : "--event-stream-version" + throw _EntryPointError.invalidArgument(argument, value: versionString) + } // If the caller specified an experimental ABI version, they must // explicitly use --experimental-event-stream-version, otherwise it's // treated as unsupported. - if let eventStreamVersion = result.eventStreamVersion, - eventStreamVersion > ABI.CurrentVersion.versionNumber, - !allowExperimental { + if eventStreamVersion > ABI.CurrentVersion.versionNumber, !allowExperimental { throw _EntryPointError.experimentalABIVersion(eventStreamVersion) } + + result.eventStreamVersionNumber = eventStreamVersion } } #endif // XML output - if let xunitOutputIndex = args.firstIndex(of: "--xunit-output"), !isLastArgument(at: xunitOutputIndex) { - result.xunitOutput = args[args.index(after: xunitOutputIndex)] + if let xunitOutputPath = args.argumentValue(forLabel: "--xunit-output") { + result.xunitOutput = xunitOutputPath } // Attachment output - if let attachmentsPathIndex = args.firstIndex(of: "--attachments-path") ?? args.firstIndex(of: "--experimental-attachments-path"), - !isLastArgument(at: attachmentsPathIndex) { - result.attachmentsPath = args[args.index(after: attachmentsPathIndex)] + if let attachmentsPath = args.argumentValue(forLabel: "--attachments-path") ?? args.argumentValue(forLabel: "--experimental-attachments-path") { + result.attachmentsPath = attachmentsPath } #endif @@ -414,15 +489,18 @@ func parseCommandLineArguments(from args: [String]) throws -> __CommandLineArgum if args.contains("--no-parallel") { result.parallel = false } + if let maximumParallelizationWidth = args.argumentValue(forLabel: "--experimental-maximum-parallelization-width").flatMap(Int.init) { + // TODO: decide if we want to repurpose --num-workers for this use case? + result.experimentalMaximumParallelizationWidth = maximumParallelizationWidth + } // Whether or not to symbolicate backtraces in the event stream. - if let symbolicateBacktracesIndex = args.firstIndex(of: "--symbolicate-backtraces"), !isLastArgument(at: symbolicateBacktracesIndex) { - result.symbolicateBacktraces = args[args.index(after: symbolicateBacktracesIndex)] + if let symbolicateBacktraces = args.argumentValue(forLabel: "--symbolicate-backtraces") { + result.symbolicateBacktraces = symbolicateBacktraces } // Verbosity - if let verbosityIndex = args.firstIndex(of: "--verbosity"), !isLastArgument(at: verbosityIndex), - let verbosity = Int(args[args.index(after: verbosityIndex)]) { + if let verbosity = args.argumentValue(forLabel: "--verbosity").flatMap(Int.init) { result.verbosity = verbosity } if args.contains("--verbose") || args.contains("-v") { @@ -437,9 +515,7 @@ func parseCommandLineArguments(from args: [String]) throws -> __CommandLineArgum // Filtering func filterValues(forArgumentsWithLabel label: String) -> [String] { - args.indices.lazy - .filter { args[$0] == label && $0 < args.endIndex } - .map { args[args.index(after: $0)] } + args.indices.compactMap { args.argumentValue(forLabel: label, at: $0) } } let filter = filterValues(forArgumentsWithLabel: "--filter") if !filter.isEmpty { @@ -451,11 +527,11 @@ func parseCommandLineArguments(from args: [String]) throws -> __CommandLineArgum } // Set up the iteration policy for the test run. - if let repetitionsIndex = args.firstIndex(of: "--repetitions"), !isLastArgument(at: repetitionsIndex) { - result.repetitions = Int(args[args.index(after: repetitionsIndex)]) + if let repetitions = args.argumentValue(forLabel: "--repetitions").flatMap(Int.init) { + result.repetitions = repetitions } - if let repeatUntilIndex = args.firstIndex(of: "--repeat-until"), !isLastArgument(at: repeatUntilIndex) { - result.repeatUntil = args[args.index(after: repeatUntilIndex)] + if let repeatUntil = args.argumentValue(forLabel: "--repeat-until") { + result.repeatUntil = repeatUntil } return result @@ -477,7 +553,22 @@ public func configurationForEntryPoint(from args: __CommandLineArguments_v0) thr var configuration = Configuration() // Parallelization (on by default) - configuration.isParallelizationEnabled = args.parallel ?? true + if let parallel = args.parallel, !parallel { + configuration.isParallelizationEnabled = parallel + } else { + var maximumParallelizationWidth = args.experimentalMaximumParallelizationWidth + if maximumParallelizationWidth == nil && Test.current == nil { + // Don't check the environment variable when a current test is set (which + // presumably means we're running our own unit tests). + maximumParallelizationWidth = Environment.variable(named: "SWT_EXPERIMENTAL_MAXIMUM_PARALLELIZATION_WIDTH").flatMap(Int.init) + } + if let maximumParallelizationWidth { + if maximumParallelizationWidth < 1 { + throw _EntryPointError.invalidArgument("--experimental-maximum-parallelization-width", value: String(describing: maximumParallelizationWidth)) + } + configuration.maximumParallelizationWidth = maximumParallelizationWidth + } + } // Whether or not to symbolicate backtraces in the event stream. if let symbolicateBacktraces = args.symbolicateBacktraces { @@ -518,10 +609,10 @@ public func configurationForEntryPoint(from args: __CommandLineArguments_v0) thr } #if canImport(Foundation) - // Event stream output (experimental) + // Event stream output if let eventStreamOutputPath = args.eventStreamOutputPath { let file = try FileHandle(forWritingAtPath: eventStreamOutputPath) - let eventHandler = try eventHandlerForStreamingEvents(version: args.eventStreamVersion, encodeAsJSONLines: true) { json in + let eventHandler = try eventHandlerForStreamingEvents(withVersionNumber: args.eventStreamVersionNumber, encodeAsJSONLines: true) { json in _ = try? file.withLock { try file.write(json) try file.write("\n") @@ -587,19 +678,15 @@ public func configurationForEntryPoint(from args: __CommandLineArguments_v0) thr #endif // Warning issues (experimental). - if args.isWarningIssueRecordedEventEnabled == true { - configuration.eventHandlingOptions.isWarningIssueRecordedEventEnabled = true - } else { - switch args.eventStreamVersion { - case .some(...0): - // If the event stream version was explicitly specified to a value < 1, - // disable the warning issue event to maintain legacy behavior. - configuration.eventHandlingOptions.isWarningIssueRecordedEventEnabled = false - default: - // Otherwise the requested event stream version is β‰₯ 1, so don't change - // the warning issue event setting. - break - } + switch args.eventStreamVersionNumber { + case .some(.. Void ) throws -> Event.Handler { - func eventHandler(for version: (some ABI.Version).Type) -> Event.Handler { - return version.eventHandler(encodeAsJSONLines: encodeAsJSONLines, forwardingTo: targetEventHandler) - } - - return switch versionNumber { - case nil: - eventHandler(for: ABI.CurrentVersion.self) -#if !SWT_NO_SNAPSHOT_TYPES - case ABI.Xcode16.versionNumber: - // Legacy support for Xcode 16. Support for this undocumented version will - // be removed in a future update. Do not use it. - eventHandler(for: ABI.Xcode16.self) -#endif - case ABI.v0.versionNumber: - eventHandler(for: ABI.v0.self) - case ABI.v1.versionNumber: - eventHandler(for: ABI.v1.self) - case let .some(unsupportedVersionNumber): - throw _EntryPointError.invalidArgument("--event-stream-version", value: "\(unsupportedVersionNumber)") + let versionNumber = versionNumber ?? ABI.CurrentVersion.versionNumber + guard let abi = ABI.version(forVersionNumber: versionNumber) else { + throw _EntryPointError.invalidArgument("--event-stream-version", value: "\(versionNumber)") } + return abi.eventHandler(encodeAsJSONLines: encodeAsJSONLines, forwardingTo: targetEventHandler) } #endif @@ -806,7 +878,7 @@ private enum _EntryPointError: Error { /// /// - Parameters: /// - versionNumber: The experimental ABI version number. - case experimentalABIVersion(_ versionNumber: Int) + case experimentalABIVersion(_ versionNumber: VersionNumber) } extension _EntryPointError: CustomStringConvertible { @@ -821,3 +893,17 @@ extension _EntryPointError: CustomStringConvertible { } } } + +// MARK: - Deprecated + +extension __CommandLineArguments_v0 { + @available(*, deprecated, message: "Use eventStreamSchemaVersion instead.") + public var eventStreamVersion: Int? { + get { + eventStreamVersionNumber.map(\.majorComponent).map(Int.init) + } + set { + eventStreamVersionNumber = newValue.map { VersionNumber(majorComponent: .init(clamping: $0), minorComponent: 0) } + } + } +} diff --git a/Sources/Testing/Attachments/Attachable.swift b/Sources/Testing/Attachments/Attachable.swift index be466940b..8c3476657 100644 --- a/Sources/Testing/Attachments/Attachable.swift +++ b/Sources/Testing/Attachments/Attachable.swift @@ -8,14 +8,15 @@ // See https://swift.org/CONTRIBUTORS.txt for Swift project authors // -/// A protocol describing a type that can be attached to a test report or -/// written to disk when a test is run. +private import _TestingInternals + +/// A protocol describing a type whose instances can be recorded and saved as +/// part of a test run. /// /// To attach an attachable value to a test, pass it to ``Attachment/record(_:named:sourceLocation:)``. /// To further configure an attachable value before you attach it, use it to /// initialize an instance of ``Attachment`` and set its properties before -/// passing it to ``Attachment/record(_:sourceLocation:)``. An attachable -/// value can only be attached to a test once. +/// passing it to ``Attachment/record(_:sourceLocation:)``. /// /// The testing library provides default conformances to this protocol for a /// variety of standard library types. Most user-defined types do not need to @@ -29,14 +30,15 @@ /// /// @Metadata { /// @Available(Swift, introduced: 6.2) +/// @Available(Xcode, introduced: 26.0) /// } public protocol Attachable: ~Copyable { /// An estimate of the number of bytes of memory needed to store this value as /// an attachment. /// /// The testing library uses this property to determine if an attachment - /// should be held in memory or should be immediately persisted to storage. - /// Larger attachments are more likely to be persisted, but the algorithm the + /// should be held in memory or should be immediately saved. Larger + /// attachments are more likely to be saved immediately, but the algorithm the /// testing library uses is an implementation detail and is subject to change. /// /// The value of this property is approximately equal to the number of bytes @@ -48,6 +50,7 @@ public protocol Attachable: ~Copyable { /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } var estimatedAttachmentByteCount: Int? { get } @@ -64,16 +67,16 @@ public protocol Attachable: ~Copyable { /// - Throws: Whatever is thrown by `body`, or any error that prevented the /// creation of the buffer. /// - /// The testing library uses this function when writing an attachment to a - /// test report or to a file on disk. The format of the buffer is - /// implementation-defined, but should be "idiomatic" for this type: for - /// example, if this type represents an image, it would be appropriate for - /// 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. + /// The testing library uses this function when saving an attachment. The + /// format of the buffer is implementation-defined, but should be "idiomatic" + /// for this type: for example, if this type represents an image, it would be + /// appropriate for 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) + /// @Available(Xcode, introduced: 26.0) /// } borrowing func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R @@ -88,29 +91,49 @@ public protocol Attachable: ~Copyable { /// - Returns: The preferred name for `attachment`. /// /// The testing library uses this function to determine the best name to use - /// when adding `attachment` to a test report or persisting it to storage. The - /// default implementation of this function returns `suggestedName` without - /// any changes. + /// when saving `attachment`. The default implementation of this function + /// returns `suggestedName` without any changes. /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } borrowing func preferredName(for attachment: borrowing Attachment, basedOn suggestedName: String) -> String } // MARK: - Default implementations +/// @Metadata { +/// @Available(Swift, introduced: 6.2) +/// @Available(Xcode, introduced: 26.0) +/// } extension Attachable where Self: ~Copyable { + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) + /// } public var estimatedAttachmentByteCount: Int? { nil } + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) + /// } public borrowing func preferredName(for attachment: borrowing Attachment, basedOn suggestedName: String) -> String { suggestedName } } +/// @Metadata { +/// @Available(Swift, introduced: 6.2) +/// @Available(Xcode, introduced: 26.0) +/// } extension Attachable where Self: Collection, Element == UInt8 { + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) + /// } public var estimatedAttachmentByteCount: Int? { count } @@ -122,37 +145,88 @@ extension Attachable where Self: Collection, Element == UInt8 { // (potentially expensive!) copy of the collection. } +/// @Metadata { +/// @Available(Swift, introduced: 6.2) +/// @Available(Xcode, introduced: 26.0) +/// } extension Attachable where Self: StringProtocol { + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) + /// } public var estimatedAttachmentByteCount: Int? { // NOTE: utf8.count may be O(n) for foreign strings. // SEE: https://github.com/swiftlang/swift/blob/main/stdlib/public/core/StringUTF8View.swift utf8.count } + + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) + /// } + public borrowing func preferredName(for attachment: borrowing Attachment, basedOn suggestedName: String) -> String { + if suggestedName.contains(".") { + return suggestedName + } + return "\(suggestedName).txt" + } } // MARK: - Default conformances // Implement the protocol requirements for byte arrays and buffers so that // developers can attach raw data when needed. +/// @Metadata { +/// @Available(Swift, introduced: 6.2) +/// @Available(Xcode, introduced: 26.0) +/// } extension Array: Attachable { + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) + /// } public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { try withUnsafeBytes(body) } } +/// @Metadata { +/// @Available(Swift, introduced: 6.2) +/// @Available(Xcode, introduced: 26.0) +/// } extension ContiguousArray: Attachable { + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) + /// } public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { try withUnsafeBytes(body) } } +/// @Metadata { +/// @Available(Swift, introduced: 6.2) +/// @Available(Xcode, introduced: 26.0) +/// } extension ArraySlice: Attachable { + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) + /// } public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { try withUnsafeBytes(body) } } +/// @Metadata { +/// @Available(Swift, introduced: 6.2) +/// @Available(Xcode, introduced: 26.0) +/// } extension String: Attachable { + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) + /// } public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { var selfCopy = self return try selfCopy.withUTF8 { utf8 in @@ -161,7 +235,15 @@ extension String: Attachable { } } +/// @Metadata { +/// @Available(Swift, introduced: 6.2) +/// @Available(Xcode, introduced: 26.0) +/// } extension Substring: Attachable { + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) + /// } public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { var selfCopy = self return try selfCopy.withUTF8 { utf8 in diff --git a/Sources/Testing/Attachments/AttachableWrapper.swift b/Sources/Testing/Attachments/AttachableWrapper.swift index 81df52d4d..85d7ae9dc 100644 --- a/Sources/Testing/Attachments/AttachableWrapper.swift +++ b/Sources/Testing/Attachments/AttachableWrapper.swift @@ -8,9 +8,8 @@ // See https://swift.org/CONTRIBUTORS.txt for Swift project authors // -/// A protocol describing a type that can be attached to a test report or -/// written to disk when a test is run and which contains another value that it -/// stands in for. +/// A protocol describing a type whose instances can be recorded and saved as +/// part of a test run and which contains another value that it stands in for. /// /// To attach an attachable value to a test, pass it to ``Attachment/record(_:named:sourceLocation:)``. /// To further configure an attachable value before you attach it, use it to @@ -24,12 +23,14 @@ /// /// @Metadata { /// @Available(Swift, introduced: 6.2) +/// @Available(Xcode, introduced: 26.0) /// } public protocol AttachableWrapper: Attachable, ~Copyable { /// The type of the underlying value represented by this type. /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } associatedtype Wrapped @@ -37,6 +38,7 @@ public protocol AttachableWrapper: Attachable, ~Copyable { /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } var wrappedValue: Wrapped { get } } diff --git a/Sources/Testing/Attachments/Attachment.swift b/Sources/Testing/Attachments/Attachment.swift index 366e288d1..0cbd5d703 100644 --- a/Sources/Testing/Attachments/Attachment.swift +++ b/Sources/Testing/Attachments/Attachment.swift @@ -13,29 +13,58 @@ private import _TestingInternals /// A type describing values that can be attached to the output of a test run /// and inspected later by the user. /// -/// Attachments are included in test reports in Xcode or written to disk when -/// tests are run at the command line. To create an attachment, you need a value -/// 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. +/// To create an attachment, you need a value of some type that conforms to +/// ``Attachable``. Initialize an instance of ``Attachment`` with that value +/// and, optionally, a preferred filename to use when saving the attachment. To +/// record the attachment, call ``Attachment/record(_:sourceLocation:)``. +/// Alternatively, pass your attachable value directly to ``Attachment/record(_:named:sourceLocation:)``. +/// +/// By default, the testing library saves your attachments as soon as you call +/// ``Attachment/record(_:sourceLocation:)`` or +/// ``Attachment/record(_:named:sourceLocation:)``. You can access saved +/// attachments after your tests finish running: +/// +/// - When using Xcode, you can access attachments from the test report. +/// - When using Visual Studio Code, the testing library saves attachments to +/// `.build/attachments` by default. Visual Studio Code reports the paths to +/// individual attachments in its Tests Results panel. +/// - When using Swift Package Manager's `swift test` command, you can pass the +/// `--attachments-path` option. The testing library saves attachments to the +/// specified directory. /// /// @Metadata { /// @Available(Swift, introduced: 6.2) +/// @Available(Xcode, introduced: 26.0) /// } -public struct Attachment: ~Copyable where AttachableValue: Attachable & ~Copyable { +public struct Attachment where AttachableValue: Attachable & ~Copyable { + /// A class that stores an attachment's (potentially move-only) attachable + /// value. + /// + /// We use a class to store the attachable value so that ``Attachment`` can + /// conform to `Copyable` even if `AttachableValue` doesn't. + fileprivate final class Storage { + /// Storage for ``Attachment/attachableValue-7dyjv``. + let attachableValue: AttachableValue + + init(_ attachableValue: consuming AttachableValue) { + self.attachableValue = attachableValue + } + } + /// Storage for ``attachableValue-7dyjv``. - fileprivate var _attachableValue: AttachableValue + private var _storage: Storage - /// The path to which the this attachment was written, if any. + /// The path to which the this attachment was saved, if any. /// /// If a developer sets the ``Configuration/attachmentsPath`` property of the /// current configuration before running tests, or if a developer passes /// `--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. + /// automatically saved when they are attached and the value of this property + /// will describe the paths where they were saved. A developer can use the + /// ``AttachmentSavingTrait`` trait type to defer or skip saving attachments. /// - /// If no destination path is set, or if an error occurred while writing this - /// attachment to disk, the value of this property is `nil`. + /// If no destination path is set, or if an error occurred while saving this + /// attachment, the value of this property is `nil`. @_spi(ForToolsIntegrationOnly) public var fileSystemPath: String? @@ -47,8 +76,7 @@ public struct Attachment: ~Copyable where AttachableValue: Atta /// Storage for ``preferredName``. fileprivate var _preferredName: String? - /// A filename to use when writing this attachment to a test report or to a - /// file on disk. + /// A filename to use when saving this attachment. /// /// The value of this property is used as a hint to the testing library. The /// testing library may substitute a different filename as needed. If the @@ -57,6 +85,7 @@ public struct Attachment: ~Copyable where AttachableValue: Atta /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } public var preferredName: String { let suggestedName = if let _preferredName, !_preferredName.isEmpty { @@ -78,12 +107,11 @@ public struct Attachment: ~Copyable where AttachableValue: Atta var sourceLocation: SourceLocation } -extension Attachment: Copyable where AttachableValue: Copyable {} extension Attachment: Sendable where AttachableValue: Sendable {} +extension Attachment.Storage: Sendable where AttachableValue: Sendable {} // MARK: - Initializing an attachment -#if !SWT_NO_LAZY_ATTACHMENTS extension Attachment where AttachableValue: ~Copyable { /// Initialize an instance of this type that encloses the given attachable /// value. @@ -91,40 +119,24 @@ extension Attachment where AttachableValue: ~Copyable { /// - Parameters: /// - attachableValue: The value that will be attached to the output of the /// test run. - /// - preferredName: The preferred name of the attachment when writing it to - /// a test report or to disk. If `nil`, the testing library attempts to - /// derive a reasonable filename for the attached value. + /// - preferredName: The preferred name of the attachment to use when saving + /// it. If `nil`, the testing library attempts to generate a reasonable + /// filename for the attached value. /// - 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) + /// @Available(Xcode, introduced: 26.0) /// } public init(_ attachableValue: consuming AttachableValue, named preferredName: String? = nil, sourceLocation: SourceLocation = #_sourceLocation) { - self._attachableValue = attachableValue + self._storage = Storage(attachableValue) self._preferredName = preferredName self.sourceLocation = sourceLocation } } -@_spi(ForToolsIntegrationOnly) -extension Attachment where AttachableValue == AnyAttachable { - /// Create a type-erased attachment from an instance of ``Attachment``. - /// - /// - Parameters: - /// - attachment: The attachment to type-erase. - fileprivate init(_ attachment: Attachment) { - self.init( - _attachableValue: AnyAttachable(wrappedValue: attachment.attachableValue), - fileSystemPath: attachment.fileSystemPath, - _preferredName: attachment._preferredName, - sourceLocation: attachment.sourceLocation - ) - } -} -#endif - /// A type-erased wrapper type that represents any attachable value. /// /// This type is not generally visible to developers. It is used when posting @@ -137,66 +149,62 @@ extension Attachment where AttachableValue == AnyAttachable { /// `Event.Kind.valueAttached(_:)`, otherwise it would be declared private. /// } @_spi(ForToolsIntegrationOnly) -public struct AnyAttachable: AttachableWrapper, Copyable, Sendable { -#if !SWT_NO_LAZY_ATTACHMENTS - public typealias Wrapped = any Attachable & Sendable /* & Copyable rdar://137614425 */ -#else - public typealias Wrapped = [UInt8] -#endif +public struct AnyAttachable: AttachableWrapper, Sendable, Copyable { + public struct Wrapped: Sendable {} - public var wrappedValue: Wrapped + public var wrappedValue: Wrapped { + Wrapped() + } - init(wrappedValue: Wrapped) { - self.wrappedValue = wrappedValue + init(_ attachment: Attachment) where A: Attachable & Sendable & ~Copyable { + _estimatedAttachmentByteCount = { attachment.attachableValue.estimatedAttachmentByteCount } + _withUnsafeBytes = { try attachment.withUnsafeBytes($0) } + _preferredName = { attachment.attachableValue.preferredName(for: attachment, basedOn: $0) } } + /// The implementation of ``estimatedAttachmentByteCount`` borrowed from the + /// original attachment. + private var _estimatedAttachmentByteCount: @Sendable () -> Int? + public var estimatedAttachmentByteCount: Int? { - wrappedValue.estimatedAttachmentByteCount + _estimatedAttachmentByteCount() } + /// The implementation of ``withUnsafeBytes(for:_:)`` borrowed from the + /// original attachment. + private var _withUnsafeBytes: @Sendable ((UnsafeRawBufferPointer) throws -> Void) throws -> Void + public func withUnsafeBytes(for attachment: borrowing Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { - func open(_ wrappedValue: T, for attachment: borrowing Attachment) throws -> R where T: Attachable & Sendable & Copyable { - let temporaryAttachment = Attachment( - _attachableValue: wrappedValue, - fileSystemPath: attachment.fileSystemPath, - _preferredName: attachment._preferredName, - sourceLocation: attachment.sourceLocation - ) - return try temporaryAttachment.withUnsafeBytes(body) + var result: R! + try _withUnsafeBytes { bytes in + result = try body(bytes) } - return try open(wrappedValue, for: attachment) + return result } + /// The implementation of ``preferredName(for:basedOn:)`` borrowed from the + /// original attachment. + private var _preferredName: @Sendable (String) -> String + public borrowing func preferredName(for attachment: borrowing Attachment, basedOn suggestedName: String) -> String { - func open(_ wrappedValue: T, for attachment: borrowing Attachment) -> String where T: Attachable & Sendable & Copyable { - let temporaryAttachment = Attachment( - _attachableValue: wrappedValue, - fileSystemPath: attachment.fileSystemPath, - _preferredName: attachment._preferredName, - sourceLocation: attachment.sourceLocation - ) - return temporaryAttachment.preferredName - } - return open(wrappedValue, for: attachment) + _preferredName(suggestedName) } } // 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)'"# - } -} - -extension Attachment: CustomStringConvertible { +@_preInverseGenerics +extension Attachment: CustomStringConvertible where AttachableValue: ~Copyable { /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } public var description: String { - #""\#(preferredName)": \#(String(describingForTest: attachableValue))"# + if #available(_castingWithNonCopyableGenerics, *), let attachableValue = boxCopyableValue(attachableValue) { + return #""\#(preferredName)": \#(String(describingForTest: attachableValue))"# + } + let typeInfo = TypeInfo(describing: AttachableValue.self) + return #""\#(preferredName)": instance of '\#(typeInfo.unqualifiedName)'"# } } @@ -207,10 +215,11 @@ extension Attachment where AttachableValue: ~Copyable { /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } @_disfavoredOverload public var attachableValue: AttachableValue { _read { - yield _attachableValue + yield _storage.attachableValue } } } @@ -229,6 +238,7 @@ extension Attachment where AttachableValue: AttachableWrapper & ~Copyable { /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } public var attachableValue: AttachableValue.Wrapped { _read { @@ -239,31 +249,33 @@ extension Attachment where AttachableValue: AttachableWrapper & ~Copyable { // MARK: - Attaching an attachment to a test (etc.) -#if !SWT_NO_LAZY_ATTACHMENTS -extension Attachment where AttachableValue: Sendable & Copyable { +extension Attachment where AttachableValue: Sendable & ~Copyable { /// Attach an attachment to the current test. /// /// - Parameters: /// - attachment: The attachment to attach. /// - sourceLocation: The source location of the call to this function. /// - /// When attaching a value of a type that does not conform to both - /// [`Sendable`](https://developer.apple.com/documentation/swift/sendable) and - /// [`Copyable`](https://developer.apple.com/documentation/swift/copyable), - /// the testing library encodes it as data immediately. If the value cannot be - /// encoded and an error is thrown, that error is recorded as an issue in the - /// current test and the attachment is not written to the test report or to - /// disk. - /// - /// An attachment can only be attached once. + /// When `attachableValue` is an instance of a type that does not conform to + /// the [`Sendable`](https://developer.apple.com/documentation/swift/sendable) + /// protocol, the testing library calls its ``Attachable/withUnsafeBytes(for:_:)`` + /// immediately and records a copy of the resulting buffer instead. If + /// `attachableValue` throws an error when the testing library calls its + /// ``Attachable/withUnsafeBytes(for:_:)`` function, the testing library + /// records that error as an issue in the current test. /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } @_documentation(visibility: private) public static func record(_ attachment: consuming Self, sourceLocation: SourceLocation = #_sourceLocation) { - var attachmentCopy = Attachment(attachment) - attachmentCopy.sourceLocation = sourceLocation + var attachmentCopy = Attachment( + AnyAttachable(copy attachment), + named: attachment._preferredName, + sourceLocation: sourceLocation + ) + attachmentCopy.fileSystemPath = attachment.fileSystemPath Event.post(.valueAttached(attachmentCopy)) } @@ -271,33 +283,31 @@ extension Attachment where AttachableValue: Sendable & Copyable { /// /// - Parameters: /// - attachableValue: The value to attach. - /// - preferredName: The preferred name of the attachment when writing it to - /// a test report or to disk. If `nil`, the testing library attempts to - /// derive a reasonable filename for the attached value. + /// - preferredName: The preferred name of the attachment to use when saving + /// it. If `nil`, the testing library attempts to generate a reasonable + /// filename for the attached value. /// - sourceLocation: The source location of the call to this function. /// - /// When attaching a value of a type that does not conform to both - /// [`Sendable`](https://developer.apple.com/documentation/swift/sendable) and - /// [`Copyable`](https://developer.apple.com/documentation/swift/copyable), - /// the testing library encodes it as data immediately. If the value cannot be - /// encoded and an error is thrown, that error is recorded as an issue in the - /// current test and the attachment is not written to the test report or to - /// disk. + /// When `attachableValue` is an instance of a type that does not conform to + /// the [`Sendable`](https://developer.apple.com/documentation/swift/sendable) + /// protocol, the testing library calls its ``Attachable/withUnsafeBytes(for:_:)`` + /// immediately and records a copy of the resulting buffer instead. If + /// `attachableValue` throws an error when the testing library calls its + /// ``Attachable/withUnsafeBytes(for:_:)`` function, the testing library + /// records that error as an issue in the current test. /// /// This function creates a new instance of ``Attachment`` and immediately /// attaches it to the current test. /// - /// An attachment can only be attached once. - /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } @_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) } } -#endif extension Attachment where AttachableValue: ~Copyable { /// Attach an attachment to the current test. @@ -306,31 +316,22 @@ extension Attachment where AttachableValue: ~Copyable { /// - attachment: The attachment to attach. /// - sourceLocation: The source location of the call to this function. /// - /// When attaching a value of a type that does not conform to both - /// [`Sendable`](https://developer.apple.com/documentation/swift/sendable) and - /// [`Copyable`](https://developer.apple.com/documentation/swift/copyable), - /// the testing library encodes it as data immediately. If the value cannot be - /// encoded and an error is thrown, that error is recorded as an issue in the - /// current test and the attachment is not written to the test report or to - /// disk. - /// - /// An attachment can only be attached once. + /// When `attachableValue` is an instance of a type that does not conform to + /// the [`Sendable`](https://developer.apple.com/documentation/swift/sendable) + /// protocol, the testing library calls its ``Attachable/withUnsafeBytes(for:_:)`` + /// immediately and records a copy of the resulting buffer instead. If + /// `attachableValue` throws an error when the testing library calls its + /// ``Attachable/withUnsafeBytes(for:_:)`` function, the testing library + /// records that error as an issue in the current test. /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } public static func record(_ attachment: consuming Self, sourceLocation: SourceLocation = #_sourceLocation) { do { - let attachmentCopy = try attachment.withUnsafeBytes { buffer in - let attachableWrapper = AnyAttachable(wrappedValue: Array(buffer)) - return Attachment( - _attachableValue: attachableWrapper, - fileSystemPath: attachment.fileSystemPath, - _preferredName: attachment.preferredName, // invokes preferredName(for:basedOn:) - sourceLocation: sourceLocation - ) - } - Event.post(.valueAttached(attachmentCopy)) + let bufferCopy = try attachment.withUnsafeBytes { Array($0) } + Attachment.record(bufferCopy, sourceLocation: sourceLocation) } catch { let sourceContext = SourceContext(backtrace: .current(), sourceLocation: sourceLocation) Issue(kind: .valueAttachmentFailed(error), comments: [], sourceContext: sourceContext).record() @@ -341,26 +342,25 @@ extension Attachment where AttachableValue: ~Copyable { /// /// - Parameters: /// - attachableValue: The value to attach. - /// - preferredName: The preferred name of the attachment when writing it to - /// a test report or to disk. If `nil`, the testing library attempts to - /// derive a reasonable filename for the attached value. + /// - preferredName: The preferred name of the attachment to use when saving + /// it. If `nil`, the testing library attempts to generate a reasonable + /// filename for the attached value. /// - sourceLocation: The source location of the call to this function. /// - /// When attaching a value of a type that does not conform to both - /// [`Sendable`](https://developer.apple.com/documentation/swift/sendable) and - /// [`Copyable`](https://developer.apple.com/documentation/swift/copyable), - /// the testing library encodes it as data immediately. If the value cannot be - /// encoded and an error is thrown, that error is recorded as an issue in the - /// current test and the attachment is not written to the test report or to - /// disk. + /// When `attachableValue` is an instance of a type that does not conform to + /// the [`Sendable`](https://developer.apple.com/documentation/swift/sendable) + /// protocol, the testing library calls its ``Attachable/withUnsafeBytes(for:_:)`` + /// immediately and records a copy of the resulting buffer instead. If + /// `attachableValue` throws an error when the testing library calls its + /// ``Attachable/withUnsafeBytes(for:_:)`` function, the testing library + /// records that error as an issue in the current test. /// /// This function creates a new instance of ``Attachment`` and immediately /// attaches it to the current test. /// - /// An attachment can only be attached once. - /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } public static func record(_ attachableValue: consuming AttachableValue, named preferredName: String? = nil, sourceLocation: SourceLocation = #_sourceLocation) { record(Self(attachableValue, named: preferredName, sourceLocation: sourceLocation), sourceLocation: sourceLocation) @@ -382,13 +382,13 @@ extension Attachment where AttachableValue: ~Copyable { /// - Throws: Whatever is thrown by `body`, or any error that prevented the /// creation of the buffer. /// - /// The testing library uses this function when writing an attachment to a - /// test report or to a file on disk. This function calls the - /// ``Attachable/withUnsafeBytes(for:_:)`` function on this attachment's - /// ``attachableValue-2tnj5`` property. + /// The testing library uses this function when saving an attachment. This + /// function calls the ``Attachable/withUnsafeBytes(for:_:)`` function on this + /// attachment's ``attachableValue-2tnj5`` property. /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } @inlinable public borrowing func withUnsafeBytes(_ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { try attachableValue.withUnsafeBytes(for: self, body) @@ -413,16 +413,16 @@ extension Attachment where AttachableValue: ~Copyable { /// is derived from the value of the ``Attachment/preferredName`` property. /// /// If you pass `--attachments-path` to `swift test`, the testing library - /// automatically uses this function to persist attachments to the directory - /// you specify. + /// automatically uses this function to save 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 /// value of this property if needed. /// - /// 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. + /// This function is provided as a convenience to allow tools authors to save + /// attachments the same way that Swift Package Manager does. You are not + /// required to use this function. @_spi(ForToolsIntegrationOnly) public borrowing func write(toFileInDirectoryAtPath directoryPath: String) throws -> String { try write( @@ -462,7 +462,7 @@ extension Attachment where AttachableValue: ~Copyable { // file exists at this path (note "x" in the mode string), an error will // be thrown and we'll try again by adding a suffix. let preferredPath = appendPathComponent(preferredName, to: directoryPath) - file = try FileHandle(atPath: preferredPath, mode: "wxb") + file = try FileHandle(atPath: preferredPath, mode: "wxeb") result = preferredPath } catch { // Split the extension(s) off the preferred name. The first component in @@ -478,7 +478,7 @@ extension Attachment where AttachableValue: ~Copyable { // Propagate any error *except* EEXIST, which would indicate that the // name was already in use (so we should try again with a new suffix.) do { - file = try FileHandle(atPath: preferredPath, mode: "wxb") + file = try FileHandle(atPath: preferredPath, mode: "wxeb") result = preferredPath break } catch let error as CError where error.rawValue == swt_EEXIST() { @@ -501,6 +501,24 @@ extension Attachment where AttachableValue: ~Copyable { } } +extension Runner { + /// Modify this runner's configured event handler so that it handles "value + /// attached" events and saves attachments where necessary. + mutating func configureAttachmentHandling() { + configuration.eventHandler = { [oldEventHandler = configuration.eventHandler] event, context in + var event = copy event + if case .valueAttached = event.kind { + guard let configuration = context.configuration, + configuration.handleValueAttachedEvent(&event, in: context) else { + // The attachment could not be handled, so suppress this event. + return + } + } + oldEventHandler(event, context) + } + } +} + extension Configuration { /// Handle the given "value attached" event. /// @@ -514,10 +532,10 @@ extension Configuration { /// - Returns: Whether or not to continue handling the event. /// /// This function is called automatically by ``handleEvent(_:in:)``. You do - /// not need to call it elsewhere. It automatically persists the attachment + /// not need to call it elsewhere. It automatically saves the attachment /// associated with `event` and modifies `event` to include the path where the - /// attachment was stored. - func handleValueAttachedEvent(_ event: inout Event, in eventContext: borrowing Event.Context) -> Bool { + /// attachment was saved. + fileprivate func handleValueAttachedEvent(_ event: inout Event, in eventContext: borrowing Event.Context) -> Bool { guard let attachmentsPath else { // If there is no path to which attachments should be written, there's // nothing to do here. The event handler may still want to handle it. @@ -528,9 +546,9 @@ extension Configuration { preconditionFailure("Passed the wrong kind of event to \(#function) (expected valueAttached, got \(event.kind)). Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new") } if attachment.fileSystemPath != nil { - // Somebody already persisted this attachment. This isn't necessarily a - // logic error in the testing library, but it probably means we shouldn't - // persist it again. Suppress the event. + // Somebody already saved this attachment. This isn't necessarily a logic + // error in the testing library, but it probably means we shouldn't save + // it again. Suppress the event. return false } diff --git a/Sources/Testing/Attachments/Images/AttachableAsImage.swift b/Sources/Testing/Attachments/Images/AttachableAsImage.swift new file mode 100644 index 000000000..a0683561c --- /dev/null +++ b/Sources/Testing/Attachments/Images/AttachableAsImage.swift @@ -0,0 +1,133 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024–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 +// + +#if SWT_TARGET_OS_APPLE || os(Windows) +// These platforms support image attachments. +#elseif !SWT_NO_IMAGE_ATTACHMENTS +#error("Platform-specific misconfiguration: support for image attachments requires a platform-specific implementation") +#endif + +/// ## Why can't images directly conform to Attachable? +/// +/// Three reasons: +/// +/// 1. Several image classes are not marked `Sendable`, which means that as far +/// as Swift is concerned, they cannot be safely passed to Swift Testing's +/// event handler (primarily because `Event` is `Sendable`.) So we would have +/// to eagerly serialize them, which is unnecessarily expensive if we know +/// they're actually concurrency-safe. +/// 2. We would have no place to store metadata such as the encoding quality +/// (although in the future we may introduce a "metadata" associated type to +/// `Attachable` that could store that info.) +/// 3. `Attachable` has a requirement with `Self` in non-parameter, non-return +/// position. As far as Swift is concerned, a non-final class cannot satisfy +/// such a requirement, and all image types we care about are non-final +/// classes. Thus, the compiler will steadfastly refuse to allow non-final +/// classes to conform to the `Attachable` protocol. We could get around this +/// by changing the signature of `withUnsafeBytes()` so that the +/// generic parameter to `Attachment` is not `Self`, but that would defeat +/// much of the purpose of making `Attachment` generic in the first place. +/// (And no, the language does not let us write `where T: Self` anywhere +/// useful.) + +/// A protocol describing images that can be converted to instances of +/// [`Attachment`](https://developer.apple.com/documentation/testing/attachment). +/// +/// Instances of types conforming to this protocol do not themselves conform to +/// [`Attachable`](https://developer.apple.com/documentation/testing/attachable). +/// Instead, the testing library provides additional initializers on [`Attachment`](https://developer.apple.com/documentation/testing/attachment) +/// that take instances of such types and handle converting them to image data when needed. +/// +/// You can attach instances of the following system-provided image types to a +/// test: +/// +/// | Platform | Supported Types | +/// |-|-| +/// | macOS | [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage), [`CIImage`](https://developer.apple.com/documentation/coreimage/ciimage), [`NSImage`](https://developer.apple.com/documentation/appkit/nsimage) | +/// | iOS, watchOS, tvOS, and visionOS | [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage), [`CIImage`](https://developer.apple.com/documentation/coreimage/ciimage), [`UIImage`](https://developer.apple.com/documentation/uikit/uiimage) | +/// | Windows | [`HBITMAP`](https://learn.microsoft.com/en-us/windows/win32/gdi/bitmaps), [`HICON`](https://learn.microsoft.com/en-us/windows/win32/menurc/icons), [`IWICBitmapSource`](https://learn.microsoft.com/en-us/windows/win32/api/wincodec/nn-wincodec-iwicbitmapsource) (including its subclasses declared by Windows Imaging Component) | +/// +/// You do not generally need to add your own conformances to this protocol. If +/// you have an image in another format that needs to be attached to a test, +/// first convert it to an instance of one of the types above. +/// +/// @Metadata { +/// @Available(Swift, introduced: 6.3) +/// } +#if SWT_NO_IMAGE_ATTACHMENTS +@available(*, unavailable, message: "Image attachments are not available on this platform.") +#endif +@available(_uttypesAPI, *) +public protocol AttachableAsImage { + /// Encode a representation of this image in a given image format. + /// + /// - Parameters: + /// - imageFormat: The image format to use when encoding this image. + /// - body: A function to call. A temporary buffer containing a data + /// representation of this instance is passed to it. + /// + /// - Returns: Whatever is returned by `body`. + /// + /// - Throws: Whatever is thrown by `body`, or any error that prevented the + /// creation of the buffer. + /// + /// The testing library uses this function when saving an image as an + /// attachment. The implementation should use `imageFormat` to determine what + /// encoder to use. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } + borrowing func withUnsafeBytes(as imageFormat: AttachableImageFormat, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R + + /// Make a copy of this instance to pass to an attachment. + /// + /// - Returns: A copy of `self`, or `self` if no copy is needed. + /// + /// The testing library uses this function to take ownership of image + /// resources that test authors pass to it. If possible, make a copy of or add + /// a reference to `self`. If this type does not support making copies, return + /// `self` verbatim. + /// + /// The default implementation of this function when `Self` conforms to + /// `Sendable` simply returns `self`. + /// + /// This function is not part of the public interface of the testing library. + /// It may be removed in a future update. + func _copyAttachableValue() -> Self + + /// Manually deinitialize any resources associated with this image. + /// + /// The implementation of this function cleans up any resources (such as + /// handles or COM objects) associated with this image. The testing library + /// automatically invokes this function as needed. + /// + /// This function is not responsible for releasing the image returned from + /// `_copyAttachableIWICBitmapSource(using:)`. + /// + /// The default implementation of this function when `Self` conforms to + /// `Sendable` does nothing. + /// + /// This function is not part of the public interface of the testing library. + /// It may be removed in a future update. + func _deinitializeAttachableValue() +} + +#if SWT_NO_IMAGE_ATTACHMENTS +@available(*, unavailable, message: "Image attachments are not available on this platform.") +#endif +@available(_uttypesAPI, *) +extension AttachableAsImage { + public func _copyAttachableValue() -> Self { + self + } + + public func _deinitializeAttachableValue() {} +} diff --git a/Sources/Testing/Attachments/Images/AttachableImageFormat.swift b/Sources/Testing/Attachments/Images/AttachableImageFormat.swift new file mode 100644 index 000000000..9bbc99c75 --- /dev/null +++ b/Sources/Testing/Attachments/Images/AttachableImageFormat.swift @@ -0,0 +1,201 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024–2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +/// A type describing image formats supported by the system that can be used +/// when attaching an image to a test. +/// +/// When you attach an image to a test, you can pass an instance of this type to +/// ``Attachment/record(_:named:as:sourceLocation:)`` so that the testing +/// library knows the image format you'd like to use. If you don't pass an +/// instance of this type, the testing library infers which format to use based +/// on the attachment's preferred name. +/// +/// The PNG and JPEG image formats are always supported. The set of additional +/// supported image formats is platform-specific: +/// +/// - On Apple platforms, you can use [`CGImageDestinationCopyTypeIdentifiers()`](https://developer.apple.com/documentation/imageio/cgimagedestinationcopytypeidentifiers()) +/// from the [Image I/O framework](https://developer.apple.com/documentation/imageio) +/// to determine which formats are supported. +/// - On Windows, you can use [`IWICImagingFactory.CreateComponentEnumerator()`](https://learn.microsoft.com/en-us/windows/win32/api/wincodec/nf-wincodec-iwicimagingfactory-createcomponentenumerator) +/// to enumerate the available image encoders. +/// +/// @Metadata { +/// @Available(Swift, introduced: 6.3) +/// } +#if SWT_NO_IMAGE_ATTACHMENTS +@_unavailableInEmbedded +@available(*, unavailable, message: "Image attachments are not available on this platform.") +#endif +@available(_uttypesAPI, *) +public struct AttachableImageFormat: Sendable { + /// An enumeration describing the various kinds of image format that can be + /// used with an attachment. + package enum Kind: Sendable { + /// The (widely-supported) PNG image format. + case png + + /// The (widely-supported) JPEG image format. + case jpeg + + /// A platform-specific image format. + /// + /// - Parameters: + /// - value: A platform-specific value representing the image format to + /// use. The platform-specific cross-import overlay or package is + /// responsible for exposing appropriate interfaces for this case. + /// + /// On Apple platforms, `value` should be an instance of `UTType`. On + /// Windows, it should be an instance of `CLSID`. + case systemValue(_ value: any Sendable & Equatable & Hashable) + } + + /// The kind of image format represented by this instance. + package var kind: Kind + + /// The encoding quality to use for this image format. + /// + /// The meaning of the value is format-specific with `0.0` being the lowest + /// supported encoding quality and `1.0` being the highest supported encoding + /// quality. The value of this property is ignored for image formats that do + /// not support variable encoding quality. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } + public private(set) var encodingQuality: Float = 1.0 + + package init(kind: Kind, encodingQuality: Float) { + self.kind = kind + self.encodingQuality = min(max(0.0, encodingQuality), 1.0) + } +} + +// MARK: - Equatable, Hashable + +/// @Metadata { +/// @Available(Swift, introduced: 6.3) +/// } +#if SWT_NO_IMAGE_ATTACHMENTS +@_unavailableInEmbedded +@available(*, unavailable, message: "Image attachments are not available on this platform.") +#endif +@available(_uttypesAPI, *) +extension AttachableImageFormat: Equatable, Hashable {} + +#if SWT_NO_IMAGE_ATTACHMENTS +@_unavailableInEmbedded +@available(*, unavailable, message: "Image attachments are not available on this platform.") +#endif +@available(_uttypesAPI, *) +extension AttachableImageFormat.Kind: Equatable, Hashable { + public static func ==(lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.png, .png), (.jpeg, .jpeg): + return true + case let (.systemValue(lhs), .systemValue(rhs)): + func open(_ lhs: T) -> Bool where T: Equatable { + lhs == (rhs as? T) + } + return open(lhs) + default: + return false + } + } + + public func hash(into hasher: inout Hasher) { + switch self { + case .png: + hasher.combine("png") + case .jpeg: + hasher.combine("jpeg") + case let .systemValue(systemValue): + hasher.combine(systemValue) + } + } +} + +// MARK: - CustomStringConvertible, CustomDebugStringConvertible + +/// @Metadata { +/// @Available(Swift, introduced: 6.3) +/// } +#if SWT_NO_IMAGE_ATTACHMENTS +@_unavailableInEmbedded +@available(*, unavailable, message: "Image attachments are not available on this platform.") +#endif +@available(_uttypesAPI, *) +extension AttachableImageFormat: CustomStringConvertible, CustomDebugStringConvertible { + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } + public var description: String { + let kindDescription = String(describing: kind) + if encodingQuality < 1.0 { + return "\(kindDescription) at \(Int(encodingQuality * 100.0))% quality" + } + return kindDescription + } + + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } + public var debugDescription: String { + let kindDescription = String(reflecting: kind) + return "\(kindDescription) at quality \(encodingQuality)" + } +} + +// MARK: - + +/// @Metadata { +/// @Available(Swift, introduced: 6.3) +/// } +#if SWT_NO_IMAGE_ATTACHMENTS +@_unavailableInEmbedded +@available(*, unavailable, message: "Image attachments are not available on this platform.") +#endif +@available(_uttypesAPI, *) +extension AttachableImageFormat { + /// The PNG image format. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } + public static var png: Self { + Self(kind: .png, encodingQuality: 1.0) + } + + /// The JPEG image format with maximum encoding quality. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } + public static var jpeg: Self { + Self(kind: .jpeg, encodingQuality: 1.0) + } + + /// The JPEG image format. + /// + /// - Parameters: + /// - encodingQuality: The encoding quality to use when serializing an + /// image. A value of `0.0` indicates the lowest supported encoding + /// quality and a value of `1.0` indicates the highest supported encoding + /// quality. + /// + /// - Returns: An instance of this type representing the JPEG image format + /// with the specified encoding quality. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } + public static func jpeg(withEncodingQuality encodingQuality: Float) -> Self { + Self(kind: .jpeg, encodingQuality: encodingQuality) + } +} diff --git a/Sources/Testing/Attachments/Images/Attachment+AttachableAsImage.swift b/Sources/Testing/Attachments/Images/Attachment+AttachableAsImage.swift new file mode 100644 index 000000000..3612596c5 --- /dev/null +++ b/Sources/Testing/Attachments/Images/Attachment+AttachableAsImage.swift @@ -0,0 +1,121 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024–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 +// + +/// @Metadata { +/// @Available(Swift, introduced: 6.3) +/// } +#if SWT_NO_IMAGE_ATTACHMENTS +@_unavailableInEmbedded +@available(*, unavailable, message: "Image attachments are not available on this platform.") +#endif +@available(_uttypesAPI, *) +extension Attachment { + /// Initialize an instance of this type that encloses the given image. + /// + /// - Parameters: + /// - image: The value that will be attached to the output of the test run. + /// - preferredName: The preferred name of the attachment when writing it + /// to a test report or to disk. If `nil`, the testing library attempts + /// to derive a reasonable filename for the attached value. + /// - imageFormat: The image format with which to encode `image`. + /// - sourceLocation: The source location of the call to this initializer. + /// This value is used when recording issues associated with the + /// attachment. + /// + /// You can attach instances of the following system-provided image types to a + /// test: + /// + /// | Platform | Supported Types | + /// |-|-| + /// | macOS | [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage), [`CIImage`](https://developer.apple.com/documentation/coreimage/ciimage), [`NSImage`](https://developer.apple.com/documentation/appkit/nsimage) | + /// | iOS, watchOS, tvOS, and visionOS | [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage), [`CIImage`](https://developer.apple.com/documentation/coreimage/ciimage), [`UIImage`](https://developer.apple.com/documentation/uikit/uiimage) | + /// | Windows | [`HBITMAP`](https://learn.microsoft.com/en-us/windows/win32/gdi/bitmaps), [`HICON`](https://learn.microsoft.com/en-us/windows/win32/menurc/icons), [`IWICBitmapSource`](https://learn.microsoft.com/en-us/windows/win32/api/wincodec/nn-wincodec-iwicbitmapsource) (including its subclasses declared by Windows Imaging Component) | + /// + /// The testing library uses the image format specified by `imageFormat`. Pass + /// `nil` to let the testing library decide which image format to use. If you + /// pass `nil`, then the image format that the testing library uses depends on + /// the path extension you specify in `preferredName`, if any. If you do not + /// specify a path extension, or if the path extension you specify doesn't + /// correspond to an image format the operating system knows how to write, the + /// testing library selects an appropriate image format for you. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } + public init( + _ image: T, + named preferredName: String? = nil, + as imageFormat: AttachableImageFormat? = nil, + sourceLocation: SourceLocation = #_sourceLocation + ) where AttachableValue: _AttachableImageWrapper & AttachableWrapper { + let imageWrapper = AttachableValue(image: image, imageFormat: imageFormat) + self.init(imageWrapper, named: preferredName, sourceLocation: sourceLocation) + } + + /// Attach an image to the current test. + /// + /// - Parameters: + /// - image: The value to attach. + /// - preferredName: The preferred name of the attachment when writing it to + /// a test report or to disk. If `nil`, the testing library attempts to + /// derive a reasonable filename for the attached value. + /// - imageFormat: The image format with which to encode `image`. + /// - sourceLocation: The source location of the call to this function. + /// + /// This function creates a new instance of ``Attachment`` wrapping `image` + /// and immediately attaches it to the current test. You can attach instances + /// of the following system-provided image types to a test: + /// + /// | Platform | Supported Types | + /// |-|-| + /// | macOS | [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage), [`CIImage`](https://developer.apple.com/documentation/coreimage/ciimage), [`NSImage`](https://developer.apple.com/documentation/appkit/nsimage) | + /// | iOS, watchOS, tvOS, and visionOS | [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage), [`CIImage`](https://developer.apple.com/documentation/coreimage/ciimage), [`UIImage`](https://developer.apple.com/documentation/uikit/uiimage) | + /// | Windows | [`HBITMAP`](https://learn.microsoft.com/en-us/windows/win32/gdi/bitmaps), [`HICON`](https://learn.microsoft.com/en-us/windows/win32/menurc/icons), [`IWICBitmapSource`](https://learn.microsoft.com/en-us/windows/win32/api/wincodec/nn-wincodec-iwicbitmapsource) (including its subclasses declared by Windows Imaging Component) | + /// + /// The testing library uses the image format specified by `imageFormat`. Pass + /// `nil` to let the testing library decide which image format to use. If you + /// pass `nil`, then the image format that the testing library uses depends on + /// the path extension you specify in `preferredName`, if any. If you do not + /// specify a path extension, or if the path extension you specify doesn't + /// correspond to an image format the operating system knows how to write, the + /// testing library selects an appropriate image format for you. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } + public static func record( + _ image: T, + named preferredName: String? = nil, + as imageFormat: AttachableImageFormat? = nil, + sourceLocation: SourceLocation = #_sourceLocation + ) where AttachableValue: _AttachableImageWrapper & AttachableWrapper { + let attachment = Self(image, named: preferredName, as: imageFormat, sourceLocation: sourceLocation) + Self.record(attachment, sourceLocation: sourceLocation) + } +} + +// MARK: - + +#if SWT_NO_IMAGE_ATTACHMENTS +@_unavailableInEmbedded +@available(*, unavailable, message: "Image attachments are not available on this platform.") +#endif +@available(_uttypesAPI, *) +extension Attachment where AttachableValue: AttachableWrapper, AttachableValue.Wrapped: AttachableAsImage { + /// The image format to use when encoding the represented image, if specified. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } + @_disfavoredOverload public var imageFormat: AttachableImageFormat? { + // FIXME: no way to express `where AttachableValue == _AttachableImageWrapper` on a property (see rdar://47559973) + (attachableValue as? _AttachableImageWrapper)?.imageFormat + } +} diff --git a/Sources/Testing/Attachments/Images/ImageAttachmentError.swift b/Sources/Testing/Attachments/Images/ImageAttachmentError.swift new file mode 100644 index 000000000..b1ad5a347 --- /dev/null +++ b/Sources/Testing/Attachments/Images/ImageAttachmentError.swift @@ -0,0 +1,83 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024–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 +// + +private import _TestingInternals + +/// A type representing an error that can occur when attaching an image. +#if SWT_NO_IMAGE_ATTACHMENTS +@_unavailableInEmbedded +@available(*, unavailable, message: "Image attachments are not available on this platform.") +#endif +package enum ImageAttachmentError: Error { +#if SWT_TARGET_OS_APPLE + /// The image could not be converted to an instance of `CGImage`. + case couldNotCreateCGImage + + /// The image destination could not be created. + case couldNotCreateImageDestination + + /// The image could not be converted. + case couldNotConvertImage + + /// The specified content type is not supported by Image I/O. + case unsupportedImageFormat(_ typeIdentifier: String) +#elseif os(Windows) + /// A call to `QueryInterface()` failed. + case queryInterfaceFailed(Any.Type, CLong) + + /// The testing library failed to create a COM object. + case comObjectCreationFailed(Any.Type, CLong) + + /// An image could not be written. + case imageWritingFailed(CLong) + + /// The testing library failed to get an in-memory stream's underlying buffer. + case globalFromStreamFailed(CLong) + + /// A property could not be written to a property bag. + case propertyBagWritingFailed(String, CLong) +#endif +} + +#if SWT_NO_IMAGE_ATTACHMENTS +@_unavailableInEmbedded +@available(*, unavailable, message: "Image attachments are not available on this platform.") +#endif +extension ImageAttachmentError: CustomStringConvertible { + package var description: String { +#if SWT_TARGET_OS_APPLE + switch self { + case .couldNotCreateCGImage: + "Could not create the corresponding Core Graphics image." + case .couldNotCreateImageDestination: + "Could not create the Core Graphics image destination to encode this image." + case .couldNotConvertImage: + "Could not convert the image to the specified format." + case let .unsupportedImageFormat(typeIdentifier): + "Could not convert the image to the format '\(typeIdentifier)' because the system does not support it." + } +#elseif os(Windows) + switch self { + case let .queryInterfaceFailed(type, result): + "Could not cast a COM object to type '\(type)' (HRESULT \(result))." + case let .comObjectCreationFailed(type, result): + "Could not create a COM object of type '\(type)' (HRESULT \(result))." + case let .imageWritingFailed(result): + "Could not write the image (HRESULT \(result))." + case let .globalFromStreamFailed(result): + "Could not access the buffer containing the encoded image (HRESULT \(result))." + case let .propertyBagWritingFailed(name, result): + "Could not set the property '\(name)' (HRESULT \(result))." + } +#else + swt_unreachable() +#endif + } +} diff --git a/Sources/Testing/Attachments/Images/_AttachableImageWrapper.swift b/Sources/Testing/Attachments/Images/_AttachableImageWrapper.swift new file mode 100644 index 000000000..7065c03f7 --- /dev/null +++ b/Sources/Testing/Attachments/Images/_AttachableImageWrapper.swift @@ -0,0 +1,41 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +/// A wrapper type for images that can be indirectly attached to a test. +/// +/// You can attach instances of the following system-provided image types to a +/// test: +/// +/// | Platform | Supported Types | +/// |-|-| +/// | macOS | [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage), [`CIImage`](https://developer.apple.com/documentation/coreimage/ciimage), [`NSImage`](https://developer.apple.com/documentation/appkit/nsimage) | +/// | iOS, watchOS, tvOS, and visionOS | [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage), [`CIImage`](https://developer.apple.com/documentation/coreimage/ciimage), [`UIImage`](https://developer.apple.com/documentation/uikit/uiimage) | +/// | Windows | [`HBITMAP`](https://learn.microsoft.com/en-us/windows/win32/gdi/bitmaps), [`HICON`](https://learn.microsoft.com/en-us/windows/win32/menurc/icons), [`IWICBitmapSource`](https://learn.microsoft.com/en-us/windows/win32/api/wincodec/nn-wincodec-iwicbitmapsource) (including its subclasses declared by Windows Imaging Component) | +#if SWT_NO_IMAGE_ATTACHMENTS +@_unavailableInEmbedded +@available(*, unavailable, message: "Image attachments are not available on this platform.") +#endif +@available(_uttypesAPI, *) +public final class _AttachableImageWrapper: Sendable where Image: AttachableAsImage { + /// The underlying image. + public nonisolated(unsafe) let wrappedValue: Image + + /// The image format to use when encoding the represented image. + package let imageFormat: AttachableImageFormat? + + init(image: Image, imageFormat: AttachableImageFormat?) { + self.wrappedValue = image._copyAttachableValue() + self.imageFormat = imageFormat + } + + deinit { + wrappedValue._deinitializeAttachableValue() + } +} diff --git a/Sources/Testing/CMakeLists.txt b/Sources/Testing/CMakeLists.txt index 5b84aeaf3..b60688731 100644 --- a/Sources/Testing/CMakeLists.txt +++ b/Sources/Testing/CMakeLists.txt @@ -3,8 +3,8 @@ # Copyright (c) 2024 Apple Inc. and the Swift project authors # Licensed under Apache License v2.0 with Runtime Library Exception # -# See http://swift.org/LICENSE.txt for license information -# See http://swift.org/CONTRIBUTORS.txt for Swift project authors +# See https://swift.org/LICENSE.txt for license information +# See https://swift.org/CONTRIBUTORS.txt for Swift project authors add_library(Testing ABI/EntryPoints/ABIEntryPoint.swift @@ -21,11 +21,17 @@ add_library(Testing ABI/Encoded/ABI.EncodedIssue.swift ABI/Encoded/ABI.EncodedMessage.swift ABI/Encoded/ABI.EncodedTest.swift + Attachments/Images/AttachableAsImage.swift + Attachments/Images/_AttachableImageWrapper.swift + Attachments/Images/AttachableImageFormat.swift + Attachments/Images/Attachment+AttachableAsImage.swift + Attachments/Images/ImageAttachmentError.swift Attachments/Attachable.swift Attachments/AttachableWrapper.swift Attachments/Attachment.swift Events/Clock.swift Events/Event.swift + Events/Recorder/Event.AdvancedConsoleOutputRecorder.swift Events/Recorder/Event.ConsoleOutputRecorder.swift Events/Recorder/Event.HumanReadableOutputRecorder.swift Events/Recorder/Event.JUnitXMLRecorder.swift @@ -70,8 +76,10 @@ add_library(Testing Support/Additions/ArrayAdditions.swift Support/Additions/CollectionDifferenceAdditions.swift Support/Additions/CommandLineAdditions.swift + Support/Additions/CopyableAdditions.swift Support/Additions/NumericAdditions.swift Support/Additions/ResultAdditions.swift + Support/Additions/TaskAdditions.swift Support/Additions/WinSDKAdditions.swift Support/CartesianProduct.swift Support/CError.swift @@ -82,15 +90,18 @@ add_library(Testing Support/Graph.swift Support/JSON.swift Support/Locked.swift - Support/Locked+Platform.swift + Support/Serializer.swift + Support/VersionNumber.swift Support/Versions.swift Discovery+Macro.swift Test.ID.Selection.swift Test.ID.swift Test.swift + Test+Cancellation.swift Test+Discovery.swift Test+Discovery+Legacy.swift Test+Macro.swift + Traits/AttachmentSavingTrait.swift Traits/Bug.swift Traits/Comment.swift Traits/Comment+Macro.swift @@ -117,7 +128,8 @@ if(NOT APPLE) endif() target_link_libraries(Testing PUBLIC Foundation) - if (CMAKE_SYSTEM_NAME STREQUAL "FreeBSD") + if (CMAKE_SYSTEM_NAME STREQUAL "FreeBSD" OR + CMAKE_SYSTEM_NAME STREQUAL "OpenBSD") target_link_libraries(Testing PUBLIC execinfo) endif() endif() diff --git a/Sources/Testing/Events/Event.swift b/Sources/Testing/Events/Event.swift index 0be14ae88..c6285f4f4 100644 --- a/Sources/Testing/Events/Event.swift +++ b/Sources/Testing/Events/Event.swift @@ -74,6 +74,18 @@ public struct Event: Sendable { /// that was passed to the event handler along with this event. case testCaseEnded + /// A test case was cancelled. + /// + /// - Parameters: + /// - skipInfo: A ``SkipInfo`` with details about the cancelled test case. + /// + /// This event is generated by a call to ``Test/Case/cancel(_:sourceLocation:)``. + /// + /// The test case that was cancelled is contained in the ``Event/Context`` + /// instance that was passed to the event handler along with this event. + @_spi(Experimental) + indirect case testCaseCancelled(_ skipInfo: SkipInfo) + /// An expectation was checked with `#expect()` or `#require()`. /// /// - Parameters: @@ -121,6 +133,18 @@ public struct Event: Sendable { /// available from this event's ``Event/testID`` property. indirect case testSkipped(_ skipInfo: SkipInfo) + /// A test was cancelled. + /// + /// - Parameters: + /// - skipInfo: A ``SkipInfo`` with details about the cancelled test. + /// + /// This event is generated by a call to ``Test/cancel(_:sourceLocation:)``. + /// + /// The test that was cancelled is contained in the ``Event/Context`` + /// instance that was passed to the event handler along with this event. + @_spi(Experimental) + indirect case testCancelled(_ skipInfo: SkipInfo) + /// A step in the runner plan ended. /// /// - Parameters: @@ -167,6 +191,16 @@ public struct Event: Sendable { /// The instant at which the event occurred. public var instant: Test.Clock.Instant +#if DEBUG + /// Whether or not this event was deferred. + /// + /// A deferred event is handled significantly later than when was posted. + /// + /// We currently use this property in our tests, but do not expose it as API + /// or SPI. We can expose it in the future if tools need it. + var wasDeferred: Bool = false +#endif + /// Initialize an instance of this type. /// /// - Parameters: @@ -395,6 +429,18 @@ extension Event.Kind { /// A test case ended. case testCaseEnded + /// A test case was cancelled. + /// + /// - Parameters: + /// - skipInfo: A ``SkipInfo`` with details about the cancelled test case. + /// + /// This event is generated by a call to ``Test/Case/cancel(_:sourceLocation:)``. + /// + /// The test case that was cancelled is contained in the ``Event/Context`` + /// instance that was passed to the event handler along with this event. + @_spi(Experimental) + indirect case testCaseCancelled(_ skipInfo: SkipInfo) + /// An expectation was checked with `#expect()` or `#require()`. /// /// - Parameters: @@ -431,6 +477,18 @@ extension Event.Kind { /// - skipInfo: A ``SkipInfo`` containing details about this skipped test. indirect case testSkipped(_ skipInfo: SkipInfo) + /// A test was cancelled. + /// + /// - Parameters: + /// - skipInfo: A ``SkipInfo`` with details about the cancelled test. + /// + /// This event is generated by a call to ``Test/cancel(_:sourceLocation:)``. + /// + /// The test that was cancelled is contained in the ``Event/Context`` + /// instance that was passed to the event handler along with this event. + @_spi(Experimental) + indirect case testCancelled(_ skipInfo: SkipInfo) + /// A step in the runner plan ended. /// /// - Parameters: @@ -479,6 +537,8 @@ extension Event.Kind { self = .testCaseStarted case .testCaseEnded: self = .testCaseEnded + case let .testCaseCancelled(skipInfo): + self = .testCaseCancelled(skipInfo) case let .expectationChecked(expectation): let expectationSnapshot = Expectation.Snapshot(snapshotting: expectation) self = Snapshot.expectationChecked(expectationSnapshot) @@ -490,6 +550,8 @@ extension Event.Kind { self = .testEnded case let .testSkipped(skipInfo): self = .testSkipped(skipInfo) + case let .testCancelled(skipInfo): + self = .testCancelled(skipInfo) case .planStepEnded: self = .planStepEnded case let .iterationEnded(index): diff --git a/Sources/Testing/Events/Recorder/Event.AdvancedConsoleOutputRecorder.swift b/Sources/Testing/Events/Recorder/Event.AdvancedConsoleOutputRecorder.swift new file mode 100644 index 000000000..c00a9101a --- /dev/null +++ b/Sources/Testing/Events/Recorder/Event.AdvancedConsoleOutputRecorder.swift @@ -0,0 +1,980 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +extension Event { + /// An experimental console output recorder that provides enhanced test result + /// display capabilities. + /// + /// This recorder is currently experimental and must be enabled via the + /// `SWT_ENABLE_EXPERIMENTAL_CONSOLE_OUTPUT` environment variable. + struct AdvancedConsoleOutputRecorder: Sendable { + /// Configuration for box-drawing character rendering strategy. + enum BoxDrawingMode: Sendable { + /// Use Unicode box-drawing characters (β”Œβ”€, β”œβ”€, ╰─, β”‚). + case unicode + /// Use Windows Code Page 437 box-drawing characters (β”Œβ”€, β”œβ”€, └─, β”‚). + case windows437 + /// Use ASCII fallback characters (--, |-, `-, |). + case ascii + } + + /// Configuration options for the advanced console output recorder. + struct Options: Sendable { + /// Base console output recorder options to inherit from. + var base: Event.ConsoleOutputRecorder.Options + + /// Box-drawing character mode override. + /// + /// When `nil` (default), the mode is automatically determined based on platform: + /// - macOS/Linux: Unicode if ANSI enabled, otherwise ASCII + /// - Windows: Code Page 437 if ANSI enabled, otherwise ASCII + /// + /// Set to a specific mode to override the automatic selection. + var boxDrawingMode: BoxDrawingMode? + + init() { + self.base = Event.ConsoleOutputRecorder.Options() + self.boxDrawingMode = nil // Use automatic selection + } + } + + /// Context for storing data across events during test execution. + private struct _Context: Sendable { + /// Storage for test information, keyed by test ID string value. + /// This is needed because ABI.EncodedEvent doesn't contain full test context. + var testStorage: [String: ABI.EncodedTest] = [:] + + /// Hierarchical test tree structure using Graph for efficient operations. + /// Key path represents the hierarchy (e.g., ["TestingTests", "ClockAPITests", "testMethod"]) + /// Value contains the test node data for that specific node. + var testTree: Graph = Graph() + + /// Consolidated test data for each test, keyed by test ID string. + /// Contains all runtime information gathered during test execution. + var testData: [String: _TestData] = [:] + + /// The instant when the test run was started. + /// Used to calculate total run duration. + var runStartTime: ABI.EncodedInstant? + + /// The instant when the test run was completed. + /// Used to calculate total run duration. + var runEndTime: ABI.EncodedInstant? + + /// The number of tests that passed during this run. + var totalPassed: Int = 0 + + /// The number of tests that failed during this run. + var totalFailed: Int = 0 + + /// The number of tests that were skipped during this run. + var totalSkipped: Int = 0 + } + + /// Consolidated data for a single test, combining result, timing, and issues. + private struct _TestData: Sendable { + /// The final result of the test execution (passed, failed, or skipped). + /// This is determined after all events for the test have been processed. + var result: _TestResult? + + /// The instant when the test started executing. + /// Used to calculate individual test duration. + var startTime: ABI.EncodedInstant? + + /// The instant when the test finished executing. + /// Used to calculate individual test duration. + var endTime: ABI.EncodedInstant? + + /// All issues recorded during the test execution. + /// Includes failures, warnings, and other diagnostic information. + var issues: [ABI.EncodedIssue] = [] + + /// Detailed messages for each issue, preserving the order and association. + /// Each inner array contains all messages for a single issue. + var issueMessages: [[ABI.EncodedMessage]] = [] + } + + /// Represents a node in the test hierarchy tree. + /// Graph handles the parent-child relationships, so this only stores node-specific data. + private struct _HierarchyNode: Sendable { + /// The unique identifier for this test or test suite. + let testID: String + + /// The base name of the test or suite without display formatting. + let name: String + + /// The human-readable display name for the test or suite, if different from name. + let displayName: String? + + /// Whether this node represents a test suite (true) or individual test (false). + let isSuite: Bool + + init(testID: String, name: String, displayName: String?, isSuite: Bool) { + self.testID = testID + self.name = name + self.displayName = displayName + self.isSuite = isSuite + } + } + + /// Represents the result of a test execution. + private enum _TestResult: Sendable { + /// The test executed successfully without any failures. + case passed + + /// The test failed due to one or more assertion failures or errors. + case failed + + /// The test was skipped and did not execute. + case skipped + } + + /// The options for this recorder. + let options: Options + + /// The write function for this recorder. + let write: @Sendable (String) -> Void + + /// The base console output options. + private let _baseOptions: Event.ConsoleOutputRecorder.Options + + /// Context storage for test information and results. + private let _context: Locked<_Context> + + /// Human-readable output recorder for generating messages. + private let _humanReadableRecorder: Event.HumanReadableOutputRecorder + + /// Initialize the advanced console output recorder. + /// + /// - Parameters: + /// - options: Configuration options for the recorder. + /// - write: A closure that writes output to its destination. + init(options: Options = Options(), writingUsing write: @escaping @Sendable (String) -> Void) { + self.options = options + self.write = write + self._baseOptions = options.base + self._context = Locked(rawValue: _Context()) + self._humanReadableRecorder = Event.HumanReadableOutputRecorder() + } + } +} + +// MARK: - 3-Tiered Fallback Support + +extension Event.AdvancedConsoleOutputRecorder { + /// Determine the appropriate box-drawing mode based on platform and configuration. + private var _boxDrawingMode: BoxDrawingMode { + // Use explicit override if provided + if let explicitMode = options.boxDrawingMode { + return explicitMode + } + + // Otherwise, use platform-appropriate defaults +#if os(Windows) + // On Windows, prefer Code Page 437 characters if ANSI is enabled, otherwise ASCII + return options.base.useANSIEscapeCodes ? .windows437 : .ascii +#else + // On macOS/Linux, prefer Unicode if ANSI is enabled, otherwise ASCII + return options.base.useANSIEscapeCodes ? .unicode : .ascii +#endif + } + + /// Get the appropriate tree drawing character with 3-tiered fallback. + /// + /// Implements the fallback strategy: + /// 1. Default (macOS/Linux): Unicode characters (β”Œβ”€, β”œβ”€, ╰─, β”‚) + /// 2. Windows fallback: Code Page 437 characters (β”Œβ”€, β”œβ”€, └─, β”‚) + /// 3. Final fallback: ASCII characters (--, |-, `-, |) + /// + /// - Parameters: + /// - unicode: The Unicode box-drawing character to use. + /// - windows437: The Windows Code Page 437 character to use. + /// - ascii: The ASCII fallback character(s) to use. + /// + /// - Returns: The appropriate character based on platform and terminal capabilities. + private func _treeCharacter(unicode: String, windows437: String, ascii: String) -> String { + switch _boxDrawingMode { + case .unicode: + return unicode + case .windows437: + return windows437 + case .ascii: + return ascii + } + } + + /// Get the tree branch character (β”œβ”€). + private var _treeBranch: String { + _treeCharacter(unicode: "β”œβ”€ ", windows437: "β”œβ”€ ", ascii: "|- ") + } + + /// Get the tree last branch character (╰─ or └─). + private var _treeLastBranch: String { + _treeCharacter(unicode: "╰─ ", windows437: "└─ ", ascii: "`- ") + } + + /// Get the tree vertical line character (β”‚). + private var _treeVertical: String { + _treeCharacter(unicode: "β”‚", windows437: "β”‚", ascii: "|") + } +} + +extension Event.AdvancedConsoleOutputRecorder { + /// Record an event and its context. + /// + /// - Parameters: + /// - event: The event to record. + /// - eventContext: Contextual information about the event. + public func record(_ event: borrowing Event, in eventContext: borrowing Event.Context) { + // Extract values before entering lock to avoid borrowing issues + let eventKind = event.kind + let testValue = eventContext.test + + // Handle test discovery for hierarchy building + if case .testDiscovered = eventKind, let test = testValue { + let encodedTest = ABI.EncodedTest(encoding: test) + + _context.withLock { context in + _buildTestHierarchy(encodedTest, in: &context) + } + } + + // Generate detailed messages using HumanReadableOutputRecorder + let messages = _humanReadableRecorder.record(event, in: eventContext) + + // Convert Event to ABI.EncodedEvent for processing (if needed) + if let encodedEvent = ABI.EncodedEvent(encoding: event, in: eventContext, messages: messages) { + _processABIEvent(encodedEvent) + } + + // Only output specific messages during the run, suppress most standard output + // The hierarchical summary will be shown at the end + switch eventKind { + case .runStarted: + let symbol = Event.Symbol.default.stringValue(options: _baseOptions) + write("\(symbol) Test run started.\n") + + case .runEnded: + // The hierarchical summary is generated in _processABIEvent for runEnded + break + + default: + // Suppress other standard messages to avoid duplicate output + // The hierarchy will show all the details at the end + break + } + } + + /// Build the test hierarchy from discovered tests. + /// + /// - Parameters: + /// - encodedTest: The test to add to the hierarchy. + /// - context: The mutable context to update. + private func _buildTestHierarchy(_ encodedTest: ABI.EncodedTest, in context: inout _Context) { + let testID = encodedTest.id.stringValue + let isSuite = encodedTest.kind == .suite + + // Create hierarchy node + let hierarchyNode = _HierarchyNode( + testID: testID, + name: encodedTest.name, + displayName: encodedTest.displayName, + isSuite: isSuite + ) + + // Parse the test ID to extract the key path for Graph + let keyPath = _parseTestIDToKeyPath(testID) + + // Insert the node into the Graph at the appropriate key path + context.testTree[keyPath] = hierarchyNode + + // Create intermediate nodes (modules and suites) if they don't exist + for i in 1.. ["TestingTests", "ClockAPITests", "testMethod()"] + /// - "TestingTests" -> ["TestingTests"] + /// + /// - Parameters: + /// - testID: The test ID to parse. + /// - Returns: An array of key path components. + private func _parseTestIDToKeyPath(_ testID: String) -> [String] { + // Use backtick-aware split for proper handling of raw identifiers + let components = rawIdentifierAwareSplit(testID, separator: "/").map(String.init) + var logicalPath: [String] = [] + + for component in components { + // Skip source location components (filename should be the last component) + if component.hasSuffix(".swift:") { + break + } + logicalPath.append(component) + } + + // Convert the first component from dot notation to separate components + // e.g., "TestingTests.ClockAPITests" -> ["TestingTests", "ClockAPITests"] + var keyPath: [String] = [] + + if let firstComponent = logicalPath.first { + let moduleParts = rawIdentifierAwareSplit(firstComponent, separator: ".").map(String.init) + keyPath.append(contentsOf: moduleParts) + + // Add any additional path components (for nested suites) + keyPath.append(contentsOf: logicalPath.dropFirst()) + } + + return keyPath.isEmpty ? [testID] : keyPath + } + + /// Extract all root nodes (module-level nodes) from the Graph. + /// + /// - Parameters: + /// - testTree: The Graph to extract root nodes from. + /// - Returns: Array of key paths for root nodes (modules). + private func _rootNodes(from testTree: Graph) -> [[String]] { + var rootNodes: [[String]] = [] + var moduleNames: Set = [] + + // Find all unique module names (first component of key paths) + testTree.forEach { keyPath, node in + if node != nil && !keyPath.isEmpty { + let moduleName = keyPath[0] + moduleNames.insert(moduleName) + } + } + + // Convert module names to single-component key paths + for moduleName in moduleNames.sorted() { + rootNodes.append([moduleName]) + } + + return rootNodes + } + + /// Find a hierarchy node from a test ID by searching the Graph. + /// + /// - Parameters: + /// - testID: The test ID to search for. + /// - testTree: The Graph to search in. + /// - Returns: The hierarchy node if found. + private func _nodeFromTestID(_ testID: String, in testTree: Graph) -> _HierarchyNode? { + var foundNode: _HierarchyNode? + + testTree.forEach { keyPath, node in + if node?.testID == testID { + foundNode = node + } + } + + return foundNode + } + + /// Find all child key paths for a given parent key path in the Graph. + /// + /// - Parameters: + /// - parentKeyPath: The parent key path. + /// - testTree: The Graph to search in. + /// - Returns: Array of child key paths sorted alphabetically. + private func _childKeyPaths(for parentKeyPath: [String], in testTree: Graph) -> [[String]] { + var childKeyPaths: [[String]] = [] + + testTree.forEach { keyPath, node in + if keyPath.count == parentKeyPath.count + 1 && + keyPath.prefix(parentKeyPath.count).elementsEqual(parentKeyPath) && + node != nil { + childKeyPaths.append(keyPath) + } + } + + return childKeyPaths.sorted { $0.last ?? "" < $1.last ?? "" } + } + + /// Find the key path for a given test ID in the Graph. + /// + /// - Parameters: + /// - testID: The test ID to search for. + /// - testTree: The Graph to search in. + /// - Returns: The key path if found, nil otherwise. + private func _findKeyPathForTestID(_ testID: String, in testTree: Graph) -> [String]? { + var foundKeyPath: [String]? + + testTree.forEach { keyPath, node in + if node?.testID == testID { + foundKeyPath = keyPath + } + } + + return foundKeyPath + } + + /// Process an ABI.EncodedEvent for advanced console output. + /// + /// This implements the enhanced console logic for hierarchical display and failure summary. + /// + /// - Parameters: + /// - encodedEvent: The ABI-encoded event to process. + private func _processABIEvent(_ encodedEvent: ABI.EncodedEvent) { + _context.withLock { context in + switch encodedEvent.kind { + case .runStarted: + context.runStartTime = encodedEvent.instant + + case .testStarted: + // Track test start time + if let testID = encodedEvent.testID?.stringValue { + var testData = context.testData[testID] ?? _TestData() + testData.startTime = encodedEvent.instant + context.testData[testID] = testData + } + + case .issueRecorded: + // Record issues for failure summary + if let testID = encodedEvent.testID?.stringValue, + let issue = encodedEvent.issue { + var testData = context.testData[testID] ?? _TestData() + testData.issues.append(issue) + testData.issueMessages.append(encodedEvent.messages) + context.testData[testID] = testData + } + + case .testEnded: + // Track test end time and determine result + if let testID = encodedEvent.testID?.stringValue { + var testData = context.testData[testID] ?? _TestData() + testData.endTime = encodedEvent.instant + + // Determine test result based on issues + let hasFailures = testData.issues.contains { !$0.isKnown && ($0.isFailure ?? true) } + let result: _TestResult = hasFailures ? .failed : .passed + testData.result = result + context.testData[testID] = testData + + // Update statistics + switch result { + case .passed: + context.totalPassed += 1 + case .failed: + context.totalFailed += 1 + case .skipped: + context.totalSkipped += 1 + } + } + + case .testSkipped: + // Mark test as skipped + if let testID = encodedEvent.testID?.stringValue { + var testData = context.testData[testID] ?? _TestData() + testData.result = .skipped + context.testData[testID] = testData + context.totalSkipped += 1 + } + + case .runEnded: + context.runEndTime = encodedEvent.instant + // Generate hierarchical summary + _generateHierarchicalSummary(context: context) + + default: + // Handle other event types + break + } + } + } + + /// Generate the final hierarchical summary when the run completes. + /// + /// - Parameters: + /// - context: The context containing all hierarchy and results data. + private func _generateHierarchicalSummary(context: _Context) { + var output = "\n" + + // Hierarchical Test Results + output += "══════════════════════════════════════ HIERARCHICAL TEST RESULTS ══════════════════════════════════════\n" + output += "\n" + + // Render the test hierarchy tree using Graph + let rootNodes = _rootNodes(from: context.testTree) + + if rootNodes.isEmpty { + // Show test results as flat list if no hierarchy + let allTests = context.testData.sorted { $0.key < $1.key } + for (testID, testData) in allTests { + let statusIcon = _statusIcon(for: testData.result ?? .passed) + let testName = _nodeFromTestID(testID, in: context.testTree)?.displayName ?? _nodeFromTestID(testID, in: context.testTree)?.name ?? testID + output += "\(statusIcon) \(testName)\n" + } + } else { + // Render the test hierarchy tree + for (index, rootKeyPath) in rootNodes.enumerated() { + if let rootNode = context.testTree[rootKeyPath] { + output += _renderHierarchyNode(rootNode, keyPath: rootKeyPath, context: context, prefix: "", isLast: true) + + // Add blank line between top-level modules (treat as separate trees) + if index < rootNodes.count - 1 { + output += "\n" + } + } + } + } + + output += "\n" + + // Test run summary + let totalTests = context.totalPassed + context.totalFailed + context.totalSkipped + + // Calculate total run duration + var totalDuration = "" + if let startTime = context.runStartTime, let endTime = context.runEndTime { + totalDuration = _formatDuration(endTime.absolute - startTime.absolute) + } + + // Format: [total] tests completed in [duration] ([pass symbol] pass: [number], [failed symbol] fail: [number], ...) + let passIcon = _statusIcon(for: .passed) + let failIcon = _statusIcon(for: .failed) + let skipIcon = _statusIcon(for: .skipped) + + var summaryParts: [String] = [] + if context.totalPassed > 0 { + summaryParts.append("\(passIcon) pass: \(context.totalPassed)") + } + if context.totalFailed > 0 { + summaryParts.append("\(failIcon) fail: \(context.totalFailed)") + } + if context.totalSkipped > 0 { + summaryParts.append("\(skipIcon) skip: \(context.totalSkipped)") + } + + let summaryDetails = summaryParts.joined(separator: ", ") + let durationText = totalDuration.isEmpty ? "" : " in \(totalDuration)" + output += "\(totalTests) test\(totalTests == 1 ? "" : "s") completed\(durationText) (\(summaryDetails))\n" + output += "\n" + + // Failed Test Details (only if there are failures) + let failedTests = context.testData.filter { $0.value.result == .failed } + if !failedTests.isEmpty { + output += "══════════════════════════════════════ FAILED TEST DETAILS (\(failedTests.count)) ══════════════════════════════════════\n" + output += "\n" + + // Iterate through all tests that recorded one or more failures + for (testIndex, testEntry) in failedTests.enumerated() { + let (testID, testData) = testEntry + let testNumber = testIndex + 1 + let totalFailedTests = failedTests.count + + // Get the fully qualified test name by traversing up the hierarchy + let fullyQualifiedName = _getFullyQualifiedTestNameWithFile(testID: testID, context: context) + + let failureIcon = _statusIcon(for: .failed) + output += "\(failureIcon) \(fullyQualifiedName)\n" + + // Show detailed issue information with enhanced formatting + if !testData.issues.isEmpty { + for (issueIndex, issue) in testData.issues.enumerated() { + // 1. Error Message - Get detailed error description + let issueDescription = _formatDetailedIssueDescription(issue, issueIndex: issueIndex, testData: testData) + + if !issueDescription.isEmpty { + let errorLines = issueDescription.split(separator: "\n", omittingEmptySubsequences: false) + for line in errorLines { + output += " \(line)\n" + } + } + + // 2. Location + if let sourceLocation = issue.sourceLocation { + output += "\n" + output += " Location: \(sourceLocation.fileName):\(sourceLocation.line):\(sourceLocation.column)\n" + } + + // 3. Statistics - Error counter in lower right + let errorCounter = "[\(testNumber)/\(totalFailedTests)]" + let paddingLength = max(0, 100 - errorCounter.count) + output += "\n" + output += "\(String(repeating: " ", count: paddingLength))\(errorCounter)\n" + + // Add spacing between issues (except for the last one) + if issueIndex < testData.issues.count - 1 { + output += "\n" + } + } + } + + // Add spacing between tests (except for the last one) + if testIndex < failedTests.count - 1 { + output += "\n" + } + } + } + + write(output) + } + + /// Render a hierarchy node with proper indentation and tree drawing characters. + /// + /// - Parameters: + /// - node: The node to render. + /// - context: The hierarchy context. + /// - prefix: The prefix for indentation and tree drawing. + /// - isLast: Whether this is the last child at its level. + /// - Returns: The rendered string for this node and its children. + private func _renderHierarchyNode(_ node: _HierarchyNode, keyPath: [String], context: _Context, prefix: String, isLast: Bool) -> String { + var output = "" + + if node.isSuite { + // Suite header + let treePrefix: String + if prefix.isEmpty { + // Top-level modules: no tree prefix, flush left (treat as separate trees) + treePrefix = "" + } else { + // Nested suites: use standard tree characters + treePrefix = isLast ? _treeLastBranch : _treeBranch + } + + let suiteName = node.displayName ?? node.name + output += "\(prefix)\(treePrefix)\(suiteName)\n" + + // Render children with updated prefix + let childPrefix: String + if prefix.isEmpty { + // Top-level modules: children start with 3 spaces (no vertical line needed) + childPrefix = " " + } else { + // Nested case: continue vertical line unless this is the last node + childPrefix = prefix + (isLast ? " " : "\(_treeVertical) ") + } + + let childKeyPaths = _childKeyPaths(for: keyPath, in: context.testTree) + for (childIndex, childKeyPath) in childKeyPaths.enumerated() { + let isLastChild = childIndex == childKeyPaths.count - 1 + if let childNode = context.testTree[childKeyPath] { + output += _renderHierarchyNode(childNode, keyPath: childKeyPath, context: context, prefix: childPrefix, isLast: isLastChild) + + // Add spacing between child nodes when the next sibling is a suite + // Continue the tree structure with vertical line + if childIndex < childKeyPaths.count - 1 { + // Check if the next sibling is a suite + let nextChildKeyPath = childKeyPaths[childIndex + 1] + if let nextChildNode = context.testTree[nextChildKeyPath], nextChildNode.isSuite { + // Use the correct spacing prefix + let spacingPrefix: String + if prefix.isEmpty { + // Top-level modules: use 3 spaces + vertical line + spacingPrefix = " \(_treeVertical)" + } else { + // Nested case: use the child prefix + spacingPrefix = childPrefix + } + output += "\(spacingPrefix)\n" // Add the vertical line continuation + } + } + } + } + } else { + // Test case line + let treePrefix = isLast ? _treeLastBranch : _treeBranch + let statusIcon = _statusIcon(for: context.testData[node.testID]?.result ?? .passed) + let testName = node.displayName ?? node.name + + // Calculate duration + var duration = "" + if let startTime = context.testData[node.testID]?.startTime, + let endTime = context.testData[node.testID]?.endTime { + duration = _formatDuration(endTime.absolute - startTime.absolute) + } + + // Format with right-aligned duration + let testLine = "\(statusIcon) \(testName)" + let fullPrefix = "\(prefix)\(treePrefix)" + let paddedTestLine = _padWithDuration(testLine, duration: duration, existingPrefix: fullPrefix) + output += "\(fullPrefix)\(paddedTestLine)\n" + + // Show concise issue summary for quick overview + if let issues = context.testData[node.testID]?.issues, !issues.isEmpty { + let issuePrefix = prefix + (isLast ? " " : "\(_treeVertical) ") + for (issueIndex, issue) in issues.enumerated() { + let isLastIssue = issueIndex == issues.count - 1 + let issueTreePrefix = isLastIssue ? _treeLastBranch : _treeBranch + + // Show "Expectation failed" with the actual error details + let fullDescription = _formatDetailedIssueDescription(issue, issueIndex: issueIndex, testData: context.testData[node.testID]!) + let conciseDescription = fullDescription.split(separator: "\n").first.map(String.init) ?? "Expected condition was not met" + output += "\(issuePrefix)\(issueTreePrefix)Expectation failed: \(conciseDescription)\n" + + // Add concise source location + if let sourceLocation = issue.sourceLocation { + let locationPrefix = issuePrefix + (isLastIssue ? " " : "\(_treeVertical) ") + output += "\(locationPrefix)at \(sourceLocation.fileName):\(sourceLocation.line)\n" + } + } + } + } + + return output + } + + /// Format a detailed description of an issue for the Failed Test Details section. + /// + /// - Parameters: + /// - issue: The encoded issue to format. + /// - issueIndex: The index of the issue in the testData.issues array. + /// - testData: The test data containing the stored messages. + /// - Returns: A detailed description of what failed. + private func _formatDetailedIssueDescription(_ issue: ABI.EncodedIssue, issueIndex: Int, testData: _TestData) -> String { + // Get the corresponding messages for this issue + guard issueIndex < testData.issueMessages.count else { + // Fallback to error description if available + if let error = issue._error { + return error.description + } + return "Issue recorded" + } + + let messages = testData.issueMessages[issueIndex] + + // Look for detailed messages (difference, details) that contain the actual failure information + var detailedMessages: [String] = [] + + for message in messages { + switch message.symbol { + case .difference, .details: + // These contain the detailed expectation failure information + detailedMessages.append(message.text) + case .fail: + // Primary failure message - use if no detailed messages available + if detailedMessages.isEmpty { + detailedMessages.append(message.text) + } + default: + break + } + } + + if !detailedMessages.isEmpty { + let fullMessage = detailedMessages.joined(separator: "\n") + // Truncate very long messages to prevent layout issues + if fullMessage.count > 200 { + let truncated = String(fullMessage.prefix(200)) + return truncated + "..." + } + return fullMessage + } + + // Final fallback + if let error = issue._error { + let errorDesc = error.description + // Truncate very long error descriptions + if errorDesc.count > 200 { + return String(errorDesc.prefix(200)) + "..." + } + return errorDesc + } + return "Issue recorded" + } + + /// Determine the status icon for a test result. + /// + /// - Parameters: + /// - result: The test result. + /// - Returns: The appropriate symbol string. + private func _statusIcon(for result: _TestResult) -> String { + switch result { + case .passed: + return Event.Symbol.pass(knownIssueCount: 0).stringValue(options: options.base) + case .failed: + return Event.Symbol.fail.stringValue(options: options.base) + case .skipped: + return Event.Symbol.skip.stringValue(options: options.base) + } + } + + /// Format a duration in seconds with exactly 2 decimal places. + /// + /// - Parameter duration: The duration to format. + /// - Returns: A formatted duration string (e.g., "1.80s", "0.05s"). + private func _formatDuration(_ duration: Double) -> String { + // Always format to exactly 2 decimal places + let wholePart = Int(duration) + let fractionalPart = Int((duration - Double(wholePart)) * 100 + 0.5) // Round to nearest hundredth + + // Handle rounding overflow (e.g., 0.999 -> 1.00) + if fractionalPart >= 100 { + return "\(wholePart + 1).00s" + } else { + let fractionalString = fractionalPart < 10 ? "0\(fractionalPart)" : "\(fractionalPart)" + return "\(wholePart).\(fractionalString)s" + } + } + + /// Pad a test line with right-aligned duration. + /// + /// - Parameters: + /// - testLine: The test line to pad. + /// - duration: The duration string. + /// - existingPrefix: Any prefix that will be added before this line. + /// - Returns: The padded test line with right-aligned duration. + private func _padWithDuration(_ testLine: String, duration: String, existingPrefix: String = "") -> String { + if duration.isEmpty { + return testLine + } + + // Get terminal width dynamically, fall back to 120 if unavailable + let targetWidth = _terminalWidth() + let rightPart = "(\(duration))" + + // Calculate visible character count (excluding ANSI escape codes) + let visiblePrefixLength = _visibleCharacterCount(existingPrefix) + let visibleLeftLength = _visibleCharacterCount(testLine) + let totalRightLength = rightPart.count + + // Ensure minimum spacing between content and duration + let minimumSpacing = 3 + let totalUsedWidth = visiblePrefixLength + visibleLeftLength + totalRightLength + minimumSpacing + + if totalUsedWidth < targetWidth { + let paddingLength = targetWidth - visiblePrefixLength - visibleLeftLength - totalRightLength + return "\(testLine)\(String(repeating: " ", count: paddingLength))\(rightPart)" + } else { + return "\(testLine) \(rightPart)" + } + } + + /// Determine the current terminal width, with fallback to reasonable default. + /// + /// - Returns: Terminal width in characters, defaults to 120 if unavailable. + private func _terminalWidth() -> Int { + // Try to get terminal width from environment variable + if let columnsEnv = Environment.variable(named: "COLUMNS"), + let columns = Int(columnsEnv), columns > 0 { + return columns + } + + // Fallback to a reasonable default width + // Modern terminals are typically 120+ characters wide + return 120 + } + + /// Calculate the visible character count, excluding ANSI escape sequences. + /// + /// - Parameters: + /// - string: The string to count visible characters in. + /// - Returns: The number of visible characters. + private func _visibleCharacterCount(_ string: String) -> Int { + var visibleCount = 0 + var inEscapeSequence = false + var i = string.startIndex + + while i < string.endIndex { + let char = string[i] + + if char == "\u{1B}" { // ESC character + inEscapeSequence = true + } else if inEscapeSequence && (char == "m" || char == "K") { + // End of ANSI escape sequence + inEscapeSequence = false + } else if !inEscapeSequence { + visibleCount += 1 + } + + i = string.index(after: i) + } + + return visibleCount + } + + /// Get the fully qualified test name for a given test ID. + /// + /// This function traverses the hierarchy to build the full test name. + /// + /// - Parameters: + /// - testID: The ID of the test. + /// - context: The context containing the test hierarchy. + /// - Returns: The fully qualified test name. + private func _getFullyQualifiedTestName(testID: String, context: _Context) -> String { + guard let keyPath = _findKeyPathForTestID(testID, in: context.testTree) else { return testID } + + var nameParts: [String] = [] + + // Build the hierarchy path by traversing from root to leaf + for i in 1...keyPath.count { + let currentKeyPath = Array(keyPath.prefix(i)) + if let node = context.testTree[currentKeyPath] { + let displayName = node.displayName ?? node.name + nameParts.append(displayName) + } + } + + return nameParts.joined(separator: "/") + } + + /// Get the fully qualified test name for a given test ID, including the file name. + /// + /// This function traverses the hierarchy to build the full test name in the format: + /// ModuleName/FileName/"SuiteName"/"TestName" + /// + /// - Parameters: + /// - testID: The ID of the test. + /// - context: The context containing the test hierarchy. + /// - Returns: The fully qualified test name with file name included. + private func _getFullyQualifiedTestNameWithFile(testID: String, context: _Context) -> String { + guard let keyPath = _findKeyPathForTestID(testID, in: context.testTree) else { return testID } + + // Get the source file name from the first issue + var fileName = "" + if let issues = context.testData[testID]?.issues, + let firstIssue = issues.first, + let sourceLocation = firstIssue.sourceLocation { + fileName = sourceLocation.fileName + } + + var nameParts: [String] = [] + + // Build the hierarchy path by traversing from root to leaf + for i in 1...keyPath.count { + let currentKeyPath = Array(keyPath.prefix(i)) + if let node = context.testTree[currentKeyPath] { + let displayName = node.displayName ?? node.name + + // For non-module nodes (suites and tests), wrap in quotes + if i > 1 { + nameParts.append("\"\(displayName)\"") + } else { + // Module name - no quotes + nameParts.append(displayName) + } + } + } + + // Insert file name after module name if we have it + if !fileName.isEmpty && nameParts.count > 0 { + nameParts.insert(fileName, at: 1) + } + + return nameParts.joined(separator: "/") + } +} diff --git a/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift b/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift index 6abb71442..9f0ac21b1 100644 --- a/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift +++ b/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift @@ -36,7 +36,7 @@ extension Event { /// A type that contains mutable context for /// ``Event/ConsoleOutputRecorder``. - private struct _Context { + fileprivate struct Context { /// The instant at which the run started. var runStartInstant: Test.Clock.Instant? @@ -51,6 +51,17 @@ extension Event { /// The number of test suites started or skipped during the run. var suiteCount = 0 + /// An enumeration describing the various keys which can be used in a test + /// data graph for an output recorder. + enum TestDataKey: Hashable { + /// A string key, typically containing one key from the key path + /// representation of a ``Test/ID`` instance. + case string(String) + + /// A test case ID. + case testCaseID(Test.Case.ID) + } + /// A type describing data tracked on a per-test basis. struct TestData { /// The instant at which the test started. @@ -62,18 +73,18 @@ extension Event { /// The number of known issues recorded for the test. var knownIssueCount = 0 - - /// The number of test cases for the test. - var testCasesCount = 0 + + /// Information about the cancellation of this test or test case. + var cancellationInfo: SkipInfo? } /// Data tracked on a per-test basis. - var testData = Graph() + var testData = Graph() } /// This event recorder's mutable context about events it has received, /// which may be used to inform how subsequent events are written. - private var _context = Locked(rawValue: _Context()) + private var _context = Locked(rawValue: Context()) /// Initialize a new human-readable event recorder. /// @@ -128,7 +139,9 @@ extension Event.HumanReadableOutputRecorder { /// - graph: The graph to walk while counting issues. /// /// - Returns: A tuple containing the number of issues recorded in `graph`. - private func _issueCounts(in graph: Graph?) -> (errorIssueCount: Int, warningIssueCount: Int, knownIssueCount: Int, totalIssueCount: Int, description: String) { + private func _issueCounts( + in graph: Graph? + ) -> (errorIssueCount: Int, warningIssueCount: Int, knownIssueCount: Int, totalIssueCount: Int, description: String) { guard let graph else { return (0, 0, 0, 0, "") } @@ -174,6 +187,26 @@ private func _capitalizedTitle(for test: Test?) -> String { test?.isSuite == true ? "Suite" : "Test" } +extension Test { + /// The name to use for this test in a human-readable context such as console + /// output. + /// + /// - Parameters: + /// - verbosity: The verbosity with which to describe this test. + /// + /// - Returns: The name of this test, suitable for display to the user. + func humanReadableName(withVerbosity verbosity: Int = 0) -> String { + switch displayName { + case let .some(displayName) where verbosity > 0: + #""\#(displayName)" (aka '\#(name)')"# + case let .some(displayName): + #""\#(displayName)""# + default: + name + } + } +} + extension Test.Case { /// The arguments of this test case, formatted for presentation, prefixed by /// their corresponding parameter label when available. @@ -241,19 +274,9 @@ extension Event.HumanReadableOutputRecorder { 0 } let test = eventContext.test - let testName = if let test { - if let displayName = test.displayName { - if verbosity > 0 { - "\"\(displayName)\" (aka '\(test.name)')" - } else { - "\"\(displayName)\"" - } - } else { - test.name - } - } else { - "Β«unknownΒ»" - } + let testCase = eventContext.testCase + let keyPath = eventContext.keyPath + let testName = test?.humanReadableName(withVerbosity: verbosity) ?? "Β«unknownΒ»" let instant = event.instant let iterationCount = eventContext.configuration?.repetitionPolicy.maximumIterationCount @@ -271,7 +294,7 @@ extension Event.HumanReadableOutputRecorder { case .testStarted: let test = test! - context.testData[test.id.keyPathRepresentation] = .init(startInstant: instant) + context.testData[keyPath] = .init(startInstant: instant) if test.isSuite { context.suiteCount += 1 } else { @@ -287,23 +310,20 @@ extension Event.HumanReadableOutputRecorder { } case let .issueRecorded(issue): - let id: [String] = if let test { - test.id.keyPathRepresentation - } else { - [] - } - var testData = context.testData[id] ?? .init(startInstant: instant) + var testData = context.testData[keyPath] ?? .init(startInstant: instant) if issue.isKnown { testData.knownIssueCount += 1 } else { let issueCount = testData.issueCount[issue.severity] ?? 0 testData.issueCount[issue.severity] = issueCount + 1 } - context.testData[id] = testData + context.testData[keyPath] = testData case .testCaseStarted: - let test = test! - context.testData[test.id.keyPathRepresentation]?.testCasesCount += 1 + context.testData[keyPath] = .init(startInstant: instant) + + case let .testCancelled(skipInfo), let .testCaseCancelled(skipInfo): + context.testData[keyPath]?.cancellationInfo = skipInfo default: // These events do not manipulate the context structure. @@ -337,7 +357,13 @@ extension Event.HumanReadableOutputRecorder { case .runStarted: var comments = [Comment]() if verbosity > 0 { - comments.append("Swift Version: \(swiftStandardLibraryVersion)") + if let swiftStandardLibraryVersion { + comments.append("Swift Standard Library Version: \(swiftStandardLibraryVersion)") + } + comments.append("Swift Compiler Version: \(swiftCompilerVersion)") +#if os(Linux) && canImport(Glibc) + comments.append("GNU C Library Version: \(glibcVersion)") +#endif } comments.append("Testing Library Version: \(testingLibraryVersion)") if let targetTriple { @@ -384,32 +410,39 @@ extension Event.HumanReadableOutputRecorder { case .testEnded: let test = test! - let id = test.id - let testDataGraph = context.testData.subgraph(at: id.keyPathRepresentation) + let testDataGraph = context.testData.subgraph(at: keyPath) let testData = testDataGraph?.value ?? .init(startInstant: instant) let issues = _issueCounts(in: testDataGraph) let duration = testData.startInstant.descriptionOfDuration(to: instant) - let testCasesCount = if test.isParameterized { - " with \(testData.testCasesCount.counting("test case"))" + let testCasesCount = if test.isParameterized, let testDataGraph { + " with \(testDataGraph.children.count.counting("test case"))" } else { "" } - return if issues.errorIssueCount > 0 { - CollectionOfOne( - Message( - symbol: .fail, - stringValue: "\(_capitalizedTitle(for: test)) \(testName)\(testCasesCount) failed after \(duration)\(issues.description)." - ) - ) + _formattedComments(for: test) + var cancellationComment = "." + let (symbol, verbed): (Event.Symbol, String) + if issues.errorIssueCount > 0 { + (symbol, verbed) = (.fail, "failed") + } else if !test.isParameterized, let cancellationInfo = testData.cancellationInfo { + if let comment = cancellationInfo.comment { + cancellationComment = ": \"\(comment.rawValue)\"" + } + (symbol, verbed) = (.skip, "was cancelled") } else { - [ - Message( - symbol: .pass(knownIssueCount: issues.knownIssueCount), - stringValue: "\(_capitalizedTitle(for: test)) \(testName)\(testCasesCount) passed after \(duration)\(issues.description)." - ) - ] + (symbol, verbed) = (.pass(knownIssueCount: issues.knownIssueCount), "passed") } + var result = [ + Message( + symbol: symbol, + stringValue: "\(_capitalizedTitle(for: test)) \(testName)\(testCasesCount) \(verbed) after \(duration)\(issues.description)\(cancellationComment)" + ) + ] + if issues.errorIssueCount > 0 { + result += _formattedComments(for: test) + } + return result + case let .testSkipped(skipInfo): let test = test! return if let comment = skipInfo.comment { @@ -433,7 +466,7 @@ extension Event.HumanReadableOutputRecorder { } else { 0 } - let labeledArguments = if let testCase = eventContext.testCase { + let labeledArguments = if let testCase { testCase.labeledArguments() } else { "" @@ -513,18 +546,48 @@ extension Event.HumanReadableOutputRecorder { return result case .testCaseStarted: - guard let testCase = eventContext.testCase, testCase.isParameterized, let arguments = testCase.arguments else { + guard let testCase, testCase.isParameterized, let arguments = testCase.arguments else { break } return [ Message( symbol: .default, - stringValue: "Passing \(arguments.count.counting("argument")) \(testCase.labeledArguments(includingQualifiedTypeNames: verbosity > 0)) to \(testName)" + stringValue: "Test case passing \(arguments.count.counting("argument")) \(testCase.labeledArguments(includingQualifiedTypeNames: verbosity > 0)) to \(testName) started." ) ] case .testCaseEnded: + guard verbosity > 0, let test, let testCase, testCase.isParameterized, let arguments = testCase.arguments else { + break + } + + let testDataGraph = context.testData.subgraph(at: keyPath) + let testData = testDataGraph?.value ?? .init(startInstant: instant) + let issues = _issueCounts(in: testDataGraph) + let duration = testData.startInstant.descriptionOfDuration(to: instant) + + var cancellationComment = "." + let (symbol, verbed): (Event.Symbol, String) + if issues.errorIssueCount > 0 { + (symbol, verbed) = (.fail, "failed") + } else if !test.isParameterized, let cancellationInfo = testData.cancellationInfo { + if let comment = cancellationInfo.comment { + cancellationComment = ": \"\(comment.rawValue)\"" + } + (symbol, verbed) = (.skip, "was cancelled") + } else { + (symbol, verbed) = (.pass(knownIssueCount: issues.knownIssueCount), "passed") + } + return [ + Message( + symbol: symbol, + stringValue: "Test case passing \(arguments.count.counting("argument")) \(testCase.labeledArguments(includingQualifiedTypeNames: verbosity > 0)) to \(testName) \(verbed) after \(duration)\(issues.description)\(cancellationComment)" + ) + ] + + case .testCancelled, .testCaseCancelled: + // Handled in .testEnded and .testCaseEnded break case let .iterationEnded(index): @@ -542,6 +605,7 @@ extension Event.HumanReadableOutputRecorder { case .runEnded: let testCount = context.testCount + let suiteCount = context.suiteCount let issues = _issueCounts(in: context.testData) let runStartInstant = context.runStartInstant ?? instant let duration = runStartInstant.descriptionOfDuration(to: instant) @@ -550,14 +614,14 @@ extension Event.HumanReadableOutputRecorder { [ Message( symbol: .fail, - stringValue: "Test run with \(testCount.counting("test")) failed after \(duration)\(issues.description)." + stringValue: "Test run with \(testCount.counting("test")) in \(suiteCount.counting("suite")) failed after \(duration)\(issues.description)." ) ] } else { [ Message( symbol: .pass(knownIssueCount: issues.knownIssueCount), - stringValue: "Test run with \(testCount.counting("test")) passed after \(duration)\(issues.description)." + stringValue: "Test run with \(testCount.counting("test")) in \(suiteCount.counting("suite")) passed after \(duration)\(issues.description)." ) ] } @@ -567,6 +631,31 @@ extension Event.HumanReadableOutputRecorder { } } +extension Test.ID { + /// The key path in a test data graph representing this test ID. + fileprivate var keyPath: some Collection { + keyPathRepresentation.map { .string($0) } + } +} + +extension Event.Context { + /// The key path in a test data graph representing this event this context is + /// associated with, including its test and/or test case IDs. + fileprivate var keyPath: some Collection { + var keyPath = [Event.HumanReadableOutputRecorder.Context.TestDataKey]() + + if let test { + keyPath.append(contentsOf: test.id.keyPath) + + if let testCase { + keyPath.append(.testCaseID(testCase.id)) + } + } + + return keyPath + } +} + // MARK: - Codable extension Event.HumanReadableOutputRecorder.Message: Codable {} diff --git a/Sources/Testing/Events/TimeValue.swift b/Sources/Testing/Events/TimeValue.swift index 5dac1fe08..ddc454f7b 100644 --- a/Sources/Testing/Events/TimeValue.swift +++ b/Sources/Testing/Events/TimeValue.swift @@ -54,7 +54,11 @@ struct TimeValue: Sendable { @available(_clockAPI, *) init(_ instant: SuspendingClock.Instant) { +#if compiler(>=6.3) + self.init(SuspendingClock().systemEpoch.duration(to: instant)) +#else self.init(unsafeBitCast(instant, to: Duration.self)) +#endif } } @@ -77,7 +81,7 @@ extension TimeValue: Codable {} extension TimeValue: CustomStringConvertible { var description: String { -#if os(WASI) +#if os(WASI) && compiler(<6.3) // BUG: https://github.com/swiftlang/swift/issues/72398 return String(describing: Duration(self)) #else @@ -110,7 +114,11 @@ extension Duration { @available(_clockAPI, *) extension SuspendingClock.Instant { init(_ timeValue: TimeValue) { +#if compiler(>=6.3) + self = SuspendingClock().systemEpoch.advanced(by: Duration(timeValue)) +#else self = unsafeBitCast(Duration(timeValue), to: SuspendingClock.Instant.self) +#endif } } diff --git a/Sources/Testing/ExitTests/ExitStatus.swift b/Sources/Testing/ExitTests/ExitStatus.swift index 0dd6d86ab..21fa2335e 100644 --- a/Sources/Testing/ExitTests/ExitStatus.swift +++ b/Sources/Testing/ExitTests/ExitStatus.swift @@ -21,6 +21,7 @@ private import _TestingInternals /// /// @Metadata { /// @Available(Swift, introduced: 6.2) +/// @Available(Xcode, introduced: 26.0) /// } #if SWT_NO_PROCESS_SPAWNING @available(*, unavailable, message: "Exit tests are not available on this platform.") @@ -55,6 +56,7 @@ public enum ExitStatus: Sendable { /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } case exitCode(_ exitCode: CInt) @@ -81,6 +83,7 @@ public enum ExitStatus: Sendable { /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } case signal(_ signal: CInt) } @@ -93,7 +96,19 @@ public enum ExitStatus: Sendable { extension ExitStatus: Equatable {} // MARK: - CustomStringConvertible -@_spi(Experimental) + +#if os(Linux) && !SWT_NO_DYNAMIC_LINKING +/// Get the short name of a signal constant. +/// +/// This symbol is provided because the underlying function was added to glibc +/// relatively recently and may not be available on all targets. Checking +/// `__GLIBC_PREREQ()` is insufficient because `_GNU_SOURCE` may not be defined +/// at the point string.h is first included. +private let _sigabbrev_np = symbol(named: "sigabbrev_np").map { + castCFunction(at: $0, to: (@convention(c) (CInt) -> UnsafePointer?).self) +} +#endif + #if SWT_NO_PROCESS_SPAWNING @available(*, unavailable, message: "Exit tests are not available on this platform.") #endif @@ -101,9 +116,37 @@ extension ExitStatus: CustomStringConvertible { public var description: String { switch self { case let .exitCode(exitCode): - ".exitCode(\(exitCode))" + return ".exitCode(\(exitCode))" case let .signal(signal): - ".signal(\(signal))" + var signalName: String? + +#if SWT_TARGET_OS_APPLE || os(FreeBSD) || os(OpenBSD) || os(Android) +#if !SWT_NO_SYS_SIGNAME + // These platforms define sys_signame with a size, which is imported + // into Swift as a tuple. + withUnsafeBytes(of: sys_signame) { sys_signame in + sys_signame.withMemoryRebound(to: UnsafePointer.self) { sys_signame in + if signal > 0 && signal < sys_signame.count { + signalName = String(validatingCString: sys_signame[Int(signal)])?.uppercased() + } + } + } +#endif +#elseif os(Linux) +#if !SWT_NO_DYNAMIC_LINKING + signalName = _sigabbrev_np?(signal).flatMap(String.init(validatingCString:)) +#endif +#elseif os(Windows) || os(WASI) + // These platforms do not have API to get the programmatic name of a + // signal constant. +#else +#warning("Platform-specific implementation missing: signal names unavailable") +#endif + + if let signalName { + return ".signal(SIG\(signalName) β†’ \(signal))" + } + return ".signal(\(signal))" } } } diff --git a/Sources/Testing/ExitTests/ExitTest.CapturedValue.swift b/Sources/Testing/ExitTests/ExitTest.CapturedValue.swift index 1d5c9b18a..556fc0cf6 100644 --- a/Sources/Testing/ExitTests/ExitTest.CapturedValue.swift +++ b/Sources/Testing/ExitTests/ExitTest.CapturedValue.swift @@ -8,8 +8,11 @@ // See https://swift.org/CONTRIBUTORS.txt for Swift project authors // -@_spi(Experimental) @_spi(ForToolsIntegrationOnly) +private import _TestingInternals + +@_spi(ForToolsIntegrationOnly) #if SWT_NO_EXIT_TESTS +@_unavailableInEmbedded @available(*, unavailable, message: "Exit tests are not available on this platform.") #endif extension ExitTest { @@ -82,7 +85,7 @@ extension ExitTest { } return nil #else - fatalError("Unsupported") + swt_unreachable() #endif } @@ -101,7 +104,7 @@ extension ExitTest { _kind = .typeOnly(type) } #else - fatalError("Unsupported") + swt_unreachable() #endif } } @@ -119,7 +122,7 @@ extension ExitTest { type } #else - fatalError("Unsupported") + swt_unreachable() #endif } } diff --git a/Sources/Testing/ExitTests/ExitTest.Condition.swift b/Sources/Testing/ExitTests/ExitTest.Condition.swift index f737d8cf6..edd94193b 100644 --- a/Sources/Testing/ExitTests/ExitTest.Condition.swift +++ b/Sources/Testing/ExitTests/ExitTest.Condition.swift @@ -11,6 +11,7 @@ private import _TestingInternals #if SWT_NO_EXIT_TESTS +@_unavailableInEmbedded @available(*, unavailable, message: "Exit tests are not available on this platform.") #endif extension ExitTest { @@ -35,6 +36,7 @@ extension ExitTest { /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } public struct Condition: Sendable { /// An enumeration describing the possible conditions for an exit test. @@ -57,6 +59,7 @@ extension ExitTest { // MARK: - #if SWT_NO_EXIT_TESTS +@_unavailableInEmbedded @available(*, unavailable, message: "Exit tests are not available on this platform.") #endif extension ExitTest.Condition { @@ -66,6 +69,7 @@ extension ExitTest.Condition { /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } public static var success: Self { Self(_kind: .success) @@ -78,6 +82,7 @@ extension ExitTest.Condition { /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } public static var failure: Self { Self(_kind: .failure) @@ -91,6 +96,7 @@ extension ExitTest.Condition { /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } public init(_ exitStatus: ExitStatus) { self.init(_kind: .exitStatus(exitStatus)) @@ -126,12 +132,13 @@ extension ExitTest.Condition { /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } public static func exitCode(_ exitCode: CInt) -> Self { #if !SWT_NO_EXIT_TESTS Self(.exitCode(exitCode)) #else - fatalError("Unsupported") + swt_unreachable() #endif } @@ -158,20 +165,21 @@ extension ExitTest.Condition { /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } public static func signal(_ signal: CInt) -> Self { #if !SWT_NO_EXIT_TESTS Self(.signal(signal)) #else - fatalError("Unsupported") + swt_unreachable() #endif } } // MARK: - CustomStringConvertible -@_spi(Experimental) #if SWT_NO_EXIT_TESTS +@_unavailableInEmbedded @available(*, unavailable, message: "Exit tests are not available on this platform.") #endif extension ExitTest.Condition: CustomStringConvertible { @@ -186,7 +194,7 @@ extension ExitTest.Condition: CustomStringConvertible { String(describing: exitStatus) } #else - fatalError("Unsupported") + swt_unreachable() #endif } } @@ -194,6 +202,7 @@ extension ExitTest.Condition: CustomStringConvertible { // MARK: - Comparison #if SWT_NO_EXIT_TESTS +@_unavailableInEmbedded @available(*, unavailable, message: "Exit tests are not available on this platform.") #endif extension ExitTest.Condition { diff --git a/Sources/Testing/ExitTests/ExitTest.Result.swift b/Sources/Testing/ExitTests/ExitTest.Result.swift index ef70a3789..53d816c85 100644 --- a/Sources/Testing/ExitTests/ExitTest.Result.swift +++ b/Sources/Testing/ExitTests/ExitTest.Result.swift @@ -9,6 +9,7 @@ // #if SWT_NO_EXIT_TESTS +@_unavailableInEmbedded @available(*, unavailable, message: "Exit tests are not available on this platform.") #endif extension ExitTest { @@ -21,12 +22,14 @@ extension ExitTest { /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } public struct Result: Sendable { /// The exit status reported by the process hosting the exit test. /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } public var exitStatus: ExitStatus @@ -36,14 +39,14 @@ extension ExitTest { /// The value of this property may contain any arbitrary sequence of bytes, /// including sequences that are not valid UTF-8 and cannot be decoded by /// [`String.init(cString:)`](https://developer.apple.com/documentation/swift/string/init(cstring:)-6kr8s). - /// Consider using [`String.init(validatingCString:)`](https://developer.apple.com/documentation/swift/string/init(validatingcstring:)-992vo) + /// Consider using [`String.init(validating:as:)`](https://developer.apple.com/documentation/swift/string/init(validating:as:)-84qr9) /// instead. /// /// When checking the value of this property, keep in mind that the standard /// output stream is globally accessible, and any code running in an exit - /// test may write to it including including the operating system and any - /// third-party dependencies you have declared in your package. Rather than - /// comparing the value of this property with [`==`](https://developer.apple.com/documentation/swift/array/==(_:_:)), + /// test may write to it including the operating system and any third-party + /// dependencies you have declared in your package. Rather than comparing + /// the value of this property with [`==`](https://developer.apple.com/documentation/swift/array/==(_:_:)), /// use [`contains(_:)`](https://developer.apple.com/documentation/swift/collection/contains(_:)) /// to check if expected output is present. /// @@ -57,6 +60,7 @@ extension ExitTest { /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } public var standardOutputContent: [UInt8] = [] @@ -66,14 +70,14 @@ extension ExitTest { /// The value of this property may contain any arbitrary sequence of bytes, /// including sequences that are not valid UTF-8 and cannot be decoded by /// [`String.init(cString:)`](https://developer.apple.com/documentation/swift/string/init(cstring:)-6kr8s). - /// Consider using [`String.init(validatingCString:)`](https://developer.apple.com/documentation/swift/string/init(validatingcstring:)-992vo) + /// Consider using [`String.init(validating:as:)`](https://developer.apple.com/documentation/swift/string/init(validating:as:)-84qr9) /// instead. /// /// When checking the value of this property, keep in mind that the standard - /// error stream is globally accessible, and any code running in an exit - /// test may write to it including including the operating system and any - /// third-party dependencies you have declared in your package. Rather than - /// comparing the value of this property with [`==`](https://developer.apple.com/documentation/swift/array/==(_:_:)), + /// output stream is globally accessible, and any code running in an exit + /// test may write to it including the operating system and any third-party + /// dependencies you have declared in your package. Rather than comparing + /// the value of this property with [`==`](https://developer.apple.com/documentation/swift/array/==(_:_:)), /// use [`contains(_:)`](https://developer.apple.com/documentation/swift/collection/contains(_:)) /// to check if expected output is present. /// @@ -87,6 +91,7 @@ extension ExitTest { /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } public var standardErrorContent: [UInt8] = [] diff --git a/Sources/Testing/ExitTests/ExitTest.swift b/Sources/Testing/ExitTests/ExitTest.swift index 1e9c29c15..da4da4c91 100644 --- a/Sources/Testing/ExitTests/ExitTest.swift +++ b/Sources/Testing/ExitTests/ExitTest.swift @@ -32,8 +32,10 @@ private import _TestingInternals /// /// @Metadata { /// @Available(Swift, introduced: 6.2) +/// @Available(Xcode, introduced: 26.0) /// } #if SWT_NO_EXIT_TESTS +@_unavailableInEmbedded @available(*, unavailable, message: "Exit tests are not available on this platform.") #endif public struct ExitTest: Sendable, ~Copyable { @@ -127,7 +129,7 @@ public struct ExitTest: Sendable, ~Copyable { /// /// The order of values in this array must be the same between the parent and /// child processes. - @_spi(Experimental) @_spi(ForToolsIntegrationOnly) + @_spi(ForToolsIntegrationOnly) public var capturedValues = [CapturedValue]() /// Make a copy of this instance. @@ -152,7 +154,7 @@ extension ExitTest { /// /// A pointer is used for indirection because `ManagedBuffer` cannot yet hold /// move-only types. - private static nonisolated(unsafe) var _current: Locked> = { + private static nonisolated(unsafe) let _current: Locked> = { let current = UnsafeMutablePointer.allocate(capacity: 1) current.initialize(to: nil) return Locked(rawValue: current) @@ -170,6 +172,7 @@ extension ExitTest { /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } public static var current: ExitTest? { _read { @@ -231,6 +234,10 @@ extension ExitTest { #endif } + /// The name of the environment variable used to identify the exit test to + /// call in a spawned exit test process. + private static let _idEnvironmentVariableName = "SWT_EXIT_TEST_ID" + /// Call the exit test in the current process. /// /// This function invokes the closure originally passed to @@ -243,15 +250,30 @@ extension ExitTest { #if os(Windows) // Windows does not support signal handling to the degree UNIX-like systems // do. When a signal is raised in a Windows process, the default signal - // handler simply calls `exit()` and passes the constant value `3`. To allow - // us to handle signals on Windows, we install signal handlers for all + // handler simply calls `_exit()` and passes the constant value `3`. To + // allow us to handle signals on Windows, we install signal handlers for all // signals supported on Windows. These signal handlers exit with a specific // exit code that is unlikely to be encountered "in the wild" and which // encodes the caught signal. Corresponding code in the parent process looks // for these special exit codes and translates them back to signals. + // + // Microsoft's documentation for `_Exit()` and `_exit()` indicates they + // behave identically. Their documentation for abort() can be found at + // https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/abort?view=msvc-170 + // and states: "[...] abort calls _exit to terminate the process with exit + // code 3 [...]". + // + // The Wine project's implementation of raise() calls `_exit(3)` by default. + // See https://github.com/wine-mirror/wine/blob/master/dlls/msvcrt/except.c (ignore-unacceptable-language) + // + // Finally, an official copy of the UCRT sources (not up to date) is hosted + // at https://www.nuget.org/packages/Microsoft.Windows.SDK.CRTSource . That + // repository doesn't have an official GitHub mirror, but you can manually + // navigate to misc/signal.cpp:481 to see the implementation of SIG_DFL + // (which, again, calls `_exit(3)` unconditionally.) for sig in [SIGINT, SIGILL, SIGFPE, SIGSEGV, SIGTERM, SIGBREAK, SIGABRT, SIGABRT_COMPAT] { _ = signal(sig) { sig in - _Exit(STATUS_SIGNAL_CAUGHT_BITS | sig) + _exit(STATUS_SIGNAL_CAUGHT_BITS | sig) } } #endif @@ -270,9 +292,10 @@ extension ExitTest { current.pointee = self.unsafeCopy() } - do { + let error = await Issue.withErrorRecording(at: nil) { try await body(&self) - } catch { + } + if let error { _errorInMain(error) } @@ -327,7 +350,7 @@ extension ExitTest { /// /// - Warning: This function is used to implement the /// `#expect(processExitsWith:)` macro. Do not use it directly. - public static func __store( + @safe public static func __store( _ id: (UInt64, UInt64, UInt64, UInt64), _ body: @escaping @Sendable (repeat each T) async throws -> Void, into outValue: UnsafeMutableRawPointer, @@ -360,6 +383,25 @@ extension ExitTest { outValue.initializeMemory(as: Record.self, to: record) return true } + + /// Attempt to store an invalid exit test into the given memory. + /// + /// This overload of `__store()` is provided to suppress diagnostics when a + /// value of an unsupported type is captured as an argument of `body`. It + /// always terminates the current process. + /// + /// - Warning: This function is used to implement the + /// `#expect(processExitsWith:)` macro. Do not use it directly. + @_disfavoredOverload + @safe public static func __store( + _ id: (UInt64, UInt64, UInt64, UInt64), + _ body: T, + into outValue: UnsafeMutableRawPointer, + asTypeAt typeAddress: UnsafeRawPointer, + withHintAt hintAddress: UnsafeRawPointer? = nil + ) -> CBool { + swt_unreachable() + } } @_spi(ForToolsIntegrationOnly) @@ -479,11 +521,11 @@ func callExitTest( } // Plumb the exit test's result through the general expectation machinery. + let expression = __Expression(String(describingForTest: expectedExitCondition)) return __checkValue( expectedExitCondition.isApproximatelyEqual(to: result.exitStatus), expression: expression, expressionWithCapturedRuntimeValues: expression.capturingRuntimeValues(result.exitStatus), - mismatchedExitConditionDescription: String(describingForTest: expectedExitCondition), comments: comments(), isRequired: isRequired, sourceLocation: sourceLocation @@ -496,14 +538,80 @@ extension ABI { /// The ABI version to use for encoding and decoding events sent over the back /// channel. /// - /// The back channel always uses the latest ABI version (even if experimental) - /// since both the producer and consumer use this exact version of the testing - /// library. - fileprivate typealias BackChannelVersion = v1 + /// The back channel always uses the experimental ABI version since both the + /// producer and consumer use this exact version of the testing library. + fileprivate typealias BackChannelVersion = ExperimentalVersion } @_spi(ForToolsIntegrationOnly) extension ExitTest { + /// A barrier value to insert into the standard output and standard error + /// streams immediately before and after the body of an exit test runs in + /// order to distinguish output produced by the host process. + /// + /// The value of this property was randomly generated. It could conceivably + /// show up in actual output from an exit test, but the statistical likelihood + /// of that happening is negligible. + static var barrierValue: [UInt8] { + [ + 0x39, 0x74, 0x87, 0x6d, 0x96, 0xdd, 0xf6, 0x17, + 0x7f, 0x05, 0x61, 0x5d, 0x46, 0xeb, 0x37, 0x0c, + 0x90, 0x07, 0xca, 0xe5, 0xed, 0x0b, 0xc4, 0xc4, + 0x46, 0x36, 0xc5, 0xb8, 0x9c, 0xc7, 0x86, 0x57, + ] + } + + /// Remove the leading and trailing barrier values from the given array of + /// bytes along. + /// + /// - Parameters: + /// - buffer: The buffer to trim. + /// + /// - Returns: A copy of `buffer`. If a barrier value (equal to + /// ``barrierValue``) is present in `buffer`, it and everything before it + /// are trimmed from the beginning of the copy. If there is more than one + /// barrier value present, the last one and everything after it are trimmed + /// from the end of the copy. If no barrier value is present, `buffer` is + /// returned verbatim. + private static func _trimToBarrierValues(_ buffer: [UInt8]) -> [UInt8] { + let barrierValue = barrierValue + let firstBarrierByte = barrierValue[0] + + // If the buffer is too small to contain the barrier value, exit early. + guard buffer.count > barrierValue.count else { + return buffer + } + + // Find all the indices where the first byte of the barrier is present. + let splits = buffer.indices.filter { buffer[$0] == firstBarrierByte } + + // Trim off the leading barrier value. If we didn't find any barrier values, + // we do nothing. + let leadingIndex = splits.first { buffer[$0...].starts(with: barrierValue) } + guard let leadingIndex else { + return buffer + } + var trimmedBuffer = buffer[leadingIndex...].dropFirst(barrierValue.count) + + // If there's a trailing barrier value, trim it too. If it's at the same + // index as the leading barrier value, that means only one barrier value + // was present and we should assume it's the leading one. + let trailingIndex = splits.last { buffer[$0...].starts(with: barrierValue) } + if let trailingIndex, trailingIndex > leadingIndex { + trimmedBuffer = trimmedBuffer[.. Self? { - // Find the ID of the exit test to run, if any, in the environment block. - var id: ExitTest.ID? - if var idString = Environment.variable(named: "SWT_EXIT_TEST_ID") { - // Clear the environment variable. It's an implementation detail and exit - // test code shouldn't be dependent on it. Use ExitTest.current if needed! - Environment.setVariable(nil, named: "SWT_EXIT_TEST_ID") - - id = try? idString.withUTF8 { idBuffer in - try JSON.decode(ExitTest.ID.self, from: UnsafeRawBufferPointer(idBuffer)) - } - } - guard let id, var result = find(identifiedBy: id) else { + guard let id = environmentIDForEntryPoint, var result = find(identifiedBy: id) else { return nil } + // Since an exit test was found, clear the environment variable. It's an + // implementation detail and exit test code shouldn't be dependent on it. + // Use ExitTest.current if needed! + Environment.setVariable(nil, named: Self._idEnvironmentVariableName) + // If an exit test was found, inject back channel handling into its body. // External tools authors should set up their own back channel mechanisms // and ensure they're installed before calling ExitTest.callAsFunction(). @@ -650,12 +763,23 @@ extension ExitTest { } } configuration.eventHandler = { event, eventContext in - if case .issueRecorded = event.kind { + switch event.kind { + case .issueRecorded, .valueAttached, .testCancelled: eventHandler(event, eventContext) + default: + // Don't forward other kinds of event. + break } } result.body = { [configuration, body = result.body] exitTest in + Self._writeBarrierValues() + defer { + // We will generally not end up writing these values if the process + // exits abnormally. + Self._writeBarrierValues() + } + try await Configuration.withCurrent(configuration) { try exitTest._decodeCapturedValuesForEntryPoint() try await body(&exitTest) @@ -752,7 +876,7 @@ extension ExitTest { // Insert a specific variable that tells the child process which exit test // to run. try JSON.withEncoding(of: exitTest.id) { json in - childEnvironment["SWT_EXIT_TEST_ID"] = String(decoding: json, as: UTF8.self) + childEnvironment[Self._idEnvironmentVariableName] = String(decoding: json, as: UTF8.self) } typealias ResultUpdater = @Sendable (inout ExitTest.Result) -> Void @@ -795,7 +919,7 @@ extension ExitTest { childEnvironment["SWT_BACKCHANNEL"] = backChannelEnvironmentVariable } if let capturedValuesEnvironmentVariable = _makeEnvironmentVariable(for: capturedValuesReadEnd) { - childEnvironment["SWT_EXPERIMENTAL_CAPTURED_VALUES"] = capturedValuesEnvironmentVariable + childEnvironment["SWT_CAPTURED_VALUES"] = capturedValuesEnvironmentVariable } // Spawn the child process. @@ -827,7 +951,7 @@ extension ExitTest { capturedValuesWriteEnd.close() // Await termination of the child process. - taskGroup.addTask { + taskGroup.addTask(name: decorateTaskName("exit test", withAction: "awaiting termination")) { let exitStatus = try await wait(for: processID) return { $0.exitStatus = exitStatus } } @@ -835,15 +959,15 @@ extension ExitTest { // Read back the stdout and stderr streams. if let stdoutReadEnd { stdoutWriteEnd?.close() - taskGroup.addTask { - let standardOutputContent = try stdoutReadEnd.readToEnd() + taskGroup.addTask(name: decorateTaskName("exit test", withAction: "reading stdout")) { + let standardOutputContent = try Self._trimToBarrierValues(stdoutReadEnd.readToEnd()) return { $0.standardOutputContent = standardOutputContent } } } if let stderrReadEnd { stderrWriteEnd?.close() - taskGroup.addTask { - let standardErrorContent = try stderrReadEnd.readToEnd() + taskGroup.addTask(name: decorateTaskName("exit test", withAction: "reading stderr")) { + let standardErrorContent = try Self._trimToBarrierValues(stderrReadEnd.readToEnd()) return { $0.standardErrorContent = standardErrorContent } } } @@ -851,7 +975,7 @@ extension ExitTest { // Read back all data written to the back channel by the child process // and process it as a (minimal) event stream. backChannelWriteEnd.close() - taskGroup.addTask { + taskGroup.addTask(name: decorateTaskName("exit test", withAction: "processing events")) { Self._processRecords(fromBackChannel: backChannelReadEnd) return nil } @@ -910,29 +1034,44 @@ extension ExitTest { /// - Throws: Any error encountered attempting to decode or process the JSON. private static func _processRecord(_ recordJSON: UnsafeRawBufferPointer, fromBackChannel backChannel: borrowing FileHandle) throws { let record = try JSON.decode(ABI.Record.self, from: recordJSON) + guard case let .event(event) = record.kind else { + return + } - if case let .event(event) = record.kind, let issue = event.issue { + lazy var comments: [Comment] = event._comments?.map(Comment.init(rawValue:)) ?? [] + lazy var sourceContext = SourceContext( + backtrace: nil, // A backtrace from the child process will have the wrong address space. + sourceLocation: event._sourceLocation + ) + lazy var skipInfo = SkipInfo(comment: comments.first, sourceContext: sourceContext) + if let issue = event.issue { // 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.map(\.text).map(Comment.init(rawValue:)) let issueKind: Issue.Kind = if let error = issue._error { .errorCaught(error) } else { // TODO: improve fidelity of issue kind reporting (especially those without associated values) .unconditional } - let sourceContext = SourceContext( - backtrace: nil, // `issue._backtrace` will have the wrong address space. - sourceLocation: issue.sourceLocation - ) - var issueCopy = Issue(kind: issueKind, comments: comments, sourceContext: sourceContext) + let severity: Issue.Severity = switch issue.severity { + case .warning: + .warning + case .error, nil: + // Prior to 6.3, all Issues are errors + .error + } + var issueCopy = Issue(kind: issueKind, severity: severity, comments: comments, sourceContext: sourceContext) if issue.isKnown { // The known issue comment, if there was one, is already included in // the `comments` array above. issueCopy.knownIssueContext = Issue.KnownIssueContext() } issueCopy.record() + } else if let attachment = event.attachment { + Attachment.record(attachment, sourceLocation: event._sourceLocation!) + } else if case .testCancelled = event.kind { + _ = try? Test.cancel(with: skipInfo) } } @@ -947,7 +1086,7 @@ extension ExitTest { 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 { + guard let fileHandle = Self._makeFileHandle(forEnvironmentVariableNamed: "SWT_CAPTURED_VALUES", mode: "rb") else { return } let capturedValuesJSON = try fileHandle.readToEnd() diff --git a/Sources/Testing/ExitTests/SpawnProcess.swift b/Sources/Testing/ExitTests/SpawnProcess.swift index 8f8d95db6..6114566f1 100644 --- a/Sources/Testing/ExitTests/SpawnProcess.swift +++ b/Sources/Testing/ExitTests/SpawnProcess.swift @@ -38,7 +38,7 @@ private let _posix_spawn_file_actions_addclosefrom_np = symbol(named: "posix_spa } #endif -/// Spawn a process and wait for it to terminate. +/// Spawn a child process. /// /// - Parameters: /// - executablePath: The path to the executable to spawn. @@ -61,8 +61,7 @@ private let _posix_spawn_file_actions_addclosefrom_np = symbol(named: "posix_spa /// eventually pass this value to ``wait(for:)`` to avoid leaking system /// resources. /// -/// - Throws: Any error that prevented the process from spawning or its exit -/// condition from being read. +/// - Throws: Any error that prevented the process from spawning. func spawnExecutable( atPath executablePath: String, arguments: [String], @@ -83,8 +82,9 @@ func spawnExecutable( #if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD) return try withUnsafeTemporaryAllocation(of: P.self, capacity: 1) { fileActions in let fileActions = fileActions.baseAddress! - guard 0 == posix_spawn_file_actions_init(fileActions) else { - throw CError(rawValue: swt_errno()) + let fileActionsInitialized = posix_spawn_file_actions_init(fileActions) + guard 0 == fileActionsInitialized else { + throw CError(rawValue: fileActionsInitialized) } defer { _ = posix_spawn_file_actions_destroy(fileActions) @@ -92,8 +92,9 @@ func spawnExecutable( return try withUnsafeTemporaryAllocation(of: P.self, capacity: 1) { attrs in let attrs = attrs.baseAddress! - guard 0 == posix_spawnattr_init(attrs) else { - throw CError(rawValue: swt_errno()) + let attrsInitialized = posix_spawnattr_init(attrs) + guard 0 == attrsInitialized else { + throw CError(rawValue: attrsInitialized) } defer { _ = posix_spawnattr_destroy(attrs) @@ -112,6 +113,13 @@ func spawnExecutable( withUnsafeTemporaryAllocation(of: sigset_t.self, capacity: 1) { allSignals in let allSignals = allSignals.baseAddress! sigfillset(allSignals) +#if os(OpenBSD) + // On OpenBSD, attempting to set the signal handler for SIGKILL or + // SIGSTOP will cause the child process of a call to posix_spawn() to + // exit abnormally with exit code 127. See https://man.openbsd.org/sigaction.2#ERRORS + sigdelset(allSignals, SIGKILL) + sigdelset(allSignals, SIGSTOP) +#endif posix_spawnattr_setsigdefault(attrs, allSignals); flags |= CShort(POSIX_SPAWN_SETSIGDEF) } @@ -123,11 +131,27 @@ func spawnExecutable( guard let fd else { throw SystemError(description: "A child process cannot inherit a file handle without an associated file descriptor. Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new") } - if let standardFD { + if let standardFD, standardFD != fd { _ = posix_spawn_file_actions_adddup2(fileActions, fd, standardFD) } else { #if SWT_TARGET_OS_APPLE _ = posix_spawn_file_actions_addinherit_np(fileActions, fd) +#else + // posix_spawn_file_actions_adddup2() will automatically clear + // FD_CLOEXEC after forking but before execing even if the old and + // new file descriptors are equal. This behavior is supported by + // Glibc β‰₯ 2.29, FreeBSD, OpenBSD, and Android (Bionic) and is + // standardized in POSIX.1-2024 (see https://pubs.opengroup.org/onlinepubs/9799919799/functions/posix_spawn_file_actions_adddup2.html + // and https://www.austingroupbugs.net/view.php?id=411). + _ = posix_spawn_file_actions_adddup2(fileActions, fd, fd) +#if os(Linux) && canImport(Glibc) + if _slowPath(glibcVersion < VersionNumber(2, 29)) { + // This system is using an older version of glibc that does not + // implement FD_CLOEXEC clearing in posix_spawn_file_actions_adddup2(), + // so we must clear it here in the parent process. + try setFD_CLOEXEC(false, onFileDescriptor: fd) + } +#endif #endif highestFD = max(highestFD, fd) } @@ -156,8 +180,6 @@ func spawnExecutable( #if !SWT_NO_DYNAMIC_LINKING // This platform doesn't have POSIX_SPAWN_CLOEXEC_DEFAULT, but we can at // least close all file descriptors higher than the highest inherited one. - // We are assuming here that the caller didn't set FD_CLOEXEC on any of - // these file descriptors. _ = _posix_spawn_file_actions_addclosefrom_np?(fileActions, highestFD + 1) #endif #elseif os(FreeBSD) @@ -209,43 +231,49 @@ func spawnExecutable( } #if SWT_TARGET_OS_APPLE && DEBUG // Resume the process. - _ = kill(pid, SIGCONT) + _ = kill(pid, SIGCONT) // ignore-unacceptable-language #endif return pid } } #elseif os(Windows) return try _withStartupInfoEx(attributeCount: 1) { startupInfo in - func inherit(_ fileHandle: borrowing FileHandle, as outWindowsHANDLE: inout HANDLE?) throws { + func inherit(_ fileHandle: borrowing FileHandle) throws -> HANDLE? { try fileHandle.withUnsafeWindowsHANDLE { windowsHANDLE in guard let windowsHANDLE else { throw SystemError(description: "A child process cannot inherit a file handle without an associated Windows handle. Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new") } - outWindowsHANDLE = windowsHANDLE + + // Ensure the file handle can be inherited by the child process. + guard SetHandleInformation(windowsHANDLE, DWORD(HANDLE_FLAG_INHERIT), DWORD(HANDLE_FLAG_INHERIT)) else { + throw Win32Error(rawValue: GetLastError()) + } + + return windowsHANDLE } } - func inherit(_ fileHandle: borrowing FileHandle?, as outWindowsHANDLE: inout HANDLE?) throws { + func inherit(_ fileHandle: borrowing FileHandle?) throws -> HANDLE? { if fileHandle != nil { - try inherit(fileHandle!, as: &outWindowsHANDLE) + return try inherit(fileHandle!) } else { - outWindowsHANDLE = nil + return nil } } // Forward standard I/O streams. - try inherit(standardInput, as: &startupInfo.pointee.StartupInfo.hStdInput) - try inherit(standardOutput, as: &startupInfo.pointee.StartupInfo.hStdOutput) - try inherit(standardError, as: &startupInfo.pointee.StartupInfo.hStdError) + startupInfo.pointee.StartupInfo.hStdInput = try inherit(standardInput) + startupInfo.pointee.StartupInfo.hStdOutput = try inherit(standardOutput) + startupInfo.pointee.StartupInfo.hStdError = try inherit(standardError) startupInfo.pointee.StartupInfo.dwFlags |= STARTF_USESTDHANDLES // Ensure standard I/O streams and any explicitly added file handles are // inherited by the child process. var inheritedHandles = [HANDLE?](repeating: nil, count: additionalFileHandles.count + 3) - try inherit(standardInput, as: &inheritedHandles[0]) - try inherit(standardOutput, as: &inheritedHandles[1]) - try inherit(standardError, as: &inheritedHandles[2]) + inheritedHandles[0] = startupInfo.pointee.StartupInfo.hStdInput + inheritedHandles[1] = startupInfo.pointee.StartupInfo.hStdOutput + inheritedHandles[2] = startupInfo.pointee.StartupInfo.hStdError for i in 0 ..< additionalFileHandles.count { - try inherit(additionalFileHandles[i].pointee, as: &inheritedHandles[i + 3]) + inheritedHandles[i + 3] = try inherit(additionalFileHandles[i].pointee) } inheritedHandles = inheritedHandles.compactMap(\.self) @@ -263,25 +291,47 @@ func spawnExecutable( let commandLine = _escapeCommandLine(CollectionOfOne(executablePath) + arguments) let environ = environment.map { "\($0.key)=\($0.value)" }.joined(separator: "\0") + "\0\0" + // CreateProcessW() may modify the command line argument, so we must make + // a mutable copy of it. (environ is also passed as a mutable raw pointer, + // but it is not documented as actually being mutated.) + let commandLineCopy = commandLine.withCString(encodedAs: UTF16.self) { _wcsdup($0) } + defer { + free(commandLineCopy) + } + + // On Windows, a process holds a reference to its current working + // directory, which prevents other processes from deleting it. This causes + // code to fail if it tries to set the working directory to a temporary + // path. SEE: https://github.com/swiftlang/swift-testing/issues/1209 + // + // This problem manifests for us when we spawn a child process without + // setting its working directory, which causes it to default to that of + // the parent process. To avoid this problem, we set the working directory + // of the new process to the root directory of the boot volume (which is + // unlikely to be deleted, one hopes). + // + // SEE: https://devblogs.microsoft.com/oldnewthing/20101109-00/?p=12323 + let workingDirectoryPath = rootDirectoryPath + var flags = DWORD(CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT | EXTENDED_STARTUPINFO_PRESENT) #if DEBUG // Start the process suspended so we can attach a debugger if needed. flags |= DWORD(CREATE_SUSPENDED) #endif - return try commandLine.withCString(encodedAs: UTF16.self) { commandLine in - try environ.withCString(encodedAs: UTF16.self) { environ in + return try environ.withCString(encodedAs: UTF16.self) { environ in + try workingDirectoryPath.withCString(encodedAs: UTF16.self) { workingDirectoryPath in var processInfo = PROCESS_INFORMATION() guard CreateProcessW( nil, - .init(mutating: commandLine), + commandLineCopy, nil, nil, true, // bInheritHandles flags, .init(mutating: environ), - nil, + workingDirectoryPath, startupInfo.pointer(to: \.StartupInfo)!, &processInfo ) else { @@ -396,4 +446,29 @@ private func _escapeCommandLine(_ arguments: [String]) -> String { }.joined(separator: " ") } #endif + +/// Spawn a child process and wait for it to terminate. +/// +/// - Parameters: +/// - executablePath: The path to the executable to spawn. +/// - arguments: The arguments to pass to the executable, not including the +/// executable path. +/// - environment: The environment block to pass to the executable. +/// +/// - Returns: The exit status of the spawned process. +/// +/// - Throws: Any error that prevented the process from spawning or its exit +/// condition from being read. +/// +/// This function is a convenience that spawns the given process and waits for +/// it to terminate. It is primarily for use by other targets in this package +/// such as its cross-import overlays. +package func spawnExecutableAtPathAndWait( + _ executablePath: String, + arguments: [String] = [], + environment: [String: String] = [:] +) async throws -> ExitStatus { + let processID = try spawnExecutable(atPath: executablePath, arguments: arguments, environment: environment) + return try await wait(for: processID) +} #endif diff --git a/Sources/Testing/ExitTests/WaitFor.swift b/Sources/Testing/ExitTests/WaitFor.swift index 238ed835a..f0326ff3c 100644 --- a/Sources/Testing/ExitTests/WaitFor.swift +++ b/Sources/Testing/ExitTests/WaitFor.swift @@ -80,16 +80,47 @@ func wait(for pid: consuming pid_t) async throws -> ExitStatus { } #elseif SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD) /// A mapping of awaited child PIDs to their corresponding Swift continuations. -private let _childProcessContinuations = LockedWith]>() +private nonisolated(unsafe) let _childProcessContinuations = { + let result = ManagedBuffer<[pid_t: CheckedContinuation], pthread_mutex_t>.create( + minimumCapacity: 1, + makingHeaderWith: { _ in [:] } + ) + + result.withUnsafeMutablePointers { _, lock in + _ = pthread_mutex_init(lock, nil) + } + + return result +}() + +/// Access the value in `_childProcessContinuations` while guarded by its lock. +/// +/// - Parameters: +/// - body: A closure to invoke while the lock is held. +/// +/// - Returns: Whatever is returned by `body`. +/// +/// - Throws: Whatever is thrown by `body`. +private func _withLockedChildProcessContinuations( + _ body: ( + _ childProcessContinuations: inout [pid_t: CheckedContinuation], + _ lock: UnsafeMutablePointer + ) throws -> R +) rethrows -> R { + try _childProcessContinuations.withUnsafeMutablePointers { childProcessContinuations, lock in + _ = pthread_mutex_lock(lock) + defer { + _ = pthread_mutex_unlock(lock) + } + + return try body(&childProcessContinuations.pointee, lock) + } +} /// A condition variable used to suspend the waiter thread created by /// `_createWaitThread()` when there are no child processes to await. private nonisolated(unsafe) let _waitThreadNoChildrenCondition = { -#if os(FreeBSD) || os(OpenBSD) - let result = UnsafeMutablePointer.allocate(capacity: 1) -#else let result = UnsafeMutablePointer.allocate(capacity: 1) -#endif _ = pthread_cond_init(result, nil) return result }() @@ -116,7 +147,7 @@ private let _createWaitThread: Void = { var siginfo = siginfo_t() if 0 == waitid(P_ALL, 0, &siginfo, WEXITED | WNOWAIT) { if case let pid = siginfo.si_pid, pid != 0 { - let continuation = _childProcessContinuations.withLock { childProcessContinuations in + let continuation = _withLockedChildProcessContinuations { childProcessContinuations, _ in childProcessContinuations.removeValue(forKey: pid) } @@ -137,7 +168,7 @@ private let _createWaitThread: Void = { // newly-scheduled waiter process. (If this condition is spuriously // woken, we'll just loop again, which is fine.) Note that we read errno // outside the lock in case acquiring the lock perturbs it. - _childProcessContinuations.withUnsafeUnderlyingLock { lock, childProcessContinuations in + _withLockedChildProcessContinuations { childProcessContinuations, lock in if childProcessContinuations.isEmpty { _ = pthread_cond_wait(_waitThreadNoChildrenCondition, lock) } @@ -167,9 +198,9 @@ private let _createWaitThread: Void = { _ = _pthread_setname_np?(pthread_self(), "SWT ExT monitor") #endif #elseif os(FreeBSD) - _ = pthread_set_name_np(pthread_self(), "SWT ex test monitor") + pthread_set_name_np(pthread_self(), "SWT ex test monitor") #elseif os(OpenBSD) - _ = pthread_set_name_np(pthread_self(), "SWT exit test monitor") + pthread_set_name_np(pthread_self(), "SWT exit test monitor") #else #warning("Platform-specific implementation missing: thread naming unavailable") #endif @@ -209,7 +240,7 @@ func wait(for pid: consuming pid_t) async throws -> ExitStatus { _createWaitThread return try await withCheckedThrowingContinuation { continuation in - _childProcessContinuations.withLock { childProcessContinuations in + _withLockedChildProcessContinuations { childProcessContinuations, _ in // We don't need to worry about a race condition here because waitid() // does not clear the wait/zombie state of the child process. If it sees // the child process has terminated and manages to acquire the lock before diff --git a/Sources/Testing/Expectations/Expectation+Macro.swift b/Sources/Testing/Expectations/Expectation+Macro.swift index d14920547..ea007f667 100644 --- a/Sources/Testing/Expectations/Expectation+Macro.swift +++ b/Sources/Testing/Expectations/Expectation+Macro.swift @@ -131,6 +131,72 @@ public macro require( // MARK: - Matching errors by type +/// Check that an expression always throws an error of a given type. +/// +/// - Parameters: +/// - errorType: The type of error that is expected to be thrown. If +/// `expression` could throw _any_ error, or the specific type of thrown +/// error is unimportant, pass `(any Error).self`. +/// - comment: A comment describing the expectation. +/// - sourceLocation: The source location to which recorded expectations and +/// issues should be attributed. +/// - expression: The expression to be evaluated. +/// +/// - Returns: If the expectation passes, the instance of `errorType` that was +/// thrown by `expression`. If the expectation fails, the result is `nil`. +/// +/// Use this overload of `#expect()` when the expression `expression` _should_ +/// throw an error of a given type: +/// +/// ```swift +/// #expect(throws: EngineFailureError.self) { +/// FoodTruck.shared.engine.batteryLevel = 0 +/// try FoodTruck.shared.engine.start() +/// } +/// ``` +/// +/// If `expression` does not throw an error, or if it throws an error that is +/// not an instance of `errorType`, an ``Issue`` is recorded for the test that +/// is running in the current task. Any value returned by `expression` is +/// discarded. +/// +/// - Note: If you use this macro with a Swift compiler version lower than 6.1, +/// it doesn't return a value. +/// +/// If the thrown error need only equal another instance of [`Error`](https://developer.apple.com/documentation/swift/error), +/// use ``expect(throws:_:sourceLocation:performing:)-7du1h`` instead. +/// +/// ## Expressions that should never throw +/// +/// If the expression `expression` should _never_ throw any error, you can pass +/// [`Never.self`](https://developer.apple.com/documentation/swift/never): +/// +/// ```swift +/// #expect(throws: Never.self) { +/// FoodTruck.shared.engine.batteryLevel = 100 +/// try FoodTruck.shared.engine.start() +/// } +/// ``` +/// +/// If `expression` throws an error, an ``Issue`` is recorded for the test that +/// is running in the current task. Any value returned by `expression` is +/// discarded. +/// +/// Test functions can be annotated with `throws` and can throw errors which are +/// then recorded as issues when the test runs. If the intent is for a test to +/// fail when an error is thrown by `expression`, rather than to explicitly +/// check that an error is _not_ thrown by it, do not use this macro. Instead, +/// simply call the code in question and allow it to throw an error naturally. +@discardableResult +@freestanding(expression) +@_documentation(visibility: private) +public macro expect( + throws errorType: E.Type, + _ comment: @autoclosure () -> Comment? = nil, + sourceLocation: SourceLocation = #_sourceLocation, + performing expression: () throws -> R +) -> E? = #externalMacro(module: "TestingMacros", type: "ExpectMacro") where E: Error + /// Check that an expression always throws an error of a given type. /// /// - Parameters: @@ -195,6 +261,56 @@ public macro require( performing expression: () async throws -> R ) -> E? = #externalMacro(module: "TestingMacros", type: "ExpectMacro") where E: Error +/// Check that an expression always throws an error of a given type, and throw +/// an error if it does not. +/// +/// - Parameters: +/// - errorType: The type of error that is expected to be thrown. If +/// `expression` could throw _any_ error, or the specific type of thrown +/// error is unimportant, pass `(any Error).self`. +/// - comment: A comment describing the expectation. +/// - sourceLocation: The source location to which recorded expectations and +/// issues should be attributed. +/// - expression: The expression to be evaluated. +/// +/// - Returns: The instance of `errorType` that was thrown by `expression`. +/// +/// - Throws: An instance of ``ExpectationFailedError`` if `expression` does not +/// throw a matching error. The error thrown by `expression` is not rethrown. +/// +/// Use this overload of `#require()` when the expression `expression` _should_ +/// throw an error of a given type: +/// +/// ```swift +/// try #require(throws: EngineFailureError.self) { +/// FoodTruck.shared.engine.batteryLevel = 0 +/// try FoodTruck.shared.engine.start() +/// } +/// ``` +/// +/// If `expression` does not throw an error, or if it throws an error that is +/// not an instance of `errorType`, an ``Issue`` is recorded for the test that +/// is running in the current task and an instance of ``ExpectationFailedError`` +/// is thrown. Any value returned by `expression` is discarded. +/// +/// - Note: If you use this macro with a Swift compiler version lower than 6.1, +/// it doesn't return a value. +/// +/// If the thrown error need only equal another instance of [`Error`](https://developer.apple.com/documentation/swift/error), +/// use ``require(throws:_:sourceLocation:performing:)-4djuw`` instead. +/// +/// If `expression` should _never_ throw, simply invoke the code without using +/// this macro. The test will then fail if an error is thrown. +@discardableResult +@freestanding(expression) +@_documentation(visibility: private) +public macro require( + throws errorType: E.Type, + _ comment: @autoclosure () -> Comment? = nil, + sourceLocation: SourceLocation = #_sourceLocation, + performing expression: () throws -> R +) -> E = #externalMacro(module: "TestingMacros", type: "RequireThrowsMacro") where E: Error + /// Check that an expression always throws an error of a given type, and throw /// an error if it does not. /// @@ -243,6 +359,26 @@ public macro require( performing expression: () async throws -> R ) -> E = #externalMacro(module: "TestingMacros", type: "RequireThrowsMacro") where E: Error +/// Check that an expression never throws an error, and throw an error if it +/// does. +/// +/// - Parameters: +/// - comment: A comment describing the expectation. +/// - sourceLocation: The source location to which recorded expectations and +/// issues should be attributed. +/// - expression: The expression to be evaluated. +/// +/// - Throws: An instance of ``ExpectationFailedError`` if `expression` throws +/// any error. The error thrown by `expression` is not rethrown. +@freestanding(expression) +@_documentation(visibility: private) +public macro require( + throws _: Never.Type, + _ comment: @autoclosure () -> Comment? = nil, + sourceLocation: SourceLocation = #_sourceLocation, + performing expression: () throws -> R +) = #externalMacro(module: "TestingMacros", type: "RequireThrowsNeverMacro") + /// Check that an expression never throws an error, and throw an error if it /// does. /// @@ -265,6 +401,48 @@ public macro require( // MARK: - Matching instances of equatable errors +/// Check that an expression always throws a specific error. +/// +/// - Parameters: +/// - error: The error that is expected to be thrown. +/// - comment: A comment describing the expectation. +/// - sourceLocation: The source location to which recorded expectations and +/// issues should be attributed. +/// - expression: The expression to be evaluated. +/// +/// - Returns: If the expectation passes, the instance of `E` that was thrown by +/// `expression` and is equal to `error`. If the expectation fails, the result +/// is `nil`. +/// +/// Use this overload of `#expect()` when the expression `expression` _should_ +/// throw a specific error: +/// +/// ```swift +/// #expect(throws: EngineFailureError.batteryDied) { +/// FoodTruck.shared.engine.batteryLevel = 0 +/// try FoodTruck.shared.engine.start() +/// } +/// ``` +/// +/// If `expression` does not throw an error, or if it throws an error that is +/// not equal to `error`, an ``Issue`` is recorded for the test that is running +/// in the current task. Any value returned by `expression` is discarded. +/// +/// - Note: If you use this macro with a Swift compiler version lower than 6.1, +/// it doesn't return a value. +/// +/// If the thrown error need only be an instance of a particular type, use +/// ``expect(throws:_:sourceLocation:performing:)-1hfms`` instead. +@discardableResult +@freestanding(expression) +@_documentation(visibility: private) +public macro expect( + throws error: E, + _ comment: @autoclosure () -> Comment? = nil, + sourceLocation: SourceLocation = #_sourceLocation, + performing expression: () throws -> R +) -> E? = #externalMacro(module: "TestingMacros", type: "ExpectMacro") where E: Error & Equatable + /// Check that an expression always throws a specific error. /// /// - Parameters: @@ -315,6 +493,52 @@ public macro require( /// issues should be attributed. /// - expression: The expression to be evaluated. +/// - Returns: The instance of `E` that was thrown by `expression` and is equal +/// to `error`. +/// +/// - Throws: An instance of ``ExpectationFailedError`` if `expression` does not +/// throw a matching error. The error thrown by `expression` is not rethrown. +/// +/// Use this overload of `#require()` when the expression `expression` _should_ +/// throw a specific error: +/// +/// ```swift +/// try #require(throws: EngineFailureError.batteryDied) { +/// FoodTruck.shared.engine.batteryLevel = 0 +/// try FoodTruck.shared.engine.start() +/// } +/// ``` +/// +/// If `expression` does not throw an error, or if it throws an error that is +/// not equal to `error`, an ``Issue`` is recorded for the test that is running +/// in the current task and an instance of ``ExpectationFailedError`` is thrown. +/// Any value returned by `expression` is discarded. +/// +/// - Note: If you use this macro with a Swift compiler version lower than 6.1, +/// it doesn't return a value. +/// +/// If the thrown error need only be an instance of a particular type, use +/// ``require(throws:_:sourceLocation:performing:)-7n34r`` instead. +@discardableResult +@freestanding(expression) +@_documentation(visibility: private) +public macro require( + throws error: E, + _ comment: @autoclosure () -> Comment? = nil, + sourceLocation: SourceLocation = #_sourceLocation, + performing expression: () throws -> R +) -> E = #externalMacro(module: "TestingMacros", type: "RequireMacro") where E: Error & Equatable + +/// Check that an expression always throws a specific error, and throw an error +/// if it does not. +/// +/// - Parameters: +/// - error: The error that is expected to be thrown. +/// - comment: A comment describing the expectation. +/// - sourceLocation: The source location to which recorded expectations and +/// issues should be attributed. +/// - expression: The expression to be evaluated. + /// - Returns: The instance of `E` that was thrown by `expression` and is equal /// to `error`. /// @@ -351,6 +575,70 @@ public macro require( // MARK: - Arbitrary error matching +/// Check that an expression always throws an error matching some condition. +/// +/// - Parameters: +/// - comment: A comment describing the expectation. +/// - sourceLocation: The source location to which recorded expectations and +/// issues should be attributed. +/// - expression: The expression to be evaluated. +/// - errorMatcher: A closure to invoke when `expression` throws an error that +/// indicates if it matched or not. +/// +/// - Returns: If the expectation passes, the error that was thrown by +/// `expression`. If the expectation fails, the result is `nil`. +/// +/// Use this overload of `#expect()` when the expression `expression` _should_ +/// throw an error, but the logic to determine if the error matches is complex: +/// +/// ```swift +/// #expect { +/// FoodTruck.shared.engine.batteryLevel = 0 +/// try FoodTruck.shared.engine.start() +/// } throws: { error in +/// return error == EngineFailureError.batteryDied +/// || error == EngineFailureError.stillCharging +/// } +/// ``` +/// +/// If `expression` does not throw an error, if it throws an error that is +/// not matched by `errorMatcher`, or if `errorMatcher` throws an error +/// (including the error passed to it), an ``Issue`` is recorded for the test +/// that is running in the current task. Any value returned by `expression` is +/// discarded. +/// +/// If the thrown error need only be an instance of a particular type, use +/// ``expect(throws:_:sourceLocation:performing:)-1hfms`` instead. If the thrown +/// error need only equal another instance of [`Error`](https://developer.apple.com/documentation/swift/error), +/// use ``expect(throws:_:sourceLocation:performing:)-7du1h`` instead. +/// +/// @Metadata { +/// @Available(Swift, introduced: 6.0) +/// @Available(Xcode, introduced: 16.0) +/// } +/// +/// @DeprecationSummary { +/// Examine the result of ``expect(throws:_:sourceLocation:performing:)-7du1h`` +/// or ``expect(throws:_:sourceLocation:performing:)-1hfms`` instead: +/// +/// ```swift +/// let error = #expect(throws: FoodTruckError.self) { +/// ... +/// } +/// #expect(error?.napkinCount == 0) +/// ``` +/// } +@available(swift, deprecated: 100000.0, message: "Examine the result of '#expect(throws:)' instead.") +@discardableResult +@freestanding(expression) +@_documentation(visibility: private) +public macro expect( + _ comment: @autoclosure () -> Comment? = nil, + sourceLocation: SourceLocation = #_sourceLocation, + performing expression: () throws -> R, + throws errorMatcher: (any Error) throws -> Bool +) -> (any Error)? = #externalMacro(module: "TestingMacros", type: "ExpectMacro") + /// Check that an expression always throws an error matching some condition. /// /// - Parameters: @@ -413,6 +701,77 @@ public macro require( throws errorMatcher: (any Error) async throws -> Bool ) -> (any Error)? = #externalMacro(module: "TestingMacros", type: "ExpectMacro") +/// Check that an expression always throws an error matching some condition, and +/// throw an error if it does not. +/// +/// - Parameters: +/// - comment: A comment describing the expectation. +/// - sourceLocation: The source location to which recorded expectations and +/// issues should be attributed. +/// - expression: The expression to be evaluated. +/// - errorMatcher: A closure to invoke when `expression` throws an error that +/// indicates if it matched or not. +/// +/// - Returns: The error that was thrown by `expression`. +/// +/// - Throws: An instance of ``ExpectationFailedError`` if `expression` does not +/// throw a matching error. The error thrown by `expression` is not rethrown. +/// +/// Use this overload of `#require()` when the expression `expression` _should_ +/// throw an error, but the logic to determine if the error matches is complex: +/// +/// ```swift +/// #expect { +/// FoodTruck.shared.engine.batteryLevel = 0 +/// try FoodTruck.shared.engine.start() +/// } throws: { error in +/// return error == EngineFailureError.batteryDied +/// || error == EngineFailureError.stillCharging +/// } +/// ``` +/// +/// If `expression` does not throw an error, if it throws an error that is +/// not matched by `errorMatcher`, or if `errorMatcher` throws an error +/// (including the error passed to it), an ``Issue`` is recorded for the test +/// that is running in the current task and an instance of +/// ``ExpectationFailedError`` is thrown. Any value returned by `expression` is +/// discarded. +/// +/// If the thrown error need only be an instance of a particular type, use +/// ``require(throws:_:sourceLocation:performing:)-7n34r`` instead. If the thrown error need +/// only equal another instance of [`Error`](https://developer.apple.com/documentation/swift/error), +/// use ``require(throws:_:sourceLocation:performing:)-4djuw`` instead. +/// +/// If `expression` should _never_ throw, simply invoke the code without using +/// this macro. The test will then fail if an error is thrown. +/// +/// @Metadata { +/// @Available(Swift, introduced: 6.0) +/// @Available(Xcode, introduced: 16.0) +/// } +/// +/// @DeprecationSummary { +/// Examine the result of ``expect(throws:_:sourceLocation:performing:)-7du1h`` +/// or ``expect(throws:_:sourceLocation:performing:)-1hfms`` instead: +/// +/// ```swift +/// let error = try #require(throws: FoodTruckError.self) { +/// ... +/// } +/// #expect(error.napkinCount == 0) +/// ``` +/// } +@available(swift, deprecated: 100000.0, message: "Examine the result of '#require(throws:)' instead.") +@discardableResult +@freestanding(expression) +@_documentation(visibility: private) +public macro require( + _ comment: @autoclosure () -> Comment? = nil, + sourceLocation: SourceLocation = #_sourceLocation, + performing expression: () throws -> R, + throws errorMatcher: (any Error) throws -> Bool +) -> any Error = #externalMacro(module: "TestingMacros", type: "RequireMacro") + /// Check that an expression always throws an error matching some condition, and /// throw an error if it does not. /// @@ -513,17 +872,20 @@ public macro require( /// /// @Metadata { /// @Available(Swift, introduced: 6.2) +/// @Available(Xcode, introduced: 26.0) /// } +@freestanding(expression) +@discardableResult #if SWT_NO_EXIT_TESTS +@_unavailableInEmbedded @available(*, unavailable, message: "Exit tests are not available on this platform.") #endif -@discardableResult -@freestanding(expression) public macro expect( +public macro expect( processExitsWith expectedExitCondition: ExitTest.Condition, observing observedValues: [any PartialKeyPath & Sendable] = [], _ comment: @autoclosure () -> Comment? = nil, sourceLocation: SourceLocation = #_sourceLocation, - performing expression: @escaping @Sendable @convention(thin) () async throws -> Void + performing expression: @escaping @Sendable () async throws -> Void ) -> ExitTest.Result? = #externalMacro(module: "TestingMacros", type: "ExitTestExpectMacro") /// Check that an expression causes the process to terminate in a given fashion @@ -558,15 +920,77 @@ public macro require( /// /// @Metadata { /// @Available(Swift, introduced: 6.2) +/// @Available(Xcode, introduced: 26.0) /// } +@freestanding(expression) +@discardableResult #if SWT_NO_EXIT_TESTS +@_unavailableInEmbedded @available(*, unavailable, message: "Exit tests are not available on this platform.") #endif -@discardableResult -@freestanding(expression) public macro require( +public macro require( processExitsWith expectedExitCondition: ExitTest.Condition, observing observedValues: [any PartialKeyPath & Sendable] = [], _ comment: @autoclosure () -> Comment? = nil, sourceLocation: SourceLocation = #_sourceLocation, - performing expression: @escaping @Sendable @convention(thin) () async throws -> Void + performing expression: @escaping @Sendable () async throws -> Void ) -> ExitTest.Result = #externalMacro(module: "TestingMacros", type: "ExitTestRequireMacro") + +/// Capture a sendable and codable value to pass to an exit test. +/// +/// - Parameters: +/// - value: The captured value. +/// - name: The name of the capture list item corresponding to `value`. +/// - expectedType: The type of `value`. +/// +/// - Returns: `value` verbatim. +/// +/// - Warning: This macro is used to implement the `#expect(processExitsWith:)` +/// macro. Do not use it directly. +@freestanding(expression) +public macro __capturedValue( + _ value: T, + _ name: String, + _ expectedType: T.Type +) -> T = #externalMacro(module: "TestingMacros", type: "ExitTestCapturedValueMacro") where T: Sendable & Codable + +/// Emit a compile-time diagnostic when an unsupported value is captured by an +/// exit test. +/// +/// - Parameters: +/// - value: The captured value. +/// - name: The name of the capture list item corresponding to `value`. +/// - expectedType: The type of `value`. +/// +/// - Returns: The result of a call to `fatalError()`. `value` is discarded at +/// compile time. +/// +/// - Warning: This macro is used to implement the `#expect(processExitsWith:)` +/// macro. Do not use it directly. +@freestanding(expression) +public macro __capturedValue( + _ value: borrowing T, + _ name: String, + _ expectedType: T.Type +) -> Never = #externalMacro(module: "TestingMacros", type: "ExitTestBadCapturedValueMacro") where T: ~Copyable & ~Escapable + +/// Emit a compile-time diagnostic when a value is captured by an exit test but +/// we inferred the wrong type. +/// +/// - Parameters: +/// - value: The captured value. +/// - name: The name of the capture list item corresponding to `value`. +/// - expectedType: The _expected_ type of `value`, which will differ from the +/// _actual_ type of `value`. +/// +/// - Returns: The result of a call to `fatalError()`. `value` is discarded at +/// compile time. +/// +/// - Warning: This macro is used to implement the `#expect(processExitsWith:)` +/// macro. Do not use it directly. +@freestanding(expression) +public macro __capturedValue( + _ value: borrowing T, + _ name: String, + _ expectedType: U.Type +) -> T = #externalMacro(module: "TestingMacros", type: "ExitTestIncorrectlyCapturedValueMacro") where T: ~Copyable & ~Escapable, U: ~Copyable & ~Escapable diff --git a/Sources/Testing/Expectations/ExpectationChecking+Macro.swift b/Sources/Testing/Expectations/ExpectationChecking+Macro.swift index 6d3093f2a..ed81d1f59 100644 --- a/Sources/Testing/Expectations/ExpectationChecking+Macro.swift +++ b/Sources/Testing/Expectations/ExpectationChecking+Macro.swift @@ -198,6 +198,7 @@ private func _callBinaryOperator( /// /// - Warning: This function is used to implement the `#expect()` and /// `#require()` macros. Do not call it directly. +@_disfavoredOverload public func __checkFunctionCall( _ lhs: T, calling functionCall: (T, repeat each U) throws -> Bool, _ arguments: repeat each U, expression: __Expression, @@ -367,6 +368,7 @@ public func __checkInoutFunctionCall( /// /// - Warning: This function is used to implement the `#expect()` and /// `#require()` macros. Do not call it directly. +@_disfavoredOverload public func __checkFunctionCall( _ lhs: T, calling functionCall: (T, repeat each U) throws -> R?, _ arguments: repeat each U, expression: __Expression, @@ -1148,7 +1150,7 @@ public func __checkClosureCall( identifiedBy exitTestID: (UInt64, UInt64, UInt64, UInt64), processExitsWith expectedExitCondition: ExitTest.Condition, observing observedValues: [any PartialKeyPath & Sendable] = [], - performing _: @convention(thin) () -> Void, + performing _: @convention(c) () -> Void, expression: __Expression, comments: @autoclosure () -> [Comment], isRequired: Bool, @@ -1175,13 +1177,12 @@ public func __checkClosureCall( /// /// - 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), processExitsWith expectedExitCondition: ExitTest.Condition, observing observedValues: [any PartialKeyPath & Sendable] = [], - performing _: @convention(thin) () -> Void, + performing _: @convention(c) () -> Void, expression: __Expression, comments: @autoclosure () -> [Comment], isRequired: Bool, diff --git a/Sources/Testing/Issues/Confirmation.swift b/Sources/Testing/Issues/Confirmation.swift index 21baec505..33b0ec25a 100644 --- a/Sources/Testing/Issues/Confirmation.swift +++ b/Sources/Testing/Issues/Confirmation.swift @@ -8,6 +8,8 @@ // See https://swift.org/CONTRIBUTORS.txt for Swift project authors // +private import _TestingInternals + /// A type that can be used to confirm that an event occurs zero or more times. public struct Confirmation: Sendable { /// The number of times ``confirm(count:)`` has been called. @@ -202,7 +204,7 @@ public func confirmation( sourceLocation: SourceLocation = #_sourceLocation, _ body: (Confirmation) async throws -> R ) async rethrows -> R { - fatalError("Unsupported") + swt_unreachable() } /// An overload of ``confirmation(_:expectedCount:isolation:sourceLocation:_:)-l3il`` @@ -218,7 +220,7 @@ public func confirmation( sourceLocation: SourceLocation = #_sourceLocation, _ body: (Confirmation) async throws -> R ) async rethrows -> R { - fatalError("Unsupported") + swt_unreachable() } /// An overload of ``confirmation(_:expectedCount:isolation:sourceLocation:_:)-l3il`` @@ -234,5 +236,5 @@ public func confirmation( sourceLocation: SourceLocation = #_sourceLocation, _ body: (Confirmation) async throws -> R ) async rethrows -> R { - fatalError("Unsupported") + swt_unreachable() } diff --git a/Sources/Testing/Issues/Issue+Recording.swift b/Sources/Testing/Issues/Issue+Recording.swift index b79e94269..72f4c65a4 100644 --- a/Sources/Testing/Issues/Issue+Recording.swift +++ b/Sources/Testing/Issues/Issue+Recording.swift @@ -50,7 +50,7 @@ extension Issue { return self } - /// Record an issue when a running test fails unexpectedly. + /// Records an issue that a test encounters while it's running. /// /// - Parameters: /// - comment: A comment describing the expectation. @@ -62,6 +62,9 @@ extension Issue { /// 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.) + @_disfavoredOverload + @_documentation(visibility: private) + @available(*, deprecated, message: "Use record(_:severity:sourceLocation:) instead.") @discardableResult public static func record( _ comment: Comment? = nil, sourceLocation: SourceLocation = #_sourceLocation @@ -69,11 +72,13 @@ extension Issue { record(comment, severity: .error, sourceLocation: sourceLocation) } - /// Record an issue when a running test fails unexpectedly. + /// Records an issue that a test encounters while it's running. /// /// - Parameters: /// - comment: A comment describing the expectation. - /// - severity: The severity of the issue. + /// - severity: The severity level of the issue. The testing library marks the + /// test as failed if the severity is greater than ``Issue/Severity/warning``. + /// The default is ``Issue/Severity/error``. /// - sourceLocation: The source location to which the issue should be /// attributed. /// @@ -82,10 +87,13 @@ extension Issue { /// 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) + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } @discardableResult public static func record( _ comment: Comment? = nil, - severity: Severity, + severity: Severity = .error, sourceLocation: SourceLocation = #_sourceLocation ) -> Self { let sourceContext = SourceContext(backtrace: .current(), sourceLocation: sourceLocation) @@ -151,7 +159,8 @@ extension Issue { /// allowing it to propagate to the caller. /// /// - Parameters: - /// - sourceLocation: The source location to attribute any caught error to. + /// - sourceLocation: The source location to attribute any caught error to, + /// if available. /// - configuration: The test configuration to use when recording an issue. /// The default value is ``Configuration/current``. /// - body: A closure that might throw an error. @@ -160,7 +169,7 @@ extension Issue { /// caught, otherwise `nil`. @discardableResult static func withErrorRecording( - at sourceLocation: SourceLocation, + at sourceLocation: SourceLocation?, configuration: Configuration? = nil, _ body: () throws -> Void ) -> (any Error)? { @@ -177,6 +186,9 @@ extension Issue { // This error is thrown by expectation checking functions to indicate a // condition evaluated to `false`. Those functions record their own issue, // so we don't need to record another one redundantly. + } catch let error where SkipInfo(error) != nil { + // This error represents control flow rather than an issue, so we suppress + // it here. } catch { let issue = Issue(for: error, sourceLocation: sourceLocation) issue.record(configuration: configuration) @@ -190,7 +202,8 @@ extension Issue { /// issue instead of allowing it to propagate to the caller. /// /// - Parameters: - /// - sourceLocation: The source location to attribute any caught error to. + /// - sourceLocation: The source location to attribute any caught error to, + /// if available. /// - configuration: The test configuration to use when recording an issue. /// The default value is ``Configuration/current``. /// - isolation: The actor to which `body` is isolated, if any. @@ -200,7 +213,7 @@ extension Issue { /// caught, otherwise `nil`. @discardableResult static func withErrorRecording( - at sourceLocation: SourceLocation, + at sourceLocation: SourceLocation?, configuration: Configuration? = nil, isolation: isolated (any Actor)? = #isolation, _ body: () async throws -> Void @@ -218,6 +231,9 @@ extension Issue { // This error is thrown by expectation checking functions to indicate a // condition evaluated to `false`. Those functions record their own issue, // so we don't need to record another one redundantly. + } catch let error where SkipInfo(error) != nil { + // This error represents control flow rather than an issue, so we suppress + // it here. } catch { let issue = Issue(for: error, sourceLocation: sourceLocation) issue.record(configuration: configuration) diff --git a/Sources/Testing/Issues/Issue.swift b/Sources/Testing/Issues/Issue.swift index 9a2555177..70e4a01a9 100644 --- a/Sources/Testing/Issues/Issue.swift +++ b/Sources/Testing/Issues/Issue.swift @@ -13,7 +13,7 @@ public struct Issue: Sendable { /// Kinds of issues which may be recorded. public enum Kind: Sendable { /// An issue which occurred unconditionally, for example by using - /// ``Issue/record(_:sourceLocation:)``. + /// ``Issue/record(_:severity:sourceLocation:)``. case unconditional /// An issue due to a failed expectation, such as those produced by @@ -84,24 +84,38 @@ public struct Issue: Sendable { /// /// - ``warning`` /// - ``error`` - @_spi(Experimental) + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } public enum Severity: Sendable { /// The severity level for an issue which should be noted but is not /// necessarily an error. /// /// An issue with warning severity does not cause the test it's associated /// with to be marked as a failure, but is noted in the results. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } case warning /// The severity level for an issue which represents an error in a test. /// /// An issue with error severity causes the test it's associated with to be /// marked as a failure. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } case error } /// The severity of this issue. - @_spi(Experimental) + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } public var severity: Severity /// Whether or not this issue should cause the test it's associated with to be @@ -114,7 +128,10 @@ public struct Issue: Sendable { /// /// 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) + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } public var isFailure: Bool { return !self.isKnown && self.severity >= .error } @@ -324,7 +341,10 @@ extension Issue { public var kind: Kind.Snapshot /// The severity of this issue. - @_spi(Experimental) + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.3) + /// } public var severity: Severity /// Any comments provided by the developer and associated with this issue. @@ -397,7 +417,7 @@ extension Issue.Kind { @_spi(ForToolsIntegrationOnly) public enum Snapshot: Sendable, Codable { /// An issue which occurred unconditionally, for example by using - /// ``Issue/record(_:sourceLocation:)``. + /// ``Issue/record(_:severity:sourceLocation:)``. case unconditional /// An issue due to a failed expectation, such as those produced by diff --git a/Sources/Testing/Parameterization/TypeInfo.swift b/Sources/Testing/Parameterization/TypeInfo.swift index b0ed814b9..50026ec17 100644 --- a/Sources/Testing/Parameterization/TypeInfo.swift +++ b/Sources/Testing/Parameterization/TypeInfo.swift @@ -79,7 +79,7 @@ public struct TypeInfo: Sendable { /// /// - Parameters: /// - type: The type which this instance should describe. - init(describing type: any ~Copyable.Type) { + init(describing type: (some ~Copyable).Type) { _kind = .type(type) } @@ -88,8 +88,21 @@ public struct TypeInfo: Sendable { /// /// - Parameters: /// - value: The value whose type this instance should describe. - init(describingTypeOf value: Any) { - self.init(describing: Swift.type(of: value)) + init(describingTypeOf value: some Any) { +#if !hasFeature(Embedded) + let value = value as Any +#endif + let type = Swift.type(of: value) + self.init(describing: type) + } + + /// Initialize an instance of this type describing the type of the specified + /// value. + /// + /// - Parameters: + /// - value: The value whose type this instance should describe. + init(describingTypeOf value: borrowing T) where T: ~Copyable { + self.init(describing: T.self) } } @@ -142,6 +155,35 @@ func rawIdentifierAwareSplit(_ string: S, separator: Character, maxSplits: In } extension TypeInfo { + /// Replace any non-breaking spaces in the given string with normal spaces. + /// + /// - Parameters: + /// - rawIdentifier: The string to rewrite. + /// + /// - Returns: A copy of `rawIdentifier` with non-breaking spaces (`U+00A0`) + /// replaced with normal spaces (`U+0020`). + /// + /// When the Swift runtime demangles a raw identifier, it [replaces](https://github.com/swiftlang/swift/blob/d033eec1aa427f40dcc38679d43b83d9dbc06ae7/lib/Basic/Mangler.cpp#L250) + /// normal ASCII spaces with non-breaking spaces to maintain compatibility + /// with historical usages of spaces in mangled name forms. Non-breaking + /// spaces are not otherwise valid in raw identifiers, so this transformation + /// is reversible. + private static func _rewriteNonBreakingSpacesAsASCIISpaces(in rawIdentifier: some StringProtocol) -> String? { + let nbsp = "\u{00A0}" as UnicodeScalar + + // If there are no non-breaking spaces in the string, exit early to avoid + // any further allocations. + let unicodeScalars = rawIdentifier.unicodeScalars + guard unicodeScalars.contains(nbsp) else { + return nil + } + + // Replace non-breaking spaces, then construct a new string from the + // resulting sequence. + let result = unicodeScalars.lazy.map { $0 == nbsp ? " " : $0 } + return String(String.UnicodeScalarView(result)) + } + /// An in-memory cache of fully-qualified type name components. private static let _fullyQualifiedNameComponentsCache = Locked<[ObjectIdentifier: [String]]>() @@ -166,12 +208,21 @@ extension TypeInfo { components[0] = moduleName } - // If a type is private or embedded in a function, its fully qualified - // name may include "(unknown context at $xxxxxxxx)" as a component. Strip - // those out as they're uninteresting to us. - components = components.filter { !$0.starts(with: "(unknown context at") } - - return components.map(String.init) + return components.lazy + .filter { component in + // If a type is private or embedded in a function, its fully qualified + // name may include "(unknown context at $xxxxxxxx)" as a component. + // Strip those out as they're uninteresting to us. + !component.starts(with: "(unknown context at") + }.map { component in + // Replace non-breaking spaces with spaces. See the helper function's + // documentation for more information. + if let component = _rewriteNonBreakingSpacesAsASCIISpaces(in: component) { + component[...] + } else { + component + } + }.map(String.init) } /// The complete name of this type, with the names of all referenced types @@ -242,9 +293,14 @@ extension TypeInfo { public var unqualifiedName: String { switch _kind { case let .type(type): - String(describing: type) + // Replace non-breaking spaces with spaces. See the helper function's + // documentation for more information. + var result = String(describing: type) + result = Self._rewriteNonBreakingSpacesAsASCIISpaces(in: result) ?? result + + return result case let .nameOnly(_, unqualifiedName, _): - unqualifiedName + return unqualifiedName } } @@ -265,11 +321,7 @@ extension TypeInfo { } switch _kind { case let .type(type): -#if compiler(>=6.1) return _mangledTypeName(type) -#else - return _mangledTypeName(unsafeBitCast(type, to: Any.Type.self)) -#endif case let .nameOnly(_, _, mangledName): return mangledName } @@ -321,13 +373,10 @@ extension TypeInfo { /// - Returns: Whether `subclass` is a subclass of, or is equal to, /// `superclass`. func isClass(_ subclass: AnyClass, subclassOf superclass: AnyClass) -> Bool { - if subclass == superclass { - true - } else if let subclassImmediateSuperclass = _getSuperclass(subclass) { - isClass(subclassImmediateSuperclass, subclassOf: superclass) - } else { - false + func open(_: T.Type, _: U.Type) -> Bool where T: AnyObject, U: AnyObject { + T.self is U.Type } + return open(subclass, superclass) } // MARK: - CustomStringConvertible, CustomDebugStringConvertible, CustomTestStringConvertible @@ -369,21 +418,6 @@ extension TypeInfo: Hashable { } } -// MARK: - ObjectIdentifier support - -extension ObjectIdentifier { - /// Initialize an instance of this type from a type reference. - /// - /// - Parameters: - /// - type: The type to initialize this instance from. - /// - /// - Bug: The standard library should support this conversion. - /// ([134276458](rdar://134276458), [134415960](rdar://134415960)) - fileprivate init(_ type: any ~Copyable.Type) { - self.init(unsafeBitCast(type, to: Any.Type.self)) - } -} - // MARK: - Codable extension TypeInfo: Codable { diff --git a/Sources/Testing/Running/Configuration+EventHandling.swift b/Sources/Testing/Running/Configuration+EventHandling.swift index e3c189f8b..68eb2ab91 100644 --- a/Sources/Testing/Running/Configuration+EventHandling.swift +++ b/Sources/Testing/Running/Configuration+EventHandling.swift @@ -23,18 +23,6 @@ extension Configuration { var contextCopy = copy context contextCopy.configuration = self contextCopy.configuration?.eventHandler = { _, _ in } - -#if !SWT_NO_FILE_IO - if case .valueAttached = event.kind { - var eventCopy = copy event - guard handleValueAttachedEvent(&eventCopy, in: contextCopy) else { - // The attachment could not be handled, so suppress this event. - return - } - return eventHandler(eventCopy, contextCopy) - } -#endif - return eventHandler(event, contextCopy) } } diff --git a/Sources/Testing/Running/Configuration.TestFilter.swift b/Sources/Testing/Running/Configuration.TestFilter.swift index 7ef1fb08c..92d811ba1 100644 --- a/Sources/Testing/Running/Configuration.TestFilter.swift +++ b/Sources/Testing/Running/Configuration.TestFilter.swift @@ -514,7 +514,7 @@ extension Configuration.TestFilter.Kind { /// A protocol representing a value which can be filtered using /// ``Configuration/TestFilter-swift.struct``. -private protocol _FilterableItem { +private protocol _FilterableItem: Sendable { /// The test this item represents. var test: Test { get } diff --git a/Sources/Testing/Running/Configuration.swift b/Sources/Testing/Running/Configuration.swift index bca788ec7..e0fe009ba 100644 --- a/Sources/Testing/Running/Configuration.swift +++ b/Sources/Testing/Running/Configuration.swift @@ -8,6 +8,8 @@ // See https://swift.org/CONTRIBUTORS.txt for Swift project authors // +private import _TestingInternals + /// A type containing settings for preparing and running tests. @_spi(ForToolsIntegrationOnly) public struct Configuration: Sendable { @@ -18,7 +20,33 @@ public struct Configuration: Sendable { // MARK: - Parallelization /// Whether or not to parallelize the execution of tests and test cases. - public var isParallelizationEnabled: Bool = true + /// + /// - Note: Setting the value of this property implicitly sets the value of + /// the experimental ``maximumParallelizationWidth`` property. + public var isParallelizationEnabled: Bool { + get { + maximumParallelizationWidth > 1 + } + set { + maximumParallelizationWidth = newValue ? defaultParallelizationWidth : 1 + } + } + + /// The maximum width of parallelization. + /// + /// The value of this property determines how many tests (or rather, test + /// cases) will run in parallel. + /// + /// @Comment { + /// The default value of this property is equal to twice the number of CPU + /// cores reported by the operating system, or `Int.max` if that value is + /// not available. + /// } + /// + /// - Note: Setting the value of this property implicitly sets the value of + /// the ``isParallelizationEnabled`` property. + @_spi(Experimental) + public var maximumParallelizationWidth: Int = defaultParallelizationWidth /// How to symbolicate backtraces captured during a test run. /// @@ -184,13 +212,7 @@ public struct Configuration: Sendable { /// Whether or not events of the kind ``Event/Kind-swift.enum/issueRecorded(_:)`` /// containing issues with warning (or lower) severity should be delivered /// to the event handler of the configuration these options are applied to. - /// - /// By default, events matching this criteria are not delivered to event - /// handlers since this is an experimental feature. - /// - /// - Warning: Warning issues are not yet an approved feature. - @_spi(Experimental) - public var isWarningIssueRecordedEventEnabled: Bool = false + public var isWarningIssueRecordedEventEnabled: Bool = true /// Whether or not events of the kind /// ``Event/Kind-swift.enum/expectationChecked(_:)`` should be delivered to diff --git a/Sources/Testing/Running/Runner.Plan.swift b/Sources/Testing/Running/Runner.Plan.swift index c89fdecb5..92827ad36 100644 --- a/Sources/Testing/Running/Runner.Plan.swift +++ b/Sources/Testing/Running/Runner.Plan.swift @@ -193,6 +193,92 @@ extension Runner.Plan { synthesizeSuites(in: &graph, sourceLocation: &sourceLocation) } + /// The basic "run" action. + private static let _runAction = Action.run(options: .init()) + + /// Determine what action to perform for a given test by preparing its traits. + /// + /// - Parameters: + /// - test: The test whose action will be determined. + /// + /// - Returns:The action to take for `test`. + private static func _determineAction(for test: inout Test) async -> Action { + let result: Action + + // We use a task group here with a single child task so that, if the trait + // code calls Test.cancel() we don't end up cancelling the entire test run. + // We could also model this as an unstructured task except that they aren't + // available in the "task-to-thread" concurrency model. + // + // FIXME: Parallelize this work. Calling `prepare(...)` on all traits and + // evaluating all test arguments should be safely parallelizable. + (test, result) = await withTaskGroup(returning: (Test, Action).self) { [test] taskGroup in + let testName = test.humanReadableName() + let (taskName, taskAction) = if test.isSuite { + ("suite \(testName)", "evaluating traits") + } else { + // TODO: split the task group's single task into two serially-run subtasks + ("test \(testName)", "evaluating traits and test cases") + } + taskGroup.addTask(name: decorateTaskName(taskName, withAction: taskAction)) { + var test = test + var action = _runAction + + await Test.withCurrent(test) { + do { + var firstCaughtError: (any Error)? + + for trait in test.traits { + do { + try await trait.prepare(for: test) + } catch { + if let skipInfo = SkipInfo(error) { + action = .skip(skipInfo) + break + } else { + // Only preserve the first caught error + firstCaughtError = firstCaughtError ?? error + } + } + } + + // If no trait specified that the test should be skipped, but one + // did throw an error, then the action is to record an issue for + // that error. + if case .run = action, let error = firstCaughtError { + action = .recordIssue(Issue(for: error)) + } + } + + // If the test is still planned to run (i.e. nothing thus far has + // caused it to be skipped), evaluate its test cases now. + // + // The argument expressions of each test are captured in closures so + // they can be evaluated lazily only once it is determined that the + // test will run, to avoid unnecessary work. But now is the + // appropriate time to evaluate them. + if case .run = action { + do { + try await test.evaluateTestCases() + } catch { + if let skipInfo = SkipInfo(error) { + action = .skip(skipInfo) + } else { + action = .recordIssue(Issue(for: error)) + } + } + } + } + + return (test, action) + } + + return await taskGroup.first { _ in true }! + } + + return result + } + /// Construct a graph of runner plan steps for the specified tests. /// /// - Parameters: @@ -211,7 +297,7 @@ extension Runner.Plan { // Convert the list of test into a graph of steps. The actions for these // steps will all be .run() *unless* an error was thrown while examining // them, in which case it will be .recordIssue(). - let runAction = Action.run(options: .init()) + let runAction = _runAction var testGraph = Graph() var actionGraph = Graph(value: runAction) for test in tests { @@ -251,9 +337,6 @@ extension Runner.Plan { _recursivelyApplyTraits(to: &testGraph) // For each test value, determine the appropriate action for it. - // - // FIXME: Parallelize this work. Calling `prepare(...)` on all traits and - // evaluating all test arguments should be safely parallelizable. testGraph = await testGraph.mapValues { keyPath, test in // Skip any nil test, which implies this node is just a placeholder and // not actual test content. @@ -261,46 +344,12 @@ extension Runner.Plan { return nil } - var action = runAction - var firstCaughtError: (any Error)? - // Walk all the traits and tell each to prepare to run the test. // If any throw a `SkipInfo` error at this stage, stop walking further. // But if any throw another kind of error, keep track of the first error // but continue walking, because if any subsequent traits throw a // `SkipInfo`, the error should not be recorded. - for trait in test.traits { - do { - try await trait.prepare(for: test) - } catch let error as SkipInfo { - action = .skip(error) - break - } catch { - // Only preserve the first caught error - firstCaughtError = firstCaughtError ?? error - } - } - - // If no trait specified that the test should be skipped, but one did - // throw an error, then the action is to record an issue for that error. - if case .run = action, let error = firstCaughtError { - action = .recordIssue(Issue(for: error)) - } - - // If the test is still planned to run (i.e. nothing thus far has caused - // it to be skipped), evaluate its test cases now. - // - // The argument expressions of each test are captured in closures so they - // can be evaluated lazily only once it is determined that the test will - // run, to avoid unnecessary work. But now is the appropriate time to - // evaluate them. - if case .run = action { - do { - try await test.evaluateTestCases() - } catch { - action = .recordIssue(Issue(for: error)) - } - } + var action = await _determineAction(for: &test) // If the test is parameterized but has no cases, mark it as skipped. if case .run = action, let testCases = test.testCases, testCases.first(where: { _ in true }) == nil { diff --git a/Sources/Testing/Running/Runner.RuntimeState.swift b/Sources/Testing/Running/Runner.RuntimeState.swift index 9ae299412..eb892da62 100644 --- a/Sources/Testing/Running/Runner.RuntimeState.swift +++ b/Sources/Testing/Running/Runner.RuntimeState.swift @@ -47,9 +47,9 @@ extension Runner { return } - configuration.eventHandler = { [eventHandler = configuration.eventHandler] event, context in + configuration.eventHandler = { [oldEventHandler = configuration.eventHandler] event, context in RuntimeState.$current.withValue(existingRuntimeState) { - eventHandler(event, context) + oldEventHandler(event, context) } } } @@ -206,7 +206,10 @@ extension Test { static func withCurrent(_ test: Self, perform body: () async throws -> R) async rethrows -> R { var runtimeState = Runner.RuntimeState.current ?? .init() runtimeState.test = test - return try await Runner.RuntimeState.$current.withValue(runtimeState, operation: body) + runtimeState.testCase = nil + return try await Runner.RuntimeState.$current.withValue(runtimeState) { + try await test.withCancellationHandling(body) + } } } @@ -239,7 +242,9 @@ extension Test.Case { static func withCurrent(_ testCase: Self, perform body: () async throws -> R) async rethrows -> R { var runtimeState = Runner.RuntimeState.current ?? .init() runtimeState.testCase = testCase - return try await Runner.RuntimeState.$current.withValue(runtimeState, operation: body) + return try await Runner.RuntimeState.$current.withValue(runtimeState) { + try await testCase.withCancellationHandling(body) + } } } diff --git a/Sources/Testing/Running/Runner.swift b/Sources/Testing/Running/Runner.swift index 8520d1aaf..a6a76189e 100644 --- a/Sources/Testing/Running/Runner.swift +++ b/Sources/Testing/Running/Runner.swift @@ -66,6 +66,20 @@ extension Runner { .current ?? .init() } + /// Context to apply to a test run. + /// + /// Instances of this type are passed directly to the various functions in + /// this file and represent context for the run itself. As such, they are not + /// task-local nor are they meant to change as the test run progresses. + /// + /// This type is distinct from ``Configuration`` which _can_ change on a + /// per-test basis. If you find yourself wanting to modify a property of this + /// type at runtime, it may be better-suited for ``Configuration`` instead. + private struct _Context: Sendable { + /// A serializer used to reduce parallelism among test cases. + var testCaseSerializer: Serializer? + } + /// Apply the custom scope for any test scope providers of the traits /// associated with a specified test by calling their /// ``TestScoping/provideScope(for:testCase:performing:)`` function. @@ -149,17 +163,21 @@ extension Runner { /// /// - Parameters: /// - sequence: The sequence to enumerate. + /// - taskNamer: A function to invoke for each element in `sequence`. The + /// result of this function is used to name each child task. /// - body: The function to invoke. /// /// - Throws: Whatever is thrown by `body`. private static func _forEach( in sequence: some Sequence, - _ body: @Sendable @escaping (E) async throws -> Void - ) async throws where E: Sendable { - try await withThrowingTaskGroup(of: Void.self) { taskGroup in + namingTasksWith taskNamer: (borrowing E) -> (taskName: String, action: String?)?, + _ body: @Sendable @escaping (borrowing E) async throws -> Void + ) async rethrows where E: Sendable { + try await withThrowingTaskGroup { taskGroup in for element in sequence { // Each element gets its own subtask to run in. - _ = taskGroup.addTaskUnlessCancelled { + let taskName = taskNamer(element) + taskGroup.addTask(name: decorateTaskName(taskName?.taskName, withAction: taskName?.action)) { try await body(element) } @@ -175,6 +193,7 @@ extension Runner { /// /// - Parameters: /// - stepGraph: The subgraph whose root value, a step, is to be run. + /// - context: Context for the test run. /// /// - Throws: Whatever is thrown from the test body. Thrown errors are /// normally reported as test failures. @@ -189,10 +208,7 @@ extension Runner { /// ## See Also /// /// - ``Runner/run()`` - private static func _runStep(atRootOf stepGraph: Graph) async throws { - // Exit early if the task has already been cancelled. - try Task.checkCancellation() - + private static func _runStep(atRootOf stepGraph: Graph, context: _Context) async throws { // Whether to send a `.testEnded` event at the end of running this step. // Some steps' actions may not require a final event to be sent β€”Β for // example, a skip event only sends `.testSkipped`. @@ -243,21 +259,24 @@ extension Runner { if let step = stepGraph.value, case .run = step.action { await Test.withCurrent(step.test) { _ = await Issue.withErrorRecording(at: step.test.sourceLocation, configuration: configuration) { + // Exit early if the task has already been cancelled. + try Task.checkCancellation() + try await _applyScopingTraits(for: step.test, testCase: nil) { // Run the test function at this step (if one is present.) if let testCases = step.test.testCases { - try await _runTestCases(testCases, within: step) + await _runTestCases(testCases, within: step, context: context) } // Run the children of this test (i.e. the tests in this suite.) - try await _runChildren(of: stepGraph) + try await _runChildren(of: stepGraph, context: context) } } } } else { // There is no test at this node in the graph, so just skip down to the // child nodes. - try await _runChildren(of: stepGraph) + try await _runChildren(of: stepGraph, context: context) } } @@ -282,10 +301,11 @@ extension Runner { /// - Parameters: /// - stepGraph: The subgraph whose root value, a step, will be used to /// find children to run. + /// - context: Context for the test run. /// /// - Throws: Whatever is thrown from the test body. Thrown errors are /// normally reported as test failures. - private static func _runChildren(of stepGraph: Graph) async throws { + private static func _runChildren(of stepGraph: Graph, context: _Context) async throws { let childGraphs = if _configuration.isParallelizationEnabled { // Explicitly shuffle the steps to help detect accidental dependencies // between tests due to their ordering. @@ -314,9 +334,20 @@ extension Runner { } } + // Figure out how to name child tasks. + func taskNamer(_ childGraph: Graph) -> (String, String?)? { + childGraph.value.map { step in + let testName = step.test.humanReadableName() + if step.test.isSuite { + return ("suite \(testName)", "running") + } + return ("test \(testName)", nil) // test cases have " - running" suffix + } + } + // Run the child nodes. - try await _forEach(in: childGraphs) { _, childGraph in - try await _runStep(atRootOf: childGraph) + try await _forEach(in: childGraphs.lazy.map(\.value), namingTasksWith: taskNamer) { childGraph in + try await _runStep(atRootOf: childGraph, context: context) } } @@ -325,21 +356,35 @@ extension Runner { /// - Parameters: /// - testCases: The test cases to be run. /// - step: The runner plan step associated with this test case. - /// - /// - Throws: Whatever is thrown from a test case's body. Thrown errors are - /// normally reported as test failures. + /// - context: Context for the test run. /// /// If parallelization is supported and enabled, the generated test cases will /// be run in parallel using a task group. - private static func _runTestCases(_ testCases: some Sequence, within step: Plan.Step) async throws { + private static func _runTestCases(_ testCases: some Sequence, within step: Plan.Step, context: _Context) async { + let configuration = _configuration + // Apply the configuration's test case filter. - let testCaseFilter = _configuration.testCaseFilter + let testCaseFilter = configuration.testCaseFilter let testCases = testCases.lazy.filter { testCase in testCaseFilter(testCase, step.test) } - try await _forEach(in: testCases) { testCase in - try await _runTestCase(testCase, within: step) + // Figure out how to name child tasks. + let testName = "test \(step.test.humanReadableName())" + let taskNamer: (Int, Test.Case) -> (String, String?)? = if step.test.isParameterized { + { i, _ in (testName, "running test case #\(i + 1)") } + } else { + { _, _ in (testName, "running") } + } + + await _forEach(in: testCases.enumerated(), namingTasksWith: taskNamer) { _, testCase in + if let testCaseSerializer = context.testCaseSerializer { + // Note that if .serialized is applied to an inner scope, we still use + // this serializer (if set) so that we don't overcommit. + await testCaseSerializer.run { await _runTestCase(testCase, within: step, context: context) } + } else { + await _runTestCase(testCase, within: step, context: context) + } } } @@ -348,16 +393,11 @@ extension Runner { /// - Parameters: /// - testCase: The test case to run. /// - step: The runner plan step associated with this test case. - /// - /// - Throws: Whatever is thrown from the test case's body. Thrown errors - /// are normally reported as test failures. + /// - context: Context for the test run. /// /// This function sets ``Test/Case/current``, then invokes the test case's /// body closure. - private static func _runTestCase(_ testCase: Test.Case, within step: Plan.Step) async throws { - // Exit early if the task has already been cancelled. - try Task.checkCancellation() - + private static func _runTestCase(_ testCase: Test.Case, within step: Plan.Step, context: _Context) async { let configuration = _configuration Event.post(.testCaseStarted, for: (step.test, testCase), configuration: configuration) @@ -368,6 +408,9 @@ extension Runner { await Test.Case.withCurrent(testCase) { let sourceLocation = step.test.sourceLocation await Issue.withErrorRecording(at: sourceLocation, configuration: configuration) { + // Exit early if the task has already been cancelled. + try Task.checkCancellation() + try await withTimeLimit(for: step.test, configuration: configuration) { try await _applyScopingTraits(for: step.test, testCase: testCase) { try await testCase.body() @@ -399,6 +442,9 @@ extension Runner { private static func _run(_ runner: Self) async { var runner = runner runner.configureEventHandlerRuntimeState() +#if !SWT_NO_FILE_IO + runner.configureAttachmentHandling() +#endif // Track whether or not any issues were recorded across the entire run. let issueRecorded = Locked(rawValue: false) @@ -411,6 +457,21 @@ extension Runner { eventHandler(event, context) } + // Context to pass into the test run. We intentionally don't pass the Runner + // itself (implicitly as `self` nor as an argument) because we don't want to + // accidentally depend on e.g. the `configuration` property rather than the + // current configuration. + let context: _Context = { + var context = _Context() + + let maximumParallelizationWidth = runner.configuration.maximumParallelizationWidth + if maximumParallelizationWidth > 1 && maximumParallelizationWidth < .max { + context.testCaseSerializer = Serializer(maximumWidth: runner.configuration.maximumParallelizationWidth) + } + + return context + }() + await Configuration.withCurrent(runner.configuration) { // Post an event for every test in the test plan being run. These events // are turned into JSON objects if JSON output is enabled. @@ -424,15 +485,20 @@ extension Runner { } let repetitionPolicy = runner.configuration.repetitionPolicy - for iterationIndex in 0 ..< repetitionPolicy.maximumIterationCount { + let iterationCount = repetitionPolicy.maximumIterationCount + for iterationIndex in 0 ..< iterationCount { Event.post(.iterationStarted(iterationIndex), for: (nil, nil), configuration: runner.configuration) defer { Event.post(.iterationEnded(iterationIndex), for: (nil, nil), configuration: runner.configuration) } - await withTaskGroup(of: Void.self) { [runner] taskGroup in - _ = taskGroup.addTaskUnlessCancelled { - try? await _runStep(atRootOf: runner.plan.stepGraph) + await withTaskGroup { [runner] taskGroup in + var taskAction: String? + if iterationCount > 1 { + taskAction = "running iteration #\(iterationIndex + 1)" + } + _ = taskGroup.addTaskUnlessCancelled(name: decorateTaskName("test run", withAction: taskAction)) { + try? await _runStep(atRootOf: runner.plan.stepGraph, context: context) } await taskGroup.waitForAll() } diff --git a/Sources/Testing/Running/SkipInfo.swift b/Sources/Testing/Running/SkipInfo.swift index 0c5a6923d..687cf8434 100644 --- a/Sources/Testing/Running/SkipInfo.swift +++ b/Sources/Testing/Running/SkipInfo.swift @@ -54,6 +54,30 @@ extension SkipInfo: Equatable, Hashable {} extension SkipInfo: Codable {} +// MARK: - + +extension SkipInfo { + /// Initialize an instance of this type from an arbitrary error. + /// + /// - Parameters: + /// - error: The error to convert to an instance of this type. + /// + /// If `error` does not represent a skip or cancellation event, this + /// initializer returns `nil`. + init?(_ error: any Error) { + if let skipInfo = error as? Self { + self = skipInfo + } else if error is CancellationError, Task.isCancelled { + // Synthesize skip info for this cancellation error. + let backtrace = Backtrace(forFirstThrowOf: error) + let sourceContext = SourceContext(backtrace: backtrace, sourceLocation: nil) + self.init(comment: nil, sourceContext: sourceContext) + } else { + return nil + } + } +} + // MARK: - Deprecated extension SkipInfo { diff --git a/Sources/Testing/SourceAttribution/Backtrace.swift b/Sources/Testing/SourceAttribution/Backtrace.swift index 97815755e..552e16d68 100644 --- a/Sources/Testing/SourceAttribution/Backtrace.swift +++ b/Sources/Testing/SourceAttribution/Backtrace.swift @@ -40,6 +40,16 @@ public struct Backtrace: Sendable { self.addresses = addresses.map { Address(UInt(bitPattern: $0)) } } +#if os(Android) && !SWT_NO_DYNAMIC_LINKING + /// The `backtrace()` function. + /// + /// This function was added to Android with API level 33, which is higher than + /// our minimum deployment target, so we look it up dynamically at runtime. + private static let _backtrace = symbol(named: "backtrace").map { + castCFunction(at: $0, to: (@convention(c) (UnsafeMutablePointer, CInt) -> CInt).self) + } +#endif + /// Get the current backtrace. /// /// - Parameters: @@ -66,9 +76,11 @@ public struct Backtrace: Sendable { initializedCount = .init(clamping: backtrace(addresses.baseAddress!, .init(clamping: addresses.count))) } #elseif os(Android) - initializedCount = addresses.withMemoryRebound(to: UnsafeMutableRawPointer.self) { addresses in - .init(clamping: backtrace(addresses.baseAddress!, .init(clamping: addresses.count))) +#if !SWT_NO_DYNAMIC_LINKING + if let _backtrace { + initializedCount = .init(clamping: _backtrace(addresses.baseAddress!, .init(clamping: addresses.count))) } +#endif #elseif os(Linux) || os(FreeBSD) || os(OpenBSD) initializedCount = .init(clamping: backtrace(addresses.baseAddress!, .init(clamping: addresses.count))) #elseif os(Windows) @@ -119,6 +131,7 @@ extension Backtrace: Codable { // MARK: - Backtraces for thrown errors extension Backtrace { +#if !hasFeature(Embedded) // MARK: - Error cache keys /// A type used as a cache key that uniquely identifies error existential @@ -321,6 +334,25 @@ extension Backtrace { } forward(errorType) } +#endif + +#if !hasFeature(Embedded) && SWT_TARGET_OS_APPLE && !SWT_NO_DYNAMIC_LINKING + /// A function provided by Core Foundation that copies the captured backtrace + /// from storage inside `CFError` or `NSError`. + /// + /// - Parameters: + /// - error: The error whose backtrace is desired. + /// + /// - Returns: The backtrace (as an instance of `NSArray`) captured by `error` + /// when it was created, or `nil` if none was captured. The caller is + /// responsible for releasing this object when done. + /// + /// This function was added in an internal Foundation PR and is not available + /// on older systems. + private static let _CFErrorCopyCallStackReturnAddresses = symbol(named: "_CFErrorCopyCallStackReturnAddresses").map { + castCFunction(at: $0, to: (@convention(c) (_ error: any Error) -> Unmanaged?).self) + } +#endif /// Whether or not Foundation provides a function that triggers the capture of /// backtaces when instances of `NSError` or `CFError` are created. @@ -336,8 +368,12 @@ extension Backtrace { /// - Note: The underlying Foundation function is called (if present) the /// first time the value of this property is read. static let isFoundationCaptureEnabled = { -#if _runtime(_ObjC) && !SWT_NO_DYNAMIC_LINKING - if Environment.flag(named: "SWT_FOUNDATION_ERROR_BACKTRACING_ENABLED") == true { +#if !hasFeature(Embedded) && _runtime(_ObjC) && !SWT_NO_DYNAMIC_LINKING + // Check the environment variable; if it isn't set, enable if and only if + // the Core Foundation getter function is implemented. + let foundationBacktracesEnabled = Environment.flag(named: "SWT_FOUNDATION_ERROR_BACKTRACING_ENABLED") + ?? (_CFErrorCopyCallStackReturnAddresses != nil) + if foundationBacktracesEnabled { let _CFErrorSetCallStackCaptureEnabled = symbol(named: "_CFErrorSetCallStackCaptureEnabled").map { castCFunction(at: $0, to: (@convention(c) (DarwinBoolean) -> DarwinBoolean).self) } @@ -348,6 +384,7 @@ extension Backtrace { return false }() +#if !hasFeature(Embedded) /// The implementation of ``Backtrace/startCachingForThrownErrors()``, run /// only once. /// @@ -373,6 +410,7 @@ extension Backtrace { } } }() +#endif /// Configure the Swift runtime to allow capturing backtraces when errors are /// thrown. @@ -381,7 +419,9 @@ extension Backtrace { /// developer-supplied code to ensure that thrown errors' backtraces are /// always captured. static func startCachingForThrownErrors() { +#if !hasFeature(Embedded) __SWIFT_TESTING_IS_CAPTURING_A_BACKTRACE_FOR_A_THROWN_ERROR__ +#endif } /// Flush stale entries from the error-mapping cache. @@ -389,9 +429,11 @@ extension Backtrace { /// Call this function periodically to ensure that errors do not continue to /// take up space in the cache after they have been deinitialized. static func flushThrownErrorCache() { +#if !hasFeature(Embedded) _errorMappingCache.withLock { cache in cache = cache.filter { $0.value.errorObject != nil } } +#endif } /// Initialize an instance of this type with the previously-cached backtrace @@ -411,13 +453,22 @@ extension Backtrace { /// initializer cannot be made an instance method or property of `Error` /// because doing so will cause Swift-native errors to be unboxed into /// existential containers with different addresses. +#if !hasFeature(Embedded) @inline(never) init?(forFirstThrowOf error: any Error, checkFoundation: Bool = true) { - if checkFoundation && Self.isFoundationCaptureEnabled, - let userInfo = error._userInfo as? [String: Any], - let addresses = userInfo["NSCallStackReturnAddresses"] as? [Address], !addresses.isEmpty { - self.init(addresses: addresses) - return + if checkFoundation && Self.isFoundationCaptureEnabled { +#if !hasFeature(Embedded) && SWT_TARGET_OS_APPLE && !SWT_NO_DYNAMIC_LINKING + if let addresses = Self._CFErrorCopyCallStackReturnAddresses?(error)?.takeRetainedValue() as? [Address] { + self.init(addresses: addresses) + return + } +#endif + + if let userInfo = error._userInfo as? [String: Any], + let addresses = userInfo["NSCallStackReturnAddresses"] as? [Address], !addresses.isEmpty { + self.init(addresses: addresses) + return + } } let entry = Self._errorMappingCache.withLock { cache in @@ -430,4 +481,9 @@ extension Backtrace { return nil } } +#else + init?(forFirstThrowOf error: some Error, checkFoundation: Bool = true) { + return nil + } +#endif } diff --git a/Sources/Testing/SourceAttribution/CustomTestStringConvertible.swift b/Sources/Testing/SourceAttribution/CustomTestStringConvertible.swift index 192dde5ad..6f0517468 100644 --- a/Sources/Testing/SourceAttribution/CustomTestStringConvertible.swift +++ b/Sources/Testing/SourceAttribution/CustomTestStringConvertible.swift @@ -42,9 +42,9 @@ /// the default description of a value may not be adequately descriptive: /// /// ``` -/// β—‡ Passing argument food β†’ .paella to isDelicious(_:) -/// β—‡ Passing argument food β†’ .oden to isDelicious(_:) -/// β—‡ Passing argument food β†’ .ragu to isDelicious(_:) +/// β—‡ Test case passing 1 argument food β†’ .paella to isDelicious(_:) started. +/// β—‡ Test case passing 1 argument food β†’ .oden to isDelicious(_:) started. +/// β—‡ Test case passing 1 argument food β†’ .ragu to isDelicious(_:) started. /// ``` /// /// By adopting ``CustomTestStringConvertible``, customized descriptions can be @@ -69,9 +69,9 @@ /// ``testDescription`` property: /// /// ``` -/// β—‡ Passing argument food β†’ paella valenciana to isDelicious(_:) -/// β—‡ Passing argument food β†’ γŠγ§γ‚“ to isDelicious(_:) -/// β—‡ Passing argument food β†’ ragΓΉ alla bolognese to isDelicious(_:) +/// β—‡ Test case passing 1 argument food β†’ paella valenciana to isDelicious(_:) started. +/// β—‡ Test case passing 1 argument food β†’ γŠγ§γ‚“ to isDelicious(_:) started. +/// β—‡ Test case passing 1 argument food β†’ ragΓΉ alla bolognese to isDelicious(_:) started. /// ``` /// /// ## See Also diff --git a/Sources/Testing/SourceAttribution/SourceLocation.swift b/Sources/Testing/SourceAttribution/SourceLocation.swift index 3aca54d2f..f5af81543 100644 --- a/Sources/Testing/SourceAttribution/SourceLocation.swift +++ b/Sources/Testing/SourceAttribution/SourceLocation.swift @@ -71,11 +71,8 @@ public struct SourceLocation: Sendable { } /// The path to the source file. - /// - /// - Warning: This property is provided temporarily to aid in integrating the - /// testing library with existing tools such as Swift Package Manager. It - /// will be removed in a future release. - public var _filePath: String + @_spi(Experimental) + public var filePath: String /// The line in the source file. /// @@ -118,7 +115,7 @@ public struct SourceLocation: Sendable { precondition(column > 0, "SourceLocation.column must be greater than 0 (was \(column))") self.fileID = fileID - self._filePath = filePath + self.filePath = filePath self.line = line self.column = column } @@ -167,4 +164,56 @@ extension SourceLocation: CustomStringConvertible, CustomDebugStringConvertible // MARK: - Codable -extension SourceLocation: Codable {} +extension SourceLocation: Codable { + private enum _CodingKeys: String, CodingKey { + case fileID + case filePath + case line + case column + + /// A backwards-compatible synonym of ``filePath``. + case _filePath + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: _CodingKeys.self) + try container.encode(fileID, forKey: .fileID) + try container.encode(line, forKey: .line) + try container.encode(column, forKey: .column) + + // For backwards-compatibility, we must always encode "_filePath". + try container.encode(filePath, forKey: ._filePath) + try container.encode(filePath, forKey: .filePath) + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: _CodingKeys.self) + fileID = try container.decode(String.self, forKey: .fileID) + line = try container.decode(Int.self, forKey: .line) + column = try container.decode(Int.self, forKey: .column) + + // For simplicity's sake, we won't be picky about which key contains the + // file path. + filePath = try container.decodeIfPresent(String.self, forKey: .filePath) + ?? container.decode(String.self, forKey: ._filePath) + } +} + +// MARK: - Deprecated + +extension SourceLocation { + /// The path to the source file. + /// + /// - Warning: This property is provided temporarily to aid in integrating the + /// testing library with existing tools such as Swift Package Manager. It + /// will be removed in a future release. + @available(swift, deprecated: 100000.0, renamed: "filePath") + public var _filePath: String { + get { + filePath + } + set { + filePath = newValue + } + } +} diff --git a/Sources/Testing/Support/Additions/CommandLineAdditions.swift b/Sources/Testing/Support/Additions/CommandLineAdditions.swift index 57d9851a8..6f307acfb 100644 --- a/Sources/Testing/Support/Additions/CommandLineAdditions.swift +++ b/Sources/Testing/Support/Additions/CommandLineAdditions.swift @@ -33,14 +33,27 @@ extension CommandLine { } return result! #elseif os(Linux) || os(Android) - return try withUnsafeTemporaryAllocation(of: CChar.self, capacity: Int(PATH_MAX) * 2) { buffer in - let readCount = readlink("/proc/self/exe", buffer.baseAddress!, buffer.count - 1) - guard readCount >= 0 else { - throw CError(rawValue: swt_errno()) + var result: String? +#if DEBUG + var bufferCount = Int(1) // force looping +#else + var bufferCount = Int(PATH_MAX) +#endif + while result == nil { + try withUnsafeTemporaryAllocation(of: CChar.self, capacity: bufferCount) { buffer in + let readCount = readlink("/proc/self/exe", buffer.baseAddress!, buffer.count) + guard readCount >= 0 else { + throw CError(rawValue: swt_errno()) + } + if readCount < buffer.count { + buffer[readCount] = 0 // NUL-terminate the string. + result = String(cString: buffer.baseAddress!) + } else { + bufferCount += Int(PATH_MAX) // add more space and try again + } } - buffer[readCount] = 0 // NUL-terminate the string. - return String(cString: buffer.baseAddress!) } + return result! #elseif os(FreeBSD) var mib = [CTL_KERN, KERN_PROC, KERN_PROC_PATHNAME, -1] return try mib.withUnsafeMutableBufferPointer { mib in @@ -57,11 +70,17 @@ extension CommandLine { } #elseif os(OpenBSD) // OpenBSD does not have API to get a path to the running executable. Use - // arguments[0]. We do a basic sniff test for a path-like string, but - // otherwise return argv[0] verbatim. - guard let argv0 = arguments.first, argv0.contains("/") else { + // arguments[0]. We do a basic sniff test for a path-like string, and + // prepend the early CWD if it looks like a relative path, but otherwise + // return argv[0] verbatim. + guard var argv0 = arguments.first, argv0.contains("/") else { throw CError(rawValue: ENOEXEC) } + if argv0.first != "/", + let earlyCWD = swt_getEarlyCWD().flatMap(String.init(validatingCString:)), + !earlyCWD.isEmpty { + argv0 = "\(earlyCWD)/\(argv0)" + } return argv0 #elseif os(Windows) var result: String? @@ -71,7 +90,7 @@ extension CommandLine { var bufferCount = Int(MAX_PATH) #endif while result == nil { - try withUnsafeTemporaryAllocation(of: wchar_t.self, capacity: bufferCount) { buffer in + try withUnsafeTemporaryAllocation(of: CWideChar.self, capacity: bufferCount) { buffer in SetLastError(DWORD(ERROR_SUCCESS)) _ = GetModuleFileNameW(nil, buffer.baseAddress!, DWORD(buffer.count)) switch GetLastError() { diff --git a/Sources/Testing/Support/Additions/CopyableAdditions.swift b/Sources/Testing/Support/Additions/CopyableAdditions.swift new file mode 100644 index 000000000..ea192b1ef --- /dev/null +++ b/Sources/Testing/Support/Additions/CopyableAdditions.swift @@ -0,0 +1,47 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +#if !hasFeature(Embedded) +/// A helper protocol for ``boxCopyableValue(_:)``. +private protocol _CopyablePointer { + /// Load the value at this address into an existential box. + /// + /// - Returns: The value at this address. + func load() -> Any +} + +extension UnsafePointer: _CopyablePointer where Pointee: Copyable { + func load() -> Any { + pointee + } +} +#endif + +/// Copy a value to an existential box if its type conforms to `Copyable`. +/// +/// - Parameters: +/// - value: The value to copy. +/// +/// - Returns: A copy of `value` in an existential box, or `nil` if the type of +/// `value` does not conform to `Copyable`. +/// +/// When using Embedded Swift, this function always returns `nil`. +#if !hasFeature(Embedded) +@available(_castingWithNonCopyableGenerics, *) +func boxCopyableValue(_ value: borrowing some ~Copyable) -> Any? { + withUnsafePointer(to: value) { address in + return (address as? any _CopyablePointer)?.load() + } +} +#else +func boxCopyableValue(_ value: borrowing some ~Copyable) -> Void? { + nil +} +#endif diff --git a/Sources/Testing/Support/Additions/TaskAdditions.swift b/Sources/Testing/Support/Additions/TaskAdditions.swift new file mode 100644 index 000000000..1ec0c3079 --- /dev/null +++ b/Sources/Testing/Support/Additions/TaskAdditions.swift @@ -0,0 +1,27 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +/// Make a (decorated) task name from the given undecorated task name. +/// +/// - Parameters: +/// - taskName: The undecorated task name to modify. +/// +/// - Returns: A copy of `taskName` with a common prefix applied, or `nil` if +/// `taskName` was `nil`. +func decorateTaskName(_ taskName: String?, withAction action: String?) -> String? { + let prefix = "[Swift Testing]" + return taskName.map { taskName in +#if DEBUG + precondition(!taskName.hasPrefix(prefix), "Applied prefix '\(prefix)' to task name '\(taskName)' twice. Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new") +#endif + let action = action.map { " - \($0)" } ?? "" + return "\(prefix) \(taskName)\(action)" + } +} diff --git a/Sources/Testing/Support/CError.swift b/Sources/Testing/Support/CError.swift index a8462fda4..648cd917e 100644 --- a/Sources/Testing/Support/CError.swift +++ b/Sources/Testing/Support/CError.swift @@ -27,8 +27,12 @@ struct CError: Error, RawRepresentable { /// [here](https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes). /// /// This type is not part of the public interface of the testing library. -struct Win32Error: Error, RawRepresentable { - var rawValue: DWORD +package struct Win32Error: Error, RawRepresentable { + package var rawValue: CUnsignedLong + + package init(rawValue: CUnsignedLong) { + self.rawValue = rawValue + } } #endif @@ -66,15 +70,15 @@ extension CError: CustomStringConvertible { #if os(Windows) extension Win32Error: CustomStringConvertible { - var description: String { + package var description: String { let (address, count) = withUnsafeTemporaryAllocation(of: LPWSTR?.self, capacity: 1) { buffer in // FormatMessageW() takes a wide-character buffer into which it writes the // error message... _unless_ you pass `FORMAT_MESSAGE_ALLOCATE_BUFFER` in // which case it takes a pointer-to-pointer that it populates with a // heap-allocated string. However, the signature for FormatMessageW() - // still takes an LPWSTR? (Optional>), so we - // need to temporarily mis-cast the pointer before we can pass it in. - let count = buffer.withMemoryRebound(to: wchar_t.self) { buffer in + // still takes an LPWSTR? (Optional>), so + // we need to temporarily mis-cast the pointer before we can pass it in. + let count = buffer.withMemoryRebound(to: CWideChar.self) { buffer in FormatMessageW( DWORD(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS | FORMAT_MESSAGE_MAX_WIDTH_MASK), nil, diff --git a/Sources/Testing/Support/CustomIssueRepresentable.swift b/Sources/Testing/Support/CustomIssueRepresentable.swift index d76739d1f..77f7b23b0 100644 --- a/Sources/Testing/Support/CustomIssueRepresentable.swift +++ b/Sources/Testing/Support/CustomIssueRepresentable.swift @@ -12,7 +12,7 @@ /// record themselves as test issues. /// /// When a type conforms to this protocol, values of that type can be passed to -/// ``Issue/record(_:_:)``. The testing library then calls the +/// ``Issue/record(_:severity:sourceLocation:)``. The testing library then calls the /// ``customize(_:)`` function and passes it an instance of ``Issue`` that will /// be used to represent the value. The function can then reconfigure or replace /// the issue as needed. @@ -43,7 +43,7 @@ protocol CustomIssueRepresentable: Error { /// /// This type is not part of the public interface of the testing library. /// External callers should generally record issues by throwing their own errors -/// or by calling ``Issue/record(_:sourceLocation:)``. +/// or by calling ``Issue/record(_:severity:sourceLocation:)``. struct SystemError: Error, CustomStringConvertible, CustomIssueRepresentable { var description: String @@ -62,7 +62,7 @@ struct SystemError: Error, CustomStringConvertible, CustomIssueRepresentable { /// /// This type is not part of the public interface of the testing library. /// External callers should generally record issues by throwing their own errors -/// or by calling ``Issue/record(_:sourceLocation:)``. +/// or by calling ``Issue/record(_:severity:sourceLocation:)``. struct APIMisuseError: Error, CustomStringConvertible, CustomIssueRepresentable { var description: String diff --git a/Sources/Testing/Support/Environment.swift b/Sources/Testing/Support/Environment.swift index 2ab3710a4..514e70a2c 100644 --- a/Sources/Testing/Support/Environment.swift +++ b/Sources/Testing/Support/Environment.swift @@ -15,7 +15,7 @@ private import _TestingInternals /// This type can be used to access the current process' environment variables. /// /// This type is not part of the public interface of the testing library. -enum Environment { +package enum Environment { #if SWT_NO_ENVIRONMENT_VARIABLES /// Storage for the simulated environment. /// @@ -92,7 +92,7 @@ enum Environment { /// Get all environment variables in the current process. /// /// - Returns: A copy of the current process' environment dictionary. - static func get() -> [String: String] { + package static func get() -> [String: String] { #if SWT_NO_ENVIRONMENT_VARIABLES simulatedEnvironment.rawValue #elseif SWT_TARGET_OS_APPLE @@ -140,7 +140,7 @@ enum Environment { /// /// - Returns: The value of the specified environment variable, or `nil` if it /// is not set for the current process. - static func variable(named name: String) -> String? { + package static func variable(named name: String) -> String? { #if SWT_NO_ENVIRONMENT_VARIABLES simulatedEnvironment.rawValue[name] #elseif SWT_TARGET_OS_APPLE && !SWT_NO_DYNAMIC_LINKING @@ -175,7 +175,7 @@ enum Environment { #elseif os(Windows) name.withCString(encodedAs: UTF16.self) { name in func getVariable(maxCount: Int) -> String? { - withUnsafeTemporaryAllocation(of: wchar_t.self, capacity: maxCount) { buffer in + withUnsafeTemporaryAllocation(of: CWideChar.self, capacity: maxCount) { buffer in SetLastError(DWORD(ERROR_SUCCESS)) let count = GetEnvironmentVariableW(name, buffer.baseAddress!, DWORD(buffer.count)) if count == 0 { @@ -221,7 +221,7 @@ enum Environment { /// - String values beginning with the letters `"t"`, `"T"`, `"y"`, or `"Y"` /// are interpreted as `true`; and /// - All other non-`nil` string values are interpreted as `false`. - static func flag(named name: String) -> Bool? { + package static func flag(named name: String) -> Bool? { variable(named: name).map { if let signedValue = Int64($0) { return signedValue != 0 @@ -248,7 +248,7 @@ extension Environment { /// /// - Returns: Whether or not the environment variable was successfully set. @discardableResult - static func setVariable(_ value: String?, named name: String) -> Bool { + package static func setVariable(_ value: String?, named name: String) -> Bool { #if SWT_NO_ENVIRONMENT_VARIABLES simulatedEnvironment.withLock { environment in environment[name] = value diff --git a/Sources/Testing/Support/FileHandle.swift b/Sources/Testing/Support/FileHandle.swift index 2a2bfe967..408ba2cd6 100644 --- a/Sources/Testing/Support/FileHandle.swift +++ b/Sources/Testing/Support/FileHandle.swift @@ -73,6 +73,13 @@ struct FileHandle: ~Copyable, Sendable { return } + // On Windows, "N" is used rather than "e" to signify that a file handle is + // not inherited. + var mode = mode + if let eIndex = mode.firstIndex(of: "e") { + mode.replaceSubrange(eIndex ... eIndex, with: "N") + } + // Windows deprecates fopen() as insecure, so call _wfopen_s() instead. let fileHandle = try path.withCString(encodedAs: UTF16.self) { path in try mode.withCString(encodedAs: UTF16.self) { mode in @@ -98,8 +105,12 @@ struct FileHandle: ~Copyable, Sendable { /// - path: The path to read from. /// /// - Throws: Any error preventing the stream from being opened. + /// + /// By default, the resulting file handle is not inherited by any child + /// processes (that is, `FD_CLOEXEC` is set on POSIX-like systems and + /// `HANDLE_FLAG_INHERIT` is cleared on Windows.). init(forReadingAtPath path: String) throws { - try self.init(atPath: path, mode: "rb") + try self.init(atPath: path, mode: "reb") } /// Initialize an instance of this type to write to the given path. @@ -108,8 +119,12 @@ struct FileHandle: ~Copyable, Sendable { /// - path: The path to write to. /// /// - Throws: Any error preventing the stream from being opened. + /// + /// By default, the resulting file handle is not inherited by any child + /// processes (that is, `FD_CLOEXEC` is set on POSIX-like systems and + /// `HANDLE_FLAG_INHERIT` is cleared on Windows.). init(forWritingAtPath path: String) throws { - try self.init(atPath: path, mode: "wb") + try self.init(atPath: path, mode: "web") } /// Initialize an instance of this type with an existing C file handle. @@ -445,6 +460,17 @@ extension FileHandle { #if !SWT_NO_PIPES // MARK: - Pipes +#if !SWT_TARGET_OS_APPLE && !os(Windows) && !SWT_NO_DYNAMIC_LINKING +/// Create a pipe with flags. +/// +/// This function declaration is provided because `pipe2()` is only declared if +/// `_GNU_SOURCE` is set, but setting it causes build errors due to conflicts +/// with Swift's Glibc module. +private let _pipe2 = symbol(named: "pipe2").map { + castCFunction(at: $0, to: (@convention(c) (UnsafeMutablePointer, CInt) -> CInt).self) +} +#endif + extension FileHandle { /// Make a pipe connecting two new file handles. /// @@ -461,15 +487,36 @@ extension FileHandle { /// - Bug: This function should return a tuple containing the file handles /// instead of returning them via `inout` arguments. Swift does not support /// tuples with move-only elements. ([104669935](rdar://104669935)) + /// + /// By default, the resulting file handles are not inherited by any child + /// processes (that is, `FD_CLOEXEC` is set on POSIX-like systems and + /// `HANDLE_FLAG_INHERIT` is cleared on Windows.). static func makePipe(readEnd: inout FileHandle?, writeEnd: inout FileHandle?) throws { +#if !os(Windows) + var pipe2Called = false +#endif + var (fdReadEnd, fdWriteEnd) = try withUnsafeTemporaryAllocation(of: CInt.self, capacity: 2) { fds in #if os(Windows) - guard 0 == _pipe(fds.baseAddress, 0, _O_BINARY) else { + guard 0 == _pipe(fds.baseAddress, 0, _O_BINARY | _O_NOINHERIT) else { throw CError(rawValue: swt_errno()) } #else - guard 0 == pipe(fds.baseAddress!) else { - throw CError(rawValue: swt_errno()) +#if !SWT_TARGET_OS_APPLE && !os(Windows) && !SWT_NO_DYNAMIC_LINKING + if let _pipe2 { + guard 0 == _pipe2(fds.baseAddress!, O_CLOEXEC) else { + throw CError(rawValue: swt_errno()) + } + pipe2Called = true + } +#endif + + if !pipe2Called { + // pipe2() is not available. Use pipe() instead and simulate O_CLOEXEC + // to the best of our ability. + guard 0 == pipe(fds.baseAddress!) else { + throw CError(rawValue: swt_errno()) + } } #endif return (fds[0], fds[1]) @@ -479,6 +526,15 @@ extension FileHandle { Self._close(fdWriteEnd) } +#if !os(Windows) + if !pipe2Called { + // pipe2() is not available. Use pipe() instead and simulate O_CLOEXEC + // to the best of our ability. + try setFD_CLOEXEC(true, onFileDescriptor: fdReadEnd) + try setFD_CLOEXEC(true, onFileDescriptor: fdWriteEnd) + } +#endif + do { defer { fdReadEnd = -1 @@ -569,7 +625,7 @@ func appendPathComponent(_ pathComponent: String, to path: String) -> String { #if os(Windows) path.withCString(encodedAs: UTF16.self) { path in pathComponent.withCString(encodedAs: UTF16.self) { pathComponent in - withUnsafeTemporaryAllocation(of: wchar_t.self, capacity: (wcslen(path) + wcslen(pathComponent)) * 2 + 1) { buffer in + withUnsafeTemporaryAllocation(of: CWideChar.self, capacity: (wcslen(path) + wcslen(pathComponent)) * 2 + 1) { buffer in _ = wcscpy_s(buffer.baseAddress, buffer.count, path) _ = PathCchAppendEx(buffer.baseAddress, buffer.count, pathComponent, ULONG(PATHCCH_ALLOW_LONG_PATHS.rawValue)) return (String.decodeCString(buffer.baseAddress, as: UTF16.self)?.result)! @@ -632,4 +688,66 @@ func canonicalizePath(_ path: String) -> String? { return nil #endif } + +#if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD) || os(Android) +/// Set the given file descriptor's `FD_CLOEXEC` flag. +/// +/// - Parameters: +/// - flag: The new value of `fd`'s `FD_CLOEXEC` flag. +/// - fd: The file descriptor. +/// +/// - Throws: Any error that occurred while setting the flag. +func setFD_CLOEXEC(_ flag: Bool, onFileDescriptor fd: CInt) throws { + switch swt_getfdflags(fd) { + case -1: + // An error occurred reading the flags for this file descriptor. + throw CError(rawValue: swt_errno()) + case let oldValue: + let newValue = if flag { + oldValue | FD_CLOEXEC + } else { + oldValue & ~FD_CLOEXEC + } + if oldValue == newValue { + // No need to make a second syscall as nothing has changed. + return + } + if -1 == swt_setfdflags(fd, newValue) { + // An error occurred setting the flags for this file descriptor. + throw CError(rawValue: swt_errno()) + } + } +} +#endif + +/// The path to the root directory of the boot volume. +/// +/// On Windows, this string is usually of the form `"C:\"`. On UNIX-like +/// platforms, it is always equal to `"/"`. +let rootDirectoryPath: String = { +#if os(Windows) + var result: String? + + // The boot volume is, except in some legacy scenarios, the volume that + // contains the system Windows directory. For an explanation of the difference + // between the Windows directory and the _system_ Windows directory, see + // https://devblogs.microsoft.com/oldnewthing/20140723-00/?p=423 . + let count = GetSystemWindowsDirectoryW(nil, 0) + if count > 0 { + withUnsafeTemporaryAllocation(of: CWideChar.self, capacity: Int(count) + 1) { buffer in + _ = GetSystemWindowsDirectoryW(buffer.baseAddress!, UINT(buffer.count)) + let rStrip = PathCchStripToRoot(buffer.baseAddress!, buffer.count) + if rStrip == S_OK || rStrip == S_FALSE { + result = String.decodeCString(buffer.baseAddress!, as: UTF16.self)?.result + } + } + } + + // If we weren't able to get a path, fall back to "C:\" on the assumption that + // it's the common case and most likely correct. + return result ?? #"C:\"# +#else + return "/" +#endif +}() #endif diff --git a/Sources/Testing/Support/Locked+Platform.swift b/Sources/Testing/Support/Locked+Platform.swift deleted file mode 100644 index 951e62da8..000000000 --- a/Sources/Testing/Support/Locked+Platform.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// 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 -// - -internal import _TestingInternals - -extension Never: Lockable { - static func initializeLock(at lock: UnsafeMutablePointer) {} - static func deinitializeLock(at lock: UnsafeMutablePointer) {} - static func unsafelyAcquireLock(at lock: UnsafeMutablePointer) {} - static func unsafelyRelinquishLock(at lock: UnsafeMutablePointer) {} -} - -#if SWT_TARGET_OS_APPLE && !SWT_NO_OS_UNFAIR_LOCK -extension os_unfair_lock_s: Lockable { - static func initializeLock(at lock: UnsafeMutablePointer) { - lock.initialize(to: .init()) - } - - static func deinitializeLock(at lock: UnsafeMutablePointer) { - // No deinitialization needed. - } - - static func unsafelyAcquireLock(at lock: UnsafeMutablePointer) { - os_unfair_lock_lock(lock) - } - - static func unsafelyRelinquishLock(at lock: UnsafeMutablePointer) { - os_unfair_lock_unlock(lock) - } -} -#endif - -#if os(FreeBSD) || os(OpenBSD) -typealias pthread_mutex_t = _TestingInternals.pthread_mutex_t? -#endif - -#if SWT_TARGET_OS_APPLE || os(Linux) || os(Android) || (os(WASI) && compiler(>=6.1) && _runtime(_multithreaded)) || os(FreeBSD) || os(OpenBSD) -extension pthread_mutex_t: Lockable { - static func initializeLock(at lock: UnsafeMutablePointer) { - _ = pthread_mutex_init(lock, nil) - } - - static func deinitializeLock(at lock: UnsafeMutablePointer) { - _ = pthread_mutex_destroy(lock) - } - - static func unsafelyAcquireLock(at lock: UnsafeMutablePointer) { - _ = pthread_mutex_lock(lock) - } - - static func unsafelyRelinquishLock(at lock: UnsafeMutablePointer) { - _ = pthread_mutex_unlock(lock) - } -} -#endif - -#if os(Windows) -extension SRWLOCK: Lockable { - static func initializeLock(at lock: UnsafeMutablePointer) { - InitializeSRWLock(lock) - } - - static func deinitializeLock(at lock: UnsafeMutablePointer) { - // No deinitialization needed. - } - - static func unsafelyAcquireLock(at lock: UnsafeMutablePointer) { - AcquireSRWLockExclusive(lock) - } - - static func unsafelyRelinquishLock(at lock: UnsafeMutablePointer) { - ReleaseSRWLockExclusive(lock) - } -} -#endif - -#if SWT_TARGET_OS_APPLE && !SWT_NO_OS_UNFAIR_LOCK -typealias DefaultLock = os_unfair_lock -#elseif SWT_TARGET_OS_APPLE || os(Linux) || os(Android) || (os(WASI) && compiler(>=6.1) && _runtime(_multithreaded)) || os(FreeBSD) || os(OpenBSD) -typealias DefaultLock = pthread_mutex_t -#elseif os(Windows) -typealias DefaultLock = SRWLOCK -#elseif os(WASI) -// No locks on WASI without multithreaded runtime. -typealias DefaultLock = Never -#else -#warning("Platform-specific implementation missing: locking unavailable") -typealias DefaultLock = Never -#endif diff --git a/Sources/Testing/Support/Locked.swift b/Sources/Testing/Support/Locked.swift index d1db8ef1f..601fb14ce 100644 --- a/Sources/Testing/Support/Locked.swift +++ b/Sources/Testing/Support/Locked.swift @@ -9,37 +9,9 @@ // internal import _TestingInternals - -/// A protocol defining a type, generally platform-specific, that satisfies the -/// requirements of a lock or mutex. -protocol Lockable { - /// Initialize the lock at the given address. - /// - /// - Parameters: - /// - lock: A pointer to uninitialized memory that should be initialized as - /// an instance of this type. - static func initializeLock(at lock: UnsafeMutablePointer) - - /// Deinitialize the lock at the given address. - /// - /// - Parameters: - /// - lock: A pointer to initialized memory that should be deinitialized. - static func deinitializeLock(at lock: UnsafeMutablePointer) - - /// Acquire the lock at the given address. - /// - /// - Parameters: - /// - lock: The address of the lock to acquire. - static func unsafelyAcquireLock(at lock: UnsafeMutablePointer) - - /// Relinquish the lock at the given address. - /// - /// - Parameters: - /// - lock: The address of the lock to relinquish. - static func unsafelyRelinquishLock(at lock: UnsafeMutablePointer) -} - -// MARK: - +#if canImport(Synchronization) +private import Synchronization +#endif /// A type that wraps a value requiring access from a synchronous caller during /// concurrent execution. @@ -52,30 +24,65 @@ protocol Lockable { /// concurrency tools. /// /// This type is not part of the public interface of the testing library. -struct LockedWith: RawRepresentable where L: Lockable { - /// A type providing heap-allocated storage for an instance of ``Locked``. - private final class _Storage: ManagedBuffer { +struct Locked { + /// A type providing storage for the underlying lock and wrapped value. +#if SWT_TARGET_OS_APPLE && !SWT_NO_OS_UNFAIR_LOCK + private typealias _Storage = ManagedBuffer +#elseif !SWT_FIXED_85448 && (os(Linux) || os(Android)) + private final class _Storage: ManagedBuffer { deinit { withUnsafeMutablePointerToElements { lock in - L.deinitializeLock(at: lock) + _ = pthread_mutex_destroy(lock) } } } +#elseif canImport(Synchronization) + private final class _Storage { + let mutex: Mutex + + init(_ rawValue: consuming sending T) { + mutex = Mutex(rawValue) + } + } +#else +#error("Platform-specific misconfiguration: no mutex or lock type available") +#endif /// Storage for the underlying lock and wrapped value. - private nonisolated(unsafe) var _storage: ManagedBuffer + private nonisolated(unsafe) var _storage: _Storage +} +extension Locked: Sendable where T: Sendable {} + +extension Locked: RawRepresentable { init(rawValue: T) { - _storage = _Storage.create(minimumCapacity: 1, makingHeaderWith: { _ in rawValue }) +#if SWT_TARGET_OS_APPLE && !SWT_NO_OS_UNFAIR_LOCK + _storage = .create(minimumCapacity: 1, makingHeaderWith: { _ in rawValue }) + _storage.withUnsafeMutablePointerToElements { lock in + lock.initialize(to: .init()) + } +#elseif !SWT_FIXED_85448 && (os(Linux) || os(Android)) + _storage = _Storage.create(minimumCapacity: 1, makingHeaderWith: { _ in rawValue }) as! _Storage _storage.withUnsafeMutablePointerToElements { lock in - L.initializeLock(at: lock) + _ = pthread_mutex_init(lock, nil) } +#elseif canImport(Synchronization) + nonisolated(unsafe) let rawValue = rawValue + _storage = _Storage(rawValue) +#else +#error("Platform-specific misconfiguration: no mutex or lock type available") +#endif } var rawValue: T { - withLock { $0 } + withLock { rawValue in + nonisolated(unsafe) let rawValue = rawValue + return rawValue + } } +} +extension Locked { /// Acquire the lock and invoke a function while it is held. /// /// - Parameters: @@ -88,55 +95,83 @@ struct LockedWith: RawRepresentable where L: Lockable { /// This function can be used to synchronize access to shared data from a /// synchronous caller. Wherever possible, use actor isolation or other Swift /// concurrency tools. - nonmutating func withLock(_ body: (inout T) throws -> R) rethrows -> R where R: ~Copyable { - try _storage.withUnsafeMutablePointers { rawValue, lock in - L.unsafelyAcquireLock(at: lock) + func withLock(_ body: (inout T) throws -> sending R) rethrows -> sending R where R: ~Copyable { + nonisolated(unsafe) let result: R +#if SWT_TARGET_OS_APPLE && !SWT_NO_OS_UNFAIR_LOCK + result = try _storage.withUnsafeMutablePointers { rawValue, lock in + os_unfair_lock_lock(lock) defer { - L.unsafelyRelinquishLock(at: lock) + os_unfair_lock_unlock(lock) } return try body(&rawValue.pointee) } +#elseif !SWT_FIXED_85448 && (os(Linux) || os(Android)) + result = try _storage.withUnsafeMutablePointers { rawValue, lock in + pthread_mutex_lock(lock) + defer { + pthread_mutex_unlock(lock) + } + return try body(&rawValue.pointee) + } +#elseif canImport(Synchronization) + result = try _storage.mutex.withLock { rawValue in + try body(&rawValue) + } +#else +#error("Platform-specific misconfiguration: no mutex or lock type available") +#endif + return result } - /// Acquire the lock and invoke a function while it is held, yielding both the - /// protected value and a reference to the underlying lock guarding it. + /// Try to acquire the lock and invoke a function while it is held. /// /// - Parameters: /// - body: A closure to invoke while the lock is held. /// - /// - Returns: Whatever is returned by `body`. + /// - Returns: Whatever is returned by `body`, or `nil` if the lock could not + /// be acquired. /// /// - Throws: Whatever is thrown by `body`. /// - /// This function is equivalent to ``withLock(_:)`` except that the closure - /// passed to it also takes a reference to the underlying lock guarding this - /// instance's wrapped value. This function can be used when platform-specific - /// functionality such as a `pthread_cond_t` is needed. Because the caller has - /// direct access to the lock and is able to unlock and re-lock it, it is - /// unsafe to modify the protected value. - /// - /// - Warning: Callers that unlock the lock _must_ lock it again before the - /// closure returns. If the lock is not acquired when `body` returns, the - /// effect is undefined. - nonmutating func withUnsafeUnderlyingLock(_ body: (UnsafeMutablePointer, T) throws -> R) rethrows -> R where R: ~Copyable { - try withLock { value in - try _storage.withUnsafeMutablePointerToElements { lock in - try body(lock, value) + /// This function can be used to synchronize access to shared data from a + /// synchronous caller. Wherever possible, use actor isolation or other Swift + /// concurrency tools. + func withLockIfAvailable(_ body: (inout T) throws -> sending R) rethrows -> sending R? where R: ~Copyable { + nonisolated(unsafe) let result: R? +#if SWT_TARGET_OS_APPLE && !SWT_NO_OS_UNFAIR_LOCK + result = try _storage.withUnsafeMutablePointers { rawValue, lock in + guard os_unfair_lock_trylock(lock) else { + return nil + } + defer { + os_unfair_lock_unlock(lock) } + return try body(&rawValue.pointee) + } +#elseif !SWT_FIXED_85448 && (os(Linux) || os(Android)) + result = try _storage.withUnsafeMutablePointers { rawValue, lock in + guard 0 == pthread_mutex_trylock(lock) else { + return nil + } + defer { + pthread_mutex_unlock(lock) + } + return try body(&rawValue.pointee) } +#elseif canImport(Synchronization) + result = try _storage.mutex.withLockIfAvailable { rawValue in + return try body(&rawValue) + } +#else +#error("Platform-specific misconfiguration: no mutex or lock type available") +#endif + return result } } -extension LockedWith: Sendable where T: Sendable {} - -/// A type that wraps a value requiring access from a synchronous caller during -/// concurrent execution and which uses the default platform-specific lock type -/// for the current platform. -typealias Locked = LockedWith - // MARK: - Additions -extension LockedWith where T: AdditiveArithmetic { +extension Locked where T: AdditiveArithmetic & Sendable { /// Add something to the current wrapped value of this instance. /// /// - Parameters: @@ -152,7 +187,7 @@ extension LockedWith where T: AdditiveArithmetic { } } -extension LockedWith where T: Numeric { +extension Locked where T: Numeric & Sendable { /// Increment the current wrapped value of this instance. /// /// - Returns: The sum of ``rawValue`` and `1`. @@ -172,7 +207,7 @@ extension LockedWith where T: Numeric { } } -extension LockedWith { +extension Locked { /// Initialize an instance of this type with a raw value of `nil`. init() where T == V? { self.init(rawValue: nil) @@ -188,3 +223,10 @@ extension LockedWith { self.init(rawValue: []) } } + +// MARK: - POSIX conveniences + +#if os(FreeBSD) || os(OpenBSD) +typealias pthread_mutex_t = _TestingInternals.pthread_mutex_t? +typealias pthread_cond_t = _TestingInternals.pthread_cond_t? +#endif diff --git a/Sources/Testing/Support/Serializer.swift b/Sources/Testing/Support/Serializer.swift new file mode 100644 index 000000000..94f7d4f5b --- /dev/null +++ b/Sources/Testing/Support/Serializer.swift @@ -0,0 +1,99 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024–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 +// + +private import _TestingInternals + +#if !SWT_NO_UNSTRUCTURED_TASKS +/// The number of CPU cores on the current system, or `nil` if that +/// information is not available. +private var _cpuCoreCount: Int? { +#if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD) || os(Android) + return Int(sysconf(Int32(_SC_NPROCESSORS_CONF))) +#elseif os(Windows) + var siInfo = SYSTEM_INFO() + GetSystemInfo(&siInfo) + return Int(siInfo.dwNumberOfProcessors) +#elseif os(WASI) + return 1 +#else +#warning("Platform-specific implementation missing: CPU core count unavailable") + return nil +#endif +} +#endif + +/// The default parallelization width when parallelized testing is enabled. +var defaultParallelizationWidth: Int { + // _cpuCoreCount.map { max(1, $0) * 2 } ?? .max + .max +} + +/// A type whose instances can run a series of work items in strict order. +/// +/// When a work item is scheduled on an instance of this type, it runs after any +/// previously-scheduled work items. If it suspends, subsequently-scheduled work +/// items do not start running; they must wait until the suspended work item +/// either returns or throws an error. +/// +/// This type is not part of the public interface of the testing library. +final actor Serializer { + /// The maximum number of work items that may run concurrently. + nonisolated let maximumWidth: Int + +#if !SWT_NO_UNSTRUCTURED_TASKS + /// The number of scheduled work items, including any currently running. + private var _currentWidth = 0 + + /// Continuations for any scheduled work items that haven't started yet. + private var _continuations = [CheckedContinuation]() +#endif + + init(maximumWidth: Int = 1) { + precondition(maximumWidth >= 1, "Invalid serializer width \(maximumWidth).") + self.maximumWidth = maximumWidth + } + + /// Run a work item serially after any previously-scheduled work items. + /// + /// - Parameters: + /// - workItem: A closure to run. + /// + /// - Returns: Whatever is returned from `workItem`. + /// + /// - Throws: Whatever is thrown by `workItem`. + func run(_ workItem: @isolated(any) @Sendable () async throws -> R) async rethrows -> R where R: Sendable { +#if !SWT_NO_UNSTRUCTURED_TASKS + _currentWidth += 1 + defer { + // Resume the next scheduled closure. + if !_continuations.isEmpty { + let continuation = _continuations.removeFirst() + continuation.resume() + } + + _currentWidth -= 1 + } + + await withCheckedContinuation { continuation in + if _currentWidth <= maximumWidth { + // Nothing else was scheduled, so we can resume immediately. + continuation.resume() + } else { + // Something was scheduled, so add the continuation to the + // list. When it resumes, we can run. + _continuations.append(continuation) + } + } +#endif + + return try await workItem() + } +} + diff --git a/Sources/Testing/Support/VersionNumber.swift b/Sources/Testing/Support/VersionNumber.swift new file mode 100644 index 000000000..4dfa52994 --- /dev/null +++ b/Sources/Testing/Support/VersionNumber.swift @@ -0,0 +1,138 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024–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 +// + +private import _TestingInternals + +/// A type describing an ABI version number. +/// +/// This type implements a subset of the [semantic versioning](https://semver.org) +/// specification (specifically parsing, displaying, and comparing +/// `` values we expect that the testing library will need for the +/// foreseeable future.) +struct VersionNumber: Sendable { + /// The integer type used to store a component. + /// + /// The testing library does not generally need to deal with version numbers + /// whose components exceed the width of this type. If we need to deal with + /// larger version number components in the future, we can increase the width + /// of this type accordingly. + typealias Component = Int8 + + /// The major version. + var majorComponent: Component + + /// The minor version. + var minorComponent: Component + + /// The patch, revision, or bug fix version. + var patchComponent: Component = 0 +} + +extension VersionNumber { + init(_ majorComponent: _const Component, _ minorComponent: _const Component, _ patchComponent: _const Component = 0) { + self.init(majorComponent: majorComponent, minorComponent: minorComponent, patchComponent: patchComponent) + } +} + +// MARK: - CustomStringConvertible + +extension VersionNumber: CustomStringConvertible { + /// Initialize an instance of this type by parsing the given string. + /// + /// - Parameters: + /// - string: The string to parse, such as `"0"` or `"6.3.0"`. + /// + /// @Comment { + /// - Bug: We are not able to reuse the logic from swift-syntax's + /// `VersionTupleSyntax` type here because we cannot link to swift-syntax + /// in this target. + /// } + /// + /// If `string` contains fewer than 3 numeric components, the missing + /// components are inferred to be `0` (for example, `"1.2"` is equivalent to + /// `"1.2.0"`.) If `string` contains more than 3 numeric components, the + /// additional components are ignored. + init?(_ string: String) { + // Split the string on "." (assuming it is of the form "1", "1.2", or + // "1.2.3") and parse the individual components as integers. + let components = string.split(separator: ".", omittingEmptySubsequences: false) + func componentValue(_ index: Int) -> Component? { + components.count > index ? Component(components[index]) : 0 + } + + guard let majorComponent = componentValue(0), + let minorComponent = componentValue(1), + let patchComponent = componentValue(2) else { + return nil + } + self.init(majorComponent: majorComponent, minorComponent: minorComponent, patchComponent: patchComponent) + } + + var description: String { + if majorComponent <= 0 && minorComponent == 0 && patchComponent == 0 { + // Version 0 and earlier are described as integers for compatibility with + // Swift 6.2 and earlier. + return String(describing: majorComponent) + } else if patchComponent == 0 { + return "\(majorComponent).\(minorComponent)" + } + return "\(majorComponent).\(minorComponent).\(patchComponent)" + } +} + +// MARK: - Equatable, Comparable + +extension VersionNumber: Equatable, Comparable { + static func <(lhs: Self, rhs: Self) -> Bool { + if lhs.majorComponent != rhs.majorComponent { + return lhs.majorComponent < rhs.majorComponent + } else if lhs.minorComponent != rhs.minorComponent { + return lhs.minorComponent < rhs.minorComponent + } else if lhs.patchComponent != rhs.patchComponent { + return lhs.patchComponent < rhs.patchComponent + } + return false + } +} + +// MARK: - Codable + +extension VersionNumber: Codable { + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if let number = try? container.decode(Component.self) { + // Allow for version numbers encoded as integers for compatibility with + // Swift 6.2 and earlier. + self.init(majorComponent: number, minorComponent: 0) + } else { + let string = try container.decode(String.self) + guard let result = Self(string) else { + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: "Unexpected string '\(string)' (expected an integer or a string of the form '1.2.3')" + ) + ) + } + self = result + } + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + if majorComponent <= 0 && minorComponent == 0 && patchComponent == 0 { + // Version 0 and earlier are encoded as integers for compatibility with + // Swift 6.2 and earlier. + try container.encode(majorComponent) + } else { + try container.encode("\(majorComponent).\(minorComponent).\(patchComponent)") + } + } +} diff --git a/Sources/Testing/Support/Versions.swift b/Sources/Testing/Support/Versions.swift index 1eb7f4e48..44955cb8f 100644 --- a/Sources/Testing/Support/Versions.swift +++ b/Sources/Testing/Support/Versions.swift @@ -9,6 +9,7 @@ // private import _TestingInternals +private import SwiftShims /// A human-readable string describing the current operating system's version. /// @@ -76,7 +77,7 @@ let operatingSystemVersion: String = { // Include Service Pack details if available. if versionInfo.szCSDVersion.0 != 0 { withUnsafeBytes(of: versionInfo.szCSDVersion) { szCSDVersion in - szCSDVersion.withMemoryRebound(to: wchar_t.self) { szCSDVersion in + szCSDVersion.withMemoryRebound(to: CWideChar.self) { szCSDVersion in if let szCSDVersion = String.decodeCString(szCSDVersion.baseAddress!, as: UTF16.self)?.result { result += " (\(szCSDVersion))" } @@ -88,9 +89,8 @@ let operatingSystemVersion: String = { } } #elseif os(WASI) - if let version = swt_getWASIVersion().flatMap(String.init(validatingCString:)) { - return version - } + // WASI does not have an API to get the current WASI or Wasm version. + // wasi-libc does have uname(3), but it's stubbed out. #else #warning("Platform-specific implementation missing: OS version unavailable") #endif @@ -128,9 +128,26 @@ let simulatorVersion: String = { /// an event writer. /// /// This value is not part of the public interface of the testing library. -var testingLibraryVersion: String { - swt_getTestingLibraryVersion().flatMap(String.init(validatingCString:)) ?? "unknown" -} +let testingLibraryVersion: String = { + var result = swt_getTestingLibraryVersion().flatMap(String.init(validatingCString:)) ?? "unknown" + + // Get details of the git commit used when compiling the testing library. + var commitHash: UnsafePointer? + var commitModified = CBool(false) + swt_getTestingLibraryCommit(&commitHash, &commitModified) + + if let commitHash = commitHash.flatMap(String.init(validatingCString:)) { + // Truncate to 15 characters of the hash to match `swift --version`. + let commitHash = commitHash.prefix(15) + if commitModified { + result = "\(result) (\(commitHash) - modified)" + } else { + result = "\(result) (\(commitHash))" + } + } + + return result +}() /// Get the LLVM target triple used to build the testing library, if available. /// @@ -141,18 +158,69 @@ var targetTriple: String? { /// A human-readable string describing the Swift Standard Library's version. /// -/// This value's format is platform-specific and is not meant to be -/// machine-readable. It is added to the output of a test run when using -/// an event writer. +/// This value is unavailable on some earlier Apple runtime targets. On those +/// targets, this property has a value of `5.0.0`. /// /// This value is not part of the public interface of the testing library. -let swiftStandardLibraryVersion: String = { - if #available(_swiftVersionAPI, *) { - return String(describing: _SwiftStdlibVersion.current) +let swiftStandardLibraryVersion: VersionNumber? = { + guard #available(_swiftVersionAPI, *) else { + return VersionNumber(5, 0) } - return "unknown" + let packedValue = _SwiftStdlibVersion.current._value + return VersionNumber( + majorComponent: .init((packedValue & 0xFFFF0000) >> 16), + minorComponent: .init((packedValue & 0x0000FF00) >> 8), + patchComponent: .init((packedValue & 0x000000FF) >> 0) + ) }() +/// The version of the Swift compiler used to build the testing library. +/// +/// This value is determined at compile time by the Swift compiler. For more +/// information, see [Version.h](https://github.com/swiftlang/swift/blob/main/include/swift/Basic/Version.h) +/// and [ClangImporter.cpp](https://github.com/swiftlang/swift/blob/main/lib/ClangImporter/ClangImporter.cpp) +/// in the Swift repository. +/// +/// This value is not part of the public interface of the testing library. +var swiftCompilerVersion: VersionNumber { + let packedValue = swt_getSwiftCompilerVersion() + if packedValue == 0, let swiftStandardLibraryVersion { + // The compiler did not supply its version. This is currently expected on + // non-Darwin targets in particular. Substitute the stdlib version (which + // should generally be aligned on non-Darwin targets.) + return swiftStandardLibraryVersion + } + return VersionNumber( + majorComponent: .init((packedValue % 1_000_000_000_000_000) / 1_000_000_000_000), + minorComponent: .init((packedValue % 1_000_000_000_000) / 1_000_000_000), + patchComponent: .init((packedValue % 1_000_000_000) / 1_000_000) + ) +} + +#if os(Linux) && canImport(Glibc) +/// The (runtime, not compile-time) version of glibc in use on this system. +/// +/// This value is not part of the public interface of the testing library. +let glibcVersion: VersionNumber = { + // Default to the statically available version number if the function call + // fails for some reason. + var major = Int(clamping: __GLIBC__) + var minor = Int(clamping: __GLIBC_MINOR__) + + if let strVersion = gnu_get_libc_version() { + withUnsafeMutablePointer(to: &major) { major in + withUnsafeMutablePointer(to: &minor) { minor in + withVaList([major, minor]) { args in + _ = vsscanf(strVersion, "%zd.%zd", args) + } + } + } + } + + return VersionNumber(majorComponent: .init(clamping: major), minorComponent: .init(clamping: minor)) +}() +#endif + // MARK: - sysctlbyname() Wrapper #if !SWT_NO_SYSCTL && SWT_TARGET_OS_APPLE diff --git a/Sources/Testing/Test+Cancellation.swift b/Sources/Testing/Test+Cancellation.swift new file mode 100644 index 000000000..43fd54391 --- /dev/null +++ b/Sources/Testing/Test+Cancellation.swift @@ -0,0 +1,285 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +/// A protocol describing cancellable tests and test cases. +/// +/// This protocol is used to abstract away the common implementation of test and +/// test case cancellation. +protocol TestCancellable: Sendable { + /// Make an instance of ``Event/Kind`` appropriate for an instance of this + /// type. + /// + /// - Parameters: + /// - skipInfo: The ``SkipInfo`` structure describing the cancellation. + /// + /// - Returns: An instance of ``Event/Kind`` that describes the cancellation. + static func makeCancelledEventKind(with skipInfo: SkipInfo) -> Event.Kind +} + +// MARK: - Tracking the current task + +/// A structure that is able to cancel a task. +private struct _TaskCanceller: Sendable { + /// The unsafe underlying reference to the associated task. + private nonisolated(unsafe) var _unsafeCurrentTask = Locked() + + init() { + // WARNING! Normally, allowing an instance of `UnsafeCurrentTask` to escape + // its scope is dangerous because it could be used unsafely after the task + // ends. However, because we take care not to allow the task object to + // escape the task (by only storing it in a task-local value), we can ensure + // these unsafe scenarios won't occur. + // + // TODO: when our deployment targets allow, we should switch to calling the + // `async` overload of `withUnsafeCurrentTask()` from the body of + // `withCancellationHandling(_:)`. That will allow us to use the task object + // in a safely scoped fashion. + _unsafeCurrentTask = withUnsafeCurrentTask { Locked(rawValue: $0) } + } + + /// Clear this instance's reference to its associated task without first + /// cancelling it. + func clear() { + _unsafeCurrentTask.withLock { unsafeCurrentTask in + unsafeCurrentTask = nil + } + } + + /// Cancel this instance's associated task and clear the reference to it. + /// + /// - Returns: Whether or not this instance's task was cancelled. + /// + /// After the first call to this function _starts_, subsequent calls on the + /// same instance return `false`. In other words, if another thread calls this + /// function before it has returned (or the same thread calls it recursively), + /// it returns `false` without cancelling the task a second time. + func cancel(with skipInfo: SkipInfo) -> Bool { + // trylock means a recursive call to this function won't ruin our day, nor + // should interleaving locks. + _unsafeCurrentTask.withLockIfAvailable { unsafeCurrentTask in + defer { + unsafeCurrentTask = nil + } + if let unsafeCurrentTask { + // The task is still valid, so we'll cancel it. + $_currentSkipInfo.withValue(skipInfo) { + unsafeCurrentTask.cancel() + } + return true + } + + // The task has already been cancelled and/or cleared. + return false + } ?? false + } +} + +/// A dictionary of cancellable tasks keyed by types that conform to +/// ``TestCancellable``. +@TaskLocal private var _currentTaskCancellers = [ObjectIdentifier: _TaskCanceller]() + +/// The instance of ``SkipInfo`` to propagate to children of the current task. +/// +/// We set this value while calling `UnsafeCurrentTask.cancel()` so that its +/// value is available in tracked child tasks when their cancellation handlers +/// are called (in ``TestCancellable/withCancellationHandling(_:)`` below). +@TaskLocal private var _currentSkipInfo: SkipInfo? + +extension TestCancellable { + /// Call a function while the ``unsafeCurrentTask`` property of this instance + /// is set to the current task. + /// + /// - Parameters: + /// - body: The function to invoke. + /// + /// - Returns: Whatever is returned by `body`. + /// + /// - Throws: Whatever is thrown by `body`. + /// + /// This function sets up a task cancellation handler and calls `body`. If + /// the current task, test, or test case is cancelled, it records a + /// corresponding cancellation event. + func withCancellationHandling(_ body: () async throws -> R) async rethrows -> R { + let taskCanceller = _TaskCanceller() + var currentTaskCancellers = _currentTaskCancellers + currentTaskCancellers[ObjectIdentifier(Self.self)] = taskCanceller + return try await $_currentTaskCancellers.withValue(currentTaskCancellers) { + // Before returning, explicitly clear the stored task so that an + // unstructured task that inherits the task local isn't able to + // accidentally cancel the task after it has been deallocated. + defer { + taskCanceller.clear() + } + + return try await withTaskCancellationHandler { + try await body() + } onCancel: { + // The current task was cancelled, so cancel the test case or test + // associated with it. + let skipInfo = _currentSkipInfo ?? SkipInfo(sourceContext: SourceContext(backtrace: .current(), sourceLocation: nil)) + _ = try? Test.cancel(with: skipInfo) + } + } + } +} + +// MARK: - + +/// The common implementation of cancellation for ``Test`` and ``Test/Case``. +/// +/// - Parameters: +/// - cancellableValue: The test or test case to cancel, or `nil` if neither +/// is set and we need fallback handling. +/// - testAndTestCase: The test and test case to use when posting an event. +/// - skipInfo: Information about the cancellation event. +private func _cancel(_ cancellableValue: T?, for testAndTestCase: (Test?, Test.Case?), skipInfo: SkipInfo) where T: TestCancellable { + if cancellableValue != nil, let taskCanceller = _currentTaskCancellers[ObjectIdentifier(T.self)] { + // Try to cancel the task associated with `T`, if any. If we succeed, post a + // corresponding event with the relevant skip info. If we fail, we still + // attempt to cancel the current *task* in order to honor our API contract. + if taskCanceller.cancel(with: skipInfo) { + Event.post(T.makeCancelledEventKind(with: skipInfo), for: testAndTestCase) + } else { + withUnsafeCurrentTask { task in + task?.cancel() + } + } + } else { + // The current task isn't associated with a test/case, so just cancel the + // task. + withUnsafeCurrentTask { task in + task?.cancel() + } + + var inExitTest = false +#if !SWT_NO_EXIT_TESTS + inExitTest = (ExitTest.current != nil) +#endif + if Bool(inExitTest) { + // This code is running in an exit test. We don't have a "current test" or + // "current test case" in the child process, so we'll let the parent + // process sort that out. + Event.post(T.makeCancelledEventKind(with: skipInfo), for: (nil, nil)) + } else { + // Record an API misuse issue for trying to cancel the current test/case + // outside of any useful context. + let issue = Issue( + kind: .apiMisused, + comments: [ + "Attempted to cancel the current test or test case, but one is not associated with the current task.", + skipInfo.comment, + ].compactMap(\.self), + sourceContext: skipInfo.sourceContext + ) + issue.record() + } + } +} + +// MARK: - Test cancellation + +extension Test: TestCancellable { + /// Cancel the current test or test case. + /// + /// - Parameters: + /// - comment: A comment describing why you are cancelling the test or test + /// case. + /// - sourceLocation: The source location to which the testing library will + /// attribute the cancellation. + /// + /// - Throws: An error indicating that the current test or test case has been + /// cancelled. + /// + /// The testing library runs each test and each test case in its own task. + /// When you call this function, the testing library cancels the task + /// associated with the current test: + /// + /// ```swift + /// @Test func `Food truck is well-stocked`() throws { + /// guard businessHours.contains(.now) else { + /// try Test.cancel("We're off the clock.") + /// } + /// // ... + /// } + /// ``` + /// + /// If the current test is a parameterized test function, this function + /// instead cancels the current test case. Other test cases in the test + /// function are not affected. + /// + /// If the current test is a suite, the testing library cancels all of its + /// pending and running tests. + /// + /// If you have already cancelled the current test or if it has already + /// finished running, this function throws an error to indicate that the + /// current test has been cancelled, but does not attempt to cancel the test a + /// second time. + /// + /// @Comment { + /// TODO: Document the interaction between an exit test and test + /// cancellation. In particular, the error thrown by this function isn't + /// thrown into the parent process and task cancellation doesn't propagate + /// (because the exit test _de facto_ runs in a detached task.) + /// } + /// + /// - Important: If the current task is not associated with a test (for + /// example, because it was created with [`Task.detached(name:priority:operation:)`](https://developer.apple.com/documentation/swift/task/detached(name:priority:operation:)-795w1)) + /// this function records an issue and cancels the current task. + @_spi(Experimental) + public static func cancel(_ comment: Comment? = nil, sourceLocation: SourceLocation = #_sourceLocation) throws -> Never { + let skipInfo = SkipInfo(comment: comment, sourceContext: SourceContext(backtrace: nil, sourceLocation: sourceLocation)) + try Self.cancel(with: skipInfo) + } + + /// Cancel the current test or test case. + /// + /// - Parameters: + /// - skipInfo: Information about the cancellation event. + /// + /// - Throws: An error indicating that the current test or test case has been + /// cancelled. + /// + /// Note that the public ``Test/cancel(_:sourceLocation:)`` function has a + /// different signature and accepts a source location rather than an instance + /// of ``SkipInfo``. + static func cancel(with skipInfo: SkipInfo) throws -> Never { + let test = Test.current + let testCase = Test.Case.current + + if let testCase { + // Cancel the current test case. + _cancel(testCase, for: (test, testCase), skipInfo: skipInfo) + } + + if let test { + if !test.isParameterized { + // The current test is not parameterized, so cancel the whole test too. + _cancel(test, for: (test, nil), skipInfo: skipInfo) + } + } else { + // There is no current test (this is the API misuse path.) + _cancel(test, for: (test, nil), skipInfo: skipInfo) + } + + throw skipInfo + } + + static func makeCancelledEventKind(with skipInfo: SkipInfo) -> Event.Kind { + .testCancelled(skipInfo) + } +} + +// MARK: - Test case cancellation + +extension Test.Case: TestCancellable { + static func makeCancelledEventKind(with skipInfo: SkipInfo) -> Event.Kind { + .testCaseCancelled(skipInfo) + } +} diff --git a/Sources/Testing/Test+Discovery+Legacy.swift b/Sources/Testing/Test+Discovery+Legacy.swift index 8ff878338..cafc55a4e 100644 --- a/Sources/Testing/Test+Discovery+Legacy.swift +++ b/Sources/Testing/Test+Discovery+Legacy.swift @@ -24,14 +24,14 @@ public protocol __TestContentRecordContainer { nonisolated static var __testContentRecord: __TestContentRecord { get } } -extension DiscoverableAsTestContent where Self: ~Copyable { +extension DiscoverableAsTestContent { /// Get all test content of this type known to Swift and found in the current /// process using the legacy discovery mechanism. /// /// - Returns: A sequence of instances of ``TestContentRecord``. Only test /// content records matching this ``TestContent`` type's requirements are /// included in the sequence. - static func allTypeMetadataBasedTestContentRecords() -> AnySequence> { + static func allTypeMetadataBasedTestContentRecords() -> some Sequence> { return allTypeMetadataBasedTestContentRecords { type, buffer in guard let type = type as? any __TestContentRecordContainer.Type else { return false diff --git a/Sources/Testing/Test+Discovery.swift b/Sources/Testing/Test+Discovery.swift index 35f716525..d7d3b8ea7 100644 --- a/Sources/Testing/Test+Discovery.swift +++ b/Sources/Testing/Test+Discovery.swift @@ -39,7 +39,7 @@ extension Test { /// /// - Warning: This function is used to implement the `@Test` macro. Do not /// use it directly. - public static func __store( + @safe public static func __store( _ generator: @escaping @Sendable () async -> Test, into outValue: UnsafeMutableRawPointer, asTypeAt typeAddress: UnsafeRawPointer @@ -84,9 +84,11 @@ extension Test { // a task group and collate their results. if useNewMode { let generators = Generator.allTestContentRecords().lazy.compactMap { $0.load() } - await withTaskGroup(of: Self.self) { taskGroup in - for generator in generators { - taskGroup.addTask { await generator.rawValue() } + await withTaskGroup { taskGroup in + for (i, generator) in generators.enumerated() { + taskGroup.addTask(name: decorateTaskName("test discovery", withAction: "loading test #\(i)")) { + await generator.rawValue() + } } result = await taskGroup.reduce(into: result) { $0.insert($1) } } @@ -96,9 +98,11 @@ extension Test { // Perform legacy test discovery if needed. if useLegacyMode && result.isEmpty { let generators = Generator.allTypeMetadataBasedTestContentRecords().lazy.compactMap { $0.load() } - await withTaskGroup(of: Self.self) { taskGroup in - for generator in generators { - taskGroup.addTask { await generator.rawValue() } + await withTaskGroup { taskGroup in + for (i, generator) in generators.enumerated() { + taskGroup.addTask(name: decorateTaskName("type-based test discovery", withAction: "loading test #\(i)")) { + await generator.rawValue() + } } result = await taskGroup.reduce(into: result) { $0.insert($1) } } diff --git a/Sources/Testing/Test+Macro.swift b/Sources/Testing/Test+Macro.swift index be0b5a91b..78464cee8 100644 --- a/Sources/Testing/Test+Macro.swift +++ b/Sources/Testing/Test+Macro.swift @@ -54,7 +54,7 @@ public typealias __XCTestCompatibleSelector = Never /// - traits: Zero or more traits to apply to this test suite. /// /// A test suite is a type that contains one or more test functions. Any -/// copyable type (that is, any type that is not marked `~Copyable`) may be a +/// escapable type (that is, any type that is not marked `~Escapable`) may be a /// test suite. /// /// The use of the `@Suite` attribute is optional; types are recognized as test @@ -82,7 +82,7 @@ public macro Suite( /// - traits: Zero or more traits to apply to this test suite. /// /// A test suite is a type that contains one or more test functions. Any -/// copyable type (that is, any type that is not marked `~Copyable`) may be a +/// escapable type (that is, any type that is not marked `~Escapable`) may be a /// test suite. /// /// The use of the `@Suite` attribute is optional; types are recognized as test @@ -157,16 +157,16 @@ extension Test { /// /// - Warning: This function is used to implement the `@Test` macro. Do not /// call it directly. - public static func __function( + public static func __function( named testFunctionName: String, - in containingType: (any ~Copyable.Type)?, + in containingType: S.Type?, xcTestCompatibleSelector: __XCTestCompatibleSelector?, displayName: String? = nil, traits: [any TestTrait], sourceLocation: SourceLocation, parameters: [__Parameter] = [], testFunction: @escaping @Sendable () async throws -> Void - ) -> Self { + ) -> Self where S: ~Copyable { // Don't use Optional.map here due to a miscompile/crash. Expand out to an // if expression instead. SEE: rdar://134280902 let containingTypeInfo: TypeInfo? = if let containingType { @@ -217,8 +217,10 @@ public macro Test( /// - collection: A collection of values to pass to the associated test /// function. /// -/// During testing, the associated test function is called once for each element -/// in `collection`. +/// You can prefix the expression you pass to `collection` with `try` or `await`. +/// The testing library evaluates the expression lazily only if it determines +/// that the associated test will run. During testing, the testing library calls +/// the associated test function once for each element in `collection`. /// /// @Comment { /// - Bug: The testing library should support variadic generics. @@ -239,9 +241,9 @@ extension Test { /// /// - Warning: This function is used to implement the `@Test` macro. Do not /// call it directly. - public static func __function( + public static func __function( named testFunctionName: String, - in containingType: (any ~Copyable.Type)?, + in containingType: S.Type?, xcTestCompatibleSelector: __XCTestCompatibleSelector?, displayName: String? = nil, traits: [any TestTrait], @@ -249,7 +251,7 @@ extension Test { sourceLocation: SourceLocation, parameters paramTuples: [__Parameter], testFunction: @escaping @Sendable (C.Element) async throws -> Void - ) -> Self where C: Collection & Sendable, C.Element: Sendable { + ) -> Self where S: ~Copyable, C: Collection & Sendable, C.Element: Sendable { let containingTypeInfo: TypeInfo? = if let containingType { TypeInfo(describing: containingType) } else { @@ -270,7 +272,10 @@ extension Test { /// - collection1: A collection of values to pass to `testFunction`. /// - collection2: A second collection of values to pass to `testFunction`. /// -/// During testing, the associated test function is called once for each pair of +/// You can prefix the expressions you pass to `collection1` or `collection2` +/// with `try` or `await`. The testing library evaluates the expressions lazily +/// only if it determines that the associated test will run. During testing, the +/// testing library calls the associated test function once for each pair of /// elements in `collection1` and `collection2`. /// /// @Comment { @@ -298,7 +303,10 @@ public macro Test( /// - collection1: A collection of values to pass to `testFunction`. /// - collection2: A second collection of values to pass to `testFunction`. /// -/// During testing, the associated test function is called once for each pair of +/// You can prefix the expressions you pass to `collection1` or `collection2` +/// with `try` or `await`. The testing library evaluates the expressions lazily +/// only if it determines that the associated test will run. During testing, the +/// testing library calls the associated test function once for each pair of /// elements in `collection1` and `collection2`. /// /// @Comment { @@ -324,8 +332,11 @@ public macro Test( /// - zippedCollections: Two zipped collections of values to pass to /// `testFunction`. /// -/// During testing, the associated test function is called once for each element -/// in `zippedCollections`. +/// You can prefix the expression you pass to `zippedCollections` with `try` or +/// `await`. The testing library evaluates the expression lazily only if it +/// determines that the associated test will run. During testing, the testing +/// library calls the associated test function once for each element in +/// `zippedCollections`. /// /// @Comment { /// - Bug: The testing library should support variadic generics. @@ -352,8 +363,11 @@ public macro Test( /// - zippedCollections: Two zipped collections of values to pass to /// `testFunction`. /// -/// During testing, the associated test function is called once for each element -/// in `zippedCollections`. +/// You can prefix the expression you pass to `zippedCollections` with `try` or +/// `await`. The testing library evaluates the expression lazily only if it +/// determines that the associated test will run. During testing, the testing +/// library calls the associated test function once for each element in +/// `zippedCollections`. /// /// @Comment { /// - Bug: The testing library should support variadic generics. @@ -374,9 +388,9 @@ extension Test { /// /// - Warning: This function is used to implement the `@Test` macro. Do not /// call it directly. - public static func __function( + public static func __function( named testFunctionName: String, - in containingType: (any ~Copyable.Type)?, + in containingType: S.Type?, xcTestCompatibleSelector: __XCTestCompatibleSelector?, displayName: String? = nil, traits: [any TestTrait], @@ -384,7 +398,7 @@ extension Test { sourceLocation: SourceLocation, parameters paramTuples: [__Parameter], testFunction: @escaping @Sendable (C1.Element, C2.Element) async throws -> Void - ) -> Self where C1: Collection & Sendable, C1.Element: Sendable, C2: Collection & Sendable, C2.Element: Sendable { + ) -> Self where S: ~Copyable, C1: Collection & Sendable, C1.Element: Sendable, C2: Collection & Sendable, C2.Element: Sendable { let containingTypeInfo: TypeInfo? = if let containingType { TypeInfo(describing: containingType) } else { @@ -402,9 +416,9 @@ extension Test { /// /// - Warning: This function is used to implement the `@Test` macro. Do not /// call it directly. - public static func __function( + public static func __function( named testFunctionName: String, - in containingType: (any ~Copyable.Type)?, + in containingType: S.Type?, xcTestCompatibleSelector: __XCTestCompatibleSelector?, displayName: String? = nil, traits: [any TestTrait], @@ -412,7 +426,7 @@ extension Test { sourceLocation: SourceLocation, parameters paramTuples: [__Parameter], testFunction: @escaping @Sendable ((E1, E2)) async throws -> Void - ) -> Self where C: Collection & Sendable, C.Element == (E1, E2), E1: Sendable, E2: Sendable { + ) -> Self where S: ~Copyable, C: Collection & Sendable, C.Element == (E1, E2), E1: Sendable, E2: Sendable { let containingTypeInfo: TypeInfo? = if let containingType { TypeInfo(describing: containingType) } else { @@ -433,9 +447,9 @@ extension Test { /// /// - Warning: This function is used to implement the `@Test` macro. Do not /// call it directly. - public static func __function( + public static func __function( named testFunctionName: String, - in containingType: (any ~Copyable.Type)?, + in containingType: S.Type?, xcTestCompatibleSelector: __XCTestCompatibleSelector?, displayName: String? = nil, traits: [any TestTrait], @@ -443,7 +457,7 @@ extension Test { sourceLocation: SourceLocation, parameters paramTuples: [__Parameter], testFunction: @escaping @Sendable ((Key, Value)) async throws -> Void - ) -> Self where Key: Sendable, Value: Sendable { + ) -> Self where S: ~Copyable, Key: Sendable, Value: Sendable { let containingTypeInfo: TypeInfo? = if let containingType { TypeInfo(describing: containingType) } else { @@ -458,9 +472,9 @@ extension Test { /// /// - Warning: This function is used to implement the `@Test` macro. Do not /// call it directly. - public static func __function( + public static func __function( named testFunctionName: String, - in containingType: (any ~Copyable.Type)?, + in containingType: S.Type?, xcTestCompatibleSelector: __XCTestCompatibleSelector?, displayName: String? = nil, traits: [any TestTrait], @@ -468,7 +482,7 @@ extension Test { sourceLocation: SourceLocation, parameters paramTuples: [__Parameter], testFunction: @escaping @Sendable (C1.Element, C2.Element) async throws -> Void - ) -> Self where C1: Collection & Sendable, C1.Element: Sendable, C2: Collection & Sendable, C2.Element: Sendable { + ) -> Self where S: ~Copyable, C1: Collection & Sendable, C1.Element: Sendable, C2: Collection & Sendable, C2.Element: Sendable { let containingTypeInfo: TypeInfo? = if let containingType { TypeInfo(describing: containingType) } else { @@ -542,7 +556,7 @@ extension Test { /// /// - 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 { +@unsafe @inlinable public func __requiringUnsafe(_ value: consuming T) -> T where T: ~Copyable { value } diff --git a/Sources/Testing/Test.swift b/Sources/Testing/Test.swift index 738daf72d..52b41137d 100644 --- a/Sources/Testing/Test.swift +++ b/Sources/Testing/Test.swift @@ -69,26 +69,19 @@ public struct Test: Sendable { public nonisolated(unsafe) var xcTestCompatibleSelector: __XCTestCompatibleSelector? /// An enumeration describing the evaluation state of a test's cases. - /// - /// This use of `@unchecked Sendable` and of `AnySequence` in this type's - /// cases is necessary because it is not currently possible to express - /// `Sequence & Sendable` as an existential (`any`) - /// ([96960993](rdar://96960993)). It is also not possible to have a value of - /// an underlying generic sequence type without specifying its generic - /// parameters. - fileprivate enum TestCasesState: @unchecked Sendable { + fileprivate enum TestCasesState: Sendable { /// The test's cases have not yet been evaluated. /// /// - Parameters: /// - function: The function to call to evaluate the test's cases. The /// result is a sequence of test cases. - case unevaluated(_ function: @Sendable () async throws -> AnySequence) + case unevaluated(_ function: @Sendable () async throws -> any Sequence & Sendable) /// The test's cases have been evaluated. /// /// - Parameters: /// - testCases: The test's cases. - case evaluated(_ testCases: AnySequence) + case evaluated(_ testCases: any Sequence & Sendable) /// An error was thrown when the testing library attempted to evaluate the /// test's cases. @@ -124,7 +117,7 @@ public struct Test: Sendable { // attempt to run it, and thus never access this property. preconditionFailure("Attempting to access test cases with invalid state. Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new and include this information: \(String(reflecting: testCasesState))") } - return testCases + return AnySequence(testCases) } } @@ -139,7 +132,7 @@ public struct Test: Sendable { var uncheckedTestCases: (some Sequence)? { testCasesState.flatMap { testCasesState in if case let .evaluated(testCases) = testCasesState { - return testCases + return AnySequence(testCases) } return nil } @@ -209,8 +202,13 @@ public struct Test: Sendable { containingTypeInfo: TypeInfo, isSynthesized: Bool = false ) { - self.name = containingTypeInfo.unqualifiedName - self.displayName = displayName + let name = containingTypeInfo.unqualifiedName + self.name = name + if let displayName { + self.displayName = displayName + } else if isSynthesized && name.count > 2 && name.first == "`" && name.last == "`" { + self.displayName = String(name.dropFirst().dropLast()) + } self.traits = traits self.sourceLocation = sourceLocation self.containingTypeInfo = containingTypeInfo @@ -234,7 +232,7 @@ public struct Test: Sendable { self.sourceLocation = sourceLocation self.containingTypeInfo = containingTypeInfo self.xcTestCompatibleSelector = xcTestCompatibleSelector - self.testCasesState = .unevaluated { .init(try await testCases()) } + self.testCasesState = .unevaluated { try await testCases() } self.parameters = parameters } @@ -255,7 +253,7 @@ public struct Test: Sendable { self.sourceLocation = sourceLocation self.containingTypeInfo = containingTypeInfo self.xcTestCompatibleSelector = xcTestCompatibleSelector - self.testCasesState = .evaluated(.init(testCases)) + self.testCasesState = .evaluated(testCases) self.parameters = parameters } } diff --git a/Sources/Testing/Testing.docc/Attachments.md b/Sources/Testing/Testing.docc/Attachments.md index 0da40c201..b84a50e13 100644 --- a/Sources/Testing/Testing.docc/Attachments.md +++ b/Sources/Testing/Testing.docc/Attachments.md @@ -14,7 +14,7 @@ Attach values to tests to help diagnose issues and gather feedback. ## Overview -Attach values such as strings and files to tests. Implement the ``Attachable`` +Attach values such as strings and files to tests. Implement the ``Attachable`` protocol to create your own attachable types. ## Topics @@ -25,8 +25,16 @@ protocol to create your own attachable types. - ``Attachable`` - ``AttachableWrapper`` + + +### Attaching images to tests + +- ``AttachableAsImage`` +- ``AttachableImageFormat`` +- ``Attachment/init(_:named:as:sourceLocation:)`` +- ``Attachment/record(_:named:as:sourceLocation:)`` diff --git a/Sources/Testing/Testing.docc/DefiningTests.md b/Sources/Testing/Testing.docc/DefiningTests.md index 6b8cc4f00..237a64b4e 100644 --- a/Sources/Testing/Testing.docc/DefiningTests.md +++ b/Sources/Testing/Testing.docc/DefiningTests.md @@ -25,8 +25,9 @@ contains the test: import Testing ``` -- Note: Only import the testing library into a test target. Importing the - testing library into an application, library, or binary target isn't +- Note: Only import the testing library into a test target or library meant for + test targets. Importing the testing library into a target intended for + distribution such as an application, app library, or executable target isn't supported or recommended. Test functions aren't stripped from binaries when building for release, so logic and fixtures of a test may be visible to anyone who inspects a build product that contains a test function. diff --git a/Sources/Testing/Testing.docc/Documentation.md b/Sources/Testing/Testing.docc/Documentation.md index cc4001889..fb4ecc347 100644 --- a/Sources/Testing/Testing.docc/Documentation.md +++ b/Sources/Testing/Testing.docc/Documentation.md @@ -35,10 +35,8 @@ their problems. #### Related videos -@Links(visualStyle: compactGrid) { - - - - -} +- [Meet Swift Testing](https://developer.apple.com/videos/play/wwdc2024/10179) +- [Go further with Swift Testing](https://developer.apple.com/videos/play/wwdc2024/10195) ## Topics diff --git a/Sources/Testing/Testing.docc/EnablingAndDisabling.md b/Sources/Testing/Testing.docc/EnablingAndDisabling.md index 9fab8eeab..7e3f31bde 100644 --- a/Sources/Testing/Testing.docc/EnablingAndDisabling.md +++ b/Sources/Testing/Testing.docc/EnablingAndDisabling.md @@ -120,3 +120,20 @@ func allIngredientsAvailable(for food: Food) -> Bool { ... } ) func makeSundae() async throws { ... } ``` + + diff --git a/Sources/Testing/Testing.docc/LimitingExecutionTime.md b/Sources/Testing/Testing.docc/LimitingExecutionTime.md index 151b52028..f86091fe4 100644 --- a/Sources/Testing/Testing.docc/LimitingExecutionTime.md +++ b/Sources/Testing/Testing.docc/LimitingExecutionTime.md @@ -18,7 +18,7 @@ Some tests may naturally run slowly: they may require significant system resources to complete, may rely on downloaded data from a server, or may otherwise be dependent on external factors. -If a test may hang indefinitely or may consume too many system resources to +If a test might stall indefinitely or might consume too many system resources to complete effectively, consider setting a time limit for it so that it's marked as failing if it runs for an excessive amount of time. Use the ``Trait/timeLimit(_:)-4kzjp`` trait as an upper bound: @@ -40,8 +40,8 @@ hour (60 x 60 seconds) to execute, the task in which it's running is and the test fails with an issue of kind ``Issue/Kind-swift.enum/timeLimitExceeded(timeLimitComponents:)``. -- Note: If multiple time limit traits apply to a test, the shortest time limit - is used. +- Note: If multiple time limit traits apply to a test, the testing library uses + the shortest time limit. The testing library may adjust the specified time limit for performance reasons or to ensure tests have enough time to run. In particular, a granularity of (by @@ -49,13 +49,20 @@ default) one minute is applied to tests. The testing library can also be configured with a maximum time limit per test that overrides any applied time limit traits. -### Time limits applied to test suites +### Apply time limits to test suites -When a time limit is applied to a test suite, it's recursively applied to all -test functions and child test suites within that suite. +When you apply a time limit to a test suite, the testing library recursively +applies it to all test functions and child test suites within that suite. +The time limit applies to each test in the test suite and any child test suites, +or each test case for parameterized tests. -### Time limits applied to parameterized tests +For example, if a suite contains five tests and you apply a time limit trait +with a duration of one minute, then each test in the suite may run for up to +one minute. -When a time limit is applied to a parameterized test function, it's applied to -each invocation _separately_ so that if only some arguments cause failures, then -successful arguments aren't incorrectly marked as failing too. +### Apply time limits to parameterized tests + +When you apply a time limit to a parameterized test function, the testing +library applies it to each invocation _separately_ so that if only some +cases cause failures due to timeouts, then the testing library doesn't +incorrectly mark successful cases as failing. diff --git a/Sources/Testing/Testing.docc/MigratingFromXCTest.md b/Sources/Testing/Testing.docc/MigratingFromXCTest.md index 60744ba7a..6b1d9f381 100644 --- a/Sources/Testing/Testing.docc/MigratingFromXCTest.md +++ b/Sources/Testing/Testing.docc/MigratingFromXCTest.md @@ -277,7 +277,7 @@ XCTest has a function, [`XCTFail()`](https://developer.apple.com/documentation/x that causes a test to fail immediately and unconditionally. This function is useful when the syntax of the language prevents the use of an `XCTAssert()` function. To record an unconditional issue using the testing library, use the -``Issue/record(_:sourceLocation:)`` function: +``Issue/record(_:severity:sourceLocation:)`` function: @Row { @Column { @@ -556,6 +556,47 @@ test function with an instance of this trait type to control whether it runs: } } + + ### Annotate known issues A test may have a known issue that sometimes or always prevents it from passing. diff --git a/Sources/Testing/Testing.docc/ParameterizedTesting.md b/Sources/Testing/Testing.docc/ParameterizedTesting.md index c4310e8e6..7aeaeead4 100644 --- a/Sources/Testing/Testing.docc/ParameterizedTesting.md +++ b/Sources/Testing/Testing.docc/ParameterizedTesting.md @@ -101,6 +101,37 @@ func makeLargeOrder(count: Int) async throws { - Note: Very large ranges such as `0 ..< .max` may take an excessive amount of time to test, or may never complete due to resource constraints. +### Pass the same arguments to multiple test functions + +If you want to pass the same collection of arguments to two or more +parameterized test functions, you can extract the arguments to a separate +function or property and pass it to each `@Test` attribute. For example: + +```swift +extension Food { + static var bestSelling: [Food] { + get async throws { /* ... */ } + } +} + +@Test(arguments: try await Food.bestSelling) +func `Order entree`(food: Food) { + let foodTruck = FoodTruck() + #expect(foodTruck.order(food)) +} + +@Test(arguments: try await Food.bestSelling) +func `Package leftovers`(food: Food) throws { + let foodTruck = FoodTruck() + let container = try #require(foodTruck.container(fitting: food)) + try container.add(food) +} +``` + +> Tip: You can prefix expressions passed to `arguments:` with `try` or `await`. +> The testing library evaluates them lazily only if it determines that the +> associated test will run. + ### Test with more than one collection It's possible to test more than one collection. Consider the following test @@ -115,8 +146,8 @@ func makeLargeOrder(of food: Food, count: Int) async throws { ``` Elements from the first collection are passed as the first argument to the test -function, elements from the second collection are passed as the second argument, -and so forth. +function, and elements from the second collection are passed as the second +argument. Assuming there are five cases in the `Food` enumeration, this test function will, when run, be invoked 500 times (5 x 100) with every possible combination diff --git a/Sources/Testing/Testing.docc/Traits.md b/Sources/Testing/Testing.docc/Traits.md index e6d19c1b9..d14eda999 100644 --- a/Sources/Testing/Testing.docc/Traits.md +++ b/Sources/Testing/Testing.docc/Traits.md @@ -48,12 +48,10 @@ types that customize the behavior of your tests. - ``Trait/bug(_:id:_:)-10yf5`` - ``Trait/bug(_:id:_:)-3vtpl`` - ### Creating custom traits @@ -67,8 +65,8 @@ types that customize the behavior of your tests. - ``Bug`` - ``Comment`` - ``ConditionTrait`` +- ``IssueHandlingTrait`` - ``ParallelizationTrait`` - ``Tag`` - ``Tag/List`` - ``TimeLimitTrait`` - diff --git a/Sources/Testing/Testing.docc/exit-testing.md b/Sources/Testing/Testing.docc/exit-testing.md index 06ab53dc9..a94bfbd78 100644 --- a/Sources/Testing/Testing.docc/exit-testing.md +++ b/Sources/Testing/Testing.docc/exit-testing.md @@ -12,6 +12,7 @@ See https://swift.org/CONTRIBUTORS.txt for Swift project authors @Metadata { @Available(Swift, introduced: 6.2) + @Available(Xcode, introduced: 26.0) } Use exit tests to test functionality that might cause a test process to exit. @@ -66,21 +67,7 @@ The parent process doesn't call the body of the exit test. Instead, the child process treats the body of the exit test as its `main()` function and calls it directly. -- Note: Because the body acts as the `main()` function of a new process, it - can't capture any state originating in the parent process or from its lexical - context. For example, the following exit test will fail to compile because it - captures a variable declared outside the exit test itself: - - ```swift - @Test func `Customer won't eat food unless it's nutritious`() async { - let isNutritious = false - await #expect(processExitsWith: .failure) { - var food = ... - food.isNutritious = isNutritious // ❌ ERROR: trying to capture state here - Customer.current.eat(food) - } - } - ``` + If the body returns before the child process exits, the process exits as if `main()` returned normally. If the body throws an error, Swift handles it as if @@ -105,6 +92,59 @@ status of the child process against the expected exit condition you passed. If they match, the exit test passes; otherwise, it fails and the testing library records an issue. +### Capture state from the parent process + +To pass information from the parent process to the child process, you specify +the Swift values you want to pass in a [capture list](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/closures/#Capturing-Values) +on the exit test's body: + +```swift +@Test(arguments: Food.allJunkFood) +func `Customer won't eat food unless it's nutritious`(_ food: Food) async { + await #expect(processExitsWith: .failure) { [food] in + Customer.current.eat(food) + } +} +``` + +- Note: If you use this macro with a Swift compiler version lower than 6.3, it + doesn't support capturing state. + +If a captured value is an argument to the current function or is `self`, its +type is inferred at compile time. Otherwise, explicitly specify the type of the +value using the `as` operator: + +```swift +@Test func `Customer won't eat food unless it's nutritious`() async { + var food = ... + food.isNutritious = false + await #expect(processExitsWith: .failure) { [self, food = food as Food] in + self.prepare(food) + Customer.current.eat(food) + } +} +``` + +Every value you capture in an exit test must conform to [`Sendable`](https://developer.apple.com/documentation/swift/sendable) +and [`Codable`](https://developer.apple.com/documentation/swift/codable). Each +value is encoded by the parent process using [`encode(to:)`](https://developer.apple.com/documentation/swift/encodable/encode(to:)) +and is decoded by the child process using [`init(from:)`](https://developer.apple.com/documentation/swift/decodable/init(from:)) +before being passed to the exit test body. + +If a captured value's type does not conform to both `Sendable` and `Codable`, or +if the value is not explicitly specified in the exit test body's capture list, +the compiler emits an error: + +```swift +@Test func `Customer won't eat food unless it's nutritious`() async { + var food = ... + food.isNutritious = false + await #expect(processExitsWith: .failure) { + Customer.current.eat(food) // ❌ ERROR: implicitly capturing 'food' + } +} +``` + ### Gather output from the child process The ``expect(processExitsWith:observing:_:sourceLocation:performing:)`` and @@ -152,4 +192,4 @@ extension Customer { The testing library always sets ``ExitTest/Result/exitStatus`` to the actual exit status of the child process (as reported by the system) even if you do not -pass it. +observe `\.exitStatus`. diff --git a/Sources/Testing/Testing.swiftcrossimport/AppKit.swiftoverlay b/Sources/Testing/Testing.swiftcrossimport/AppKit.swiftoverlay new file mode 100644 index 000000000..cc9998ba3 --- /dev/null +++ b/Sources/Testing/Testing.swiftcrossimport/AppKit.swiftoverlay @@ -0,0 +1,3 @@ +version: 1 +modules: +- name: _Testing_AppKit diff --git a/Sources/Testing/Testing.swiftcrossimport/CoreGraphics.swiftoverlay b/Sources/Testing/Testing.swiftcrossimport/CoreGraphics.swiftoverlay new file mode 100644 index 000000000..656012089 --- /dev/null +++ b/Sources/Testing/Testing.swiftcrossimport/CoreGraphics.swiftoverlay @@ -0,0 +1,3 @@ +version: 1 +modules: +- name: _Testing_CoreGraphics diff --git a/Sources/Testing/Testing.swiftcrossimport/CoreImage.swiftoverlay b/Sources/Testing/Testing.swiftcrossimport/CoreImage.swiftoverlay new file mode 100644 index 000000000..cdea5109b --- /dev/null +++ b/Sources/Testing/Testing.swiftcrossimport/CoreImage.swiftoverlay @@ -0,0 +1,3 @@ +version: 1 +modules: +- name: _Testing_CoreImage diff --git a/Sources/Testing/Testing.swiftcrossimport/UIKit.swiftoverlay b/Sources/Testing/Testing.swiftcrossimport/UIKit.swiftoverlay new file mode 100644 index 000000000..b3c35caed --- /dev/null +++ b/Sources/Testing/Testing.swiftcrossimport/UIKit.swiftoverlay @@ -0,0 +1,3 @@ +version: 1 +modules: +- name: _Testing_UIKit diff --git a/Sources/Testing/Testing.swiftcrossimport/WinSDK.swiftoverlay b/Sources/Testing/Testing.swiftcrossimport/WinSDK.swiftoverlay new file mode 100644 index 000000000..fdaa23701 --- /dev/null +++ b/Sources/Testing/Testing.swiftcrossimport/WinSDK.swiftoverlay @@ -0,0 +1,3 @@ +version: 1 +modules: +- name: _Testing_WinSDK diff --git a/Sources/Testing/Traits/AttachmentSavingTrait.swift b/Sources/Testing/Traits/AttachmentSavingTrait.swift new file mode 100644 index 000000000..eed8085d5 --- /dev/null +++ b/Sources/Testing/Traits/AttachmentSavingTrait.swift @@ -0,0 +1,336 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +/// A type that defines a condition which must be satisfied for the testing +/// library to save attachments recorded by a test. +/// +/// To add this trait to a test, use one of the following functions: +/// +/// - ``Trait/savingAttachments(if:)`` +/// +/// By default, the testing library saves your attachments as soon as you call +/// ``Attachment/record(_:named:sourceLocation:)``. You can access saved +/// attachments after your tests finish running: +/// +/// - When using Xcode, you can access attachments from the test report. +/// - When using Visual Studio Code, the testing library saves attachments to +/// `.build/attachments` by default. Visual Studio Code reports the paths to +/// individual attachments in its Tests Results panel. +/// - When using Swift Package Manager's `swift test` command, you can pass the +/// `--attachments-path` option. The testing library saves attachments to the +/// specified directory. +/// +/// If you add an instance of this trait type to a test, any attachments that +/// test records are stored in memory until the test finishes running. The +/// testing library then evaluates the instance's condition and, if the +/// condition is met, saves the attachments. +@_spi(Experimental) +public struct AttachmentSavingTrait: TestTrait, SuiteTrait { + /// A type that describes the conditions under which the testing library + /// will save attachments. + /// + /// You can pass instances of this type to ``Trait/savingAttachments(if:)``. + public struct Condition: Sendable { + /// The testing library saves attachments if the test passes. + public static var testPasses: Self { + Self { !$0.hasFailed } + } + + /// The testing library saves attachments if the test fails. + public static var testFails: Self { + Self { $0.hasFailed } + } + + /// The testing library saves attachments if the test records a matching + /// issue. + /// + /// - Parameters: + /// - issueMatcher: A function to invoke when an issue occurs that is used + /// to determine if the testing library should save attachments for the + /// current test. + /// + /// - Returns: An instance of ``AttachmentSavingTrait/Condition`` that + /// evaluates `issueMatcher`. + public static func testRecordsIssue( + matching issueMatcher: @escaping @Sendable (_ issue: Issue) async throws -> Bool + ) -> Self { + Self(inspectsIssues: true) { context in + for issue in context.issues { + if try await issueMatcher(issue) { + return true + } + } + return false + } + } + + /// Whether or not this condition needs to inspect individual issues (which + /// implies a slower path.) + fileprivate var inspectsIssues = false + + /// The condition function. + fileprivate var body: @Sendable (borrowing Context) async throws -> Bool + } + + /// This instance's condition. + var condition: Condition + + /// The source location where this trait is specified. + var sourceLocation: SourceLocation + + public var isRecursive: Bool { + true + } +} + +// MARK: - TestScoping + +extension AttachmentSavingTrait: TestScoping { + /// A type representing the per-test-case context for this trait. + /// + /// An instance of this type is created for each scope this trait provides. + /// When the scope ends, the context is then passed to the trait's condition + /// function for evaluation. + fileprivate struct Context: Sendable { + /// The set of events that were deferred for later conditional handling. + var deferredEvents = [Event]() + + /// Whether or not the current test case has recorded a failing issue. + var hasFailed = false + + /// All issues recorded within the scope of the current test case. + var issues = [Issue]() + } + + public func scopeProvider(for test: Test, testCase: Test.Case?) -> Self? { + // This function should apply directly to test cases only. It doesn't make + // sense to apply it to suites or test functions since they don't run their + // own code. + // + // NOTE: this trait can't reliably affect attachments recorded when other + // traits are evaluated (we may need a new scope in the TestScoping protocol + // for that.) + testCase != nil ? self : nil + } + + public func provideScope(for test: Test, testCase: Test.Case?, performing function: @Sendable () async throws -> Void) async throws { + guard var configuration = Configuration.current else { + throw SystemError(description: "There is no current Configuration when attempting to provide scope for test '\(test.name)'. Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new") + } + let oldConfiguration = configuration + + let context = Locked(rawValue: Context()) + configuration.eventHandler = { event, eventContext in + var eventDeferred = false + defer { + if !eventDeferred { + oldConfiguration.eventHandler(event, eventContext) + } + } + + // Guard against events generated in unstructured tasks or outside a test + // function body (where testCase shouldn't be nil). + guard eventContext.test == test && eventContext.testCase != nil else { + return + } + + switch event.kind { + case .valueAttached: + // Defer this event until the current test or test case ends. + eventDeferred = true + context.withLock { context in + context.deferredEvents.append(event) + } + + case let .issueRecorded(issue): + if condition.inspectsIssues { + context.withLock { context in + if issue.isFailure { + context.hasFailed = true + } + context.issues.append(issue) + } + } else if issue.isFailure { + context.withLock { context in + context.hasFailed = true + } + } + + default: + break + } + } + + // TODO: adopt async defer if/when we get it + let result: Result + do { + result = try await .success(Configuration.withCurrent(configuration, perform: function)) + } catch { + result = .failure(error) + } + await _handleDeferredEvents(in: context.rawValue, for: test, testCase: testCase, configuration: oldConfiguration) + return try result.get() + } + + /// Handle any deferred events for a given test and test case. + /// + /// - Parameters: + /// - context: A context structure containing the deferred events to handle. + /// - test: The test for which events were recorded. + /// - testCase The test case for which events were recorded, if any. + /// - configuration: The configuration to pass events to. + private func _handleDeferredEvents(in context: consuming Context, for test: Test, testCase: Test.Case?, configuration: Configuration) async { + if context.deferredEvents.isEmpty { + // Never mind... + return + } + + await Issue.withErrorRecording(at: sourceLocation, configuration: configuration) { + // Evaluate the condition. + guard try await condition.body(context) else { + return + } + + // Finally issue the attachment-recorded events that we deferred. + let eventContext = Event.Context(test: test, testCase: testCase, configuration: configuration) + for event in context.deferredEvents { +#if DEBUG + var event = event + event.wasDeferred = true +#endif + configuration.eventHandler(event, eventContext) + } + } + } +} + +// MARK: - + +@_spi(Experimental) +extension Trait where Self == AttachmentSavingTrait { + /// Constructs a trait that tells the testing library to only save attachments + /// if a given condition is met. + /// + /// - Parameters: + /// - condition: A condition which, when met, means that the testing library + /// should save attachments that the current test has recorded. If the + /// condition is not met, the testing library discards the test's + /// attachments when the test ends. + /// - sourceLocation: The source location of the trait. + /// + /// - Returns: An instance of ``AttachmentSavingTrait`` that evaluates the + /// closure you provide. + /// + /// By default, the testing library saves your attachments as soon as you call + /// ``Attachment/record(_:named:sourceLocation:)``. You can access saved + /// attachments after your tests finish running: + /// + /// - When using Xcode, you can access attachments from the test report. + /// - When using Visual Studio Code, the testing library saves attachments to + /// `.build/attachments` by default. Visual Studio Code reports the paths to + /// individual attachments in its Tests Results panel. + /// - When using Swift Package Manager's `swift test` command, you can pass + /// the `--attachments-path` option. The testing library saves attachments + /// to the specified directory. + /// + /// If you add this trait to a test, any attachments that test records are + /// stored in memory until the test finishes running. The testing library then + /// evaluates `condition` and, if the condition is met, saves the attachments. + public static func savingAttachments( + if condition: Self.Condition, + sourceLocation: SourceLocation = #_sourceLocation + ) -> Self { + Self(condition: condition, sourceLocation: sourceLocation) + } + + /// Constructs a trait that tells the testing library to only save attachments + /// if a given condition is met. + /// + /// - Parameters: + /// - condition: A closure that contains the trait's custom condition logic. + /// If this closure returns `true`, the trait tells the testing library to + /// save attachments that the current test has recorded. If this closure + /// returns `false`, the testing library discards the test's attachments + /// when the test ends. If this closure throws an error, the testing + /// library records that error as an issue and discards the test's + /// attachments. + /// - sourceLocation: The source location of the trait. + /// + /// - Returns: An instance of ``AttachmentSavingTrait`` that evaluates the + /// closure you provide. + /// + /// By default, the testing library saves your attachments as soon as you call + /// ``Attachment/record(_:named:sourceLocation:)``. You can access saved + /// attachments after your tests finish running: + /// + /// - When using Xcode, you can access attachments from the test report. + /// - When using Visual Studio Code, the testing library saves attachments + /// Β to `.build/attachments` by default. Visual Studio Code reports the paths + /// Β to individual attachments in its Tests Results panel. + /// - When using Swift Package Manager's `swift test` command, you can pass + /// the `--attachments-path` option. The testing library saves attachments + /// to the specified directory. + /// + /// If you add this trait to a test, any attachments that test records are + /// stored in memory until the test finishes running. The testing library then + /// evaluates `condition` and, if the condition is met, saves the attachments. + public static func savingAttachments( + if condition: @autoclosure @escaping @Sendable () throws -> Bool, + sourceLocation: SourceLocation = #_sourceLocation + ) -> Self { + let condition = Self.Condition { _ in try condition() } + return savingAttachments(if: condition, sourceLocation: sourceLocation) + } + + /// Constructs a trait that tells the testing library to only save attachments + /// if a given condition is met. + /// + /// - Parameters: + /// - condition: A closure that contains the trait's custom condition logic. + /// If this closure returns `true`, the trait tells the testing library to + /// save attachments that the current test has recorded. If this closure + /// returns `false`, the testing library discards the test's attachments + /// when the test ends. If this closure throws an error, the testing + /// library records that error as an issue and discards the test's + /// attachments. + /// - sourceLocation: The source location of the trait. + /// + /// - Returns: An instance of ``AttachmentSavingTrait`` that evaluates the + /// closure you provide. + /// + /// By default, the testing library saves your attachments as soon as you call + /// ``Attachment/record(_:named:sourceLocation:)``. You can access saved + /// attachments after your tests finish running: + /// + /// - When using Xcode, you can access attachments from the test report. + /// - When using Visual Studio Code, the testing library saves attachments + /// Β to `.build/attachments` by default. Visual Studio Code reports the paths + /// Β to individual attachments in its Tests Results panel. + /// - When using Swift Package Manager's `swift test` command, you can pass + /// the `--attachments-path` option. The testing library saves attachments + /// to the specified directory. + /// + /// If you add this trait to a test, any attachments that test records are + /// stored in memory until the test finishes running. The testing library then + /// evaluates `condition` and, if the condition is met, saves the attachments. + /// + /// @Comment { + /// - Bug: `condition` cannot be `async` without making this function + /// `async` even though `condition` is not evaluated locally. + /// ([103037177](rdar://103037177)) + /// } + public static func savingAttachments( + if condition: @escaping @Sendable () async throws -> Bool, + sourceLocation: SourceLocation = #_sourceLocation + ) -> Self { + let condition = Self.Condition { _ in try await condition() } + return savingAttachments(if: condition, sourceLocation: sourceLocation) + } +} diff --git a/Sources/Testing/Traits/ConditionTrait+Macro.swift b/Sources/Testing/Traits/ConditionTrait+Macro.swift index dbddcfc1f..fd489b9e6 100644 --- a/Sources/Testing/Traits/ConditionTrait+Macro.swift +++ b/Sources/Testing/Traits/ConditionTrait+Macro.swift @@ -124,4 +124,27 @@ extension Trait where Self == ConditionTrait { sourceLocation: sourceLocation ) } + + /// Create a trait controlling availability of a test based on an + /// `@_unavailableInEmbedded` attribute applied to it. + /// + /// - Parameters: + /// - sourceLocation: The source location of the test. + /// + /// - Returns: A trait. + /// + /// - Warning: This function is used to implement the `@Test` macro. Do not + /// call it directly. + public static func __unavailableInEmbedded(sourceLocation: SourceLocation) -> Self { +#if hasFeature(Embedded) + let isEmbedded = true +#else + let isEmbedded = false +#endif + return Self( + kind: .unconditional(!isEmbedded), + comments: ["Marked @_unavailableInEmbedded"], + sourceLocation: sourceLocation + ) + } } diff --git a/Sources/Testing/Traits/ConditionTrait.swift b/Sources/Testing/Traits/ConditionTrait.swift index 079b64d8e..2bd776c91 100644 --- a/Sources/Testing/Traits/ConditionTrait.swift +++ b/Sources/Testing/Traits/ConditionTrait.swift @@ -72,6 +72,7 @@ public struct ConditionTrait: TestTrait, SuiteTrait { /// /// @Metadata { /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) /// } public func evaluate() async throws -> Bool { switch kind { @@ -84,15 +85,13 @@ public struct ConditionTrait: TestTrait, SuiteTrait { public func prepare(for test: Test) async throws { let isEnabled = try await evaluate() - if !isEnabled { // We don't need to consider including a backtrace here because it will // primarily contain frames in the testing library, not user code. If an // error was thrown by a condition evaluated above, the caller _should_ // attempt to get the backtrace of the caught error when creating an issue // for it, however. - let sourceContext = SourceContext(backtrace: nil, sourceLocation: sourceLocation) - throw SkipInfo(comment: comments.first, sourceContext: sourceContext) + try Test.cancel(comments.first, sourceLocation: sourceLocation) } } diff --git a/Sources/Testing/Traits/IssueHandlingTrait.swift b/Sources/Testing/Traits/IssueHandlingTrait.swift index d70d68e93..21a9adaac 100644 --- a/Sources/Testing/Traits/IssueHandlingTrait.swift +++ b/Sources/Testing/Traits/IssueHandlingTrait.swift @@ -15,31 +15,51 @@ /// modifying one or more of its properties, and returning the copy. You can /// observe recorded issues by returning them unmodified. Or you can suppress an /// issue by either filtering it using ``Trait/filterIssues(_:)`` or returning -/// `nil` from the closure passed to ``Trait/transformIssues(_:)``. +/// `nil` from the closure passed to ``Trait/compactMapIssues(_:)``. /// /// When an instance of this trait is applied to a suite, it is recursively /// inherited by all child suites and tests. /// /// To add this trait to a test, use one of the following functions: /// -/// - ``Trait/transformIssues(_:)`` +/// - ``Trait/compactMapIssues(_:)`` /// - ``Trait/filterIssues(_:)`` -@_spi(Experimental) +/// +/// @Metadata { +/// @Available(Swift, introduced: 6.2) +/// @Available(Xcode, introduced: 26.0) +/// } public struct IssueHandlingTrait: TestTrait, SuiteTrait { - /// A function which transforms an issue and returns an optional replacement. + /// A function which handles an issue and returns an optional replacement. /// /// - Parameters: - /// - issue: The issue to transform. + /// - issue: The issue to handle. /// /// - Returns: An issue to replace `issue`, or else `nil` if the issue should /// not be recorded. - fileprivate typealias Transformer = @Sendable (_ issue: Issue) -> Issue? + fileprivate typealias Handler = @Sendable (_ issue: Issue) -> Issue? + + /// This trait's handler function. + private var _handler: Handler - /// This trait's transformer function. - private var _transformer: Transformer + fileprivate init(handler: @escaping Handler) { + _handler = handler + } - fileprivate init(transformer: @escaping Transformer) { - _transformer = transformer + /// Handle a specified issue. + /// + /// - Parameters: + /// - issue: The issue to handle. + /// + /// - Returns: An issue to replace `issue`, or else `nil` if the issue should + /// not be recorded. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) + /// } + public func handleIssue(_ issue: Issue) -> Issue? { + _handler(issue) } public var isRecursive: Bool { @@ -47,6 +67,10 @@ public struct IssueHandlingTrait: TestTrait, SuiteTrait { } } +/// @Metadata { +/// @Available(Swift, introduced: 6.2) +/// @Available(Xcode, introduced: 26.0) +/// } extension IssueHandlingTrait: TestScoping { public func scopeProvider(for test: Test, testCase: Test.Case?) -> Self? { // Provide scope for tests at both the suite and test case levels, but not @@ -85,15 +109,37 @@ extension IssueHandlingTrait: TestScoping { return } + // Ignore system issues, as they are not expected to be caused by users. + if case .system = issue.kind { + oldConfiguration.eventHandler(event, context) + return + } + // Use the original configuration's event handler when invoking the - // transformer to avoid infinite recursion if the transformer itself + // handler closure to avoid infinite recursion if the handler itself // records new issues. This means only issue handling traits whose scope // is outside this one will be allowed to handle such issues. let newIssue = Configuration.withCurrent(oldConfiguration) { - _transformer(issue) + handleIssue(issue) } if let newIssue { + // Validate the value of the returned issue's 'kind' property. + switch (issue.kind, newIssue.kind) { + case (_, .system): + // Prohibited by ST-0011. + preconditionFailure("Issue returned by issue handling closure cannot have kind 'system': \(newIssue)") + case (.apiMisused, .apiMisused): + // This is permitted, but must be listed explicitly before the + // wildcard case below. + break + case (_, .apiMisused): + // Prohibited by ST-0011. + preconditionFailure("Issue returned by issue handling closure cannot have kind 'apiMisused' when the passed-in issue had a different kind: \(newIssue)") + default: + break + } + var event = event event.kind = .issueRecorded(newIssue) oldConfiguration.eventHandler(event, context) @@ -104,34 +150,44 @@ extension IssueHandlingTrait: TestScoping { } } -@_spi(Experimental) extension Trait where Self == IssueHandlingTrait { /// Constructs an trait that transforms issues recorded by a test. /// /// - Parameters: - /// - transformer: The closure called for each issue recorded by the test + /// - transform: A closure called for each issue recorded by the test /// this trait is applied to. It is passed a recorded issue, and returns /// an optional issue to replace the passed-in one. /// - /// The `transformer` closure is called synchronously each time an issue is + /// - Returns: An instance of ``IssueHandlingTrait`` that transforms issues. + /// + /// The `transform` closure is called synchronously each time an issue is /// recorded by the test this trait is applied to. The closure is passed the /// recorded issue, and if it returns a non-`nil` value, that will be recorded /// instead of the original. Otherwise, if the closure returns `nil`, the /// issue is suppressed and will not be included in the results. /// - /// The `transformer` closure may be called more than once if the test records + /// The `transform` closure may be called more than once if the test records /// multiple issues. If more than one instance of this trait is applied to a - /// test (including via inheritance from a containing suite), the `transformer` + /// test (including via inheritance from a containing suite), the `transform` /// closure for each instance will be called in right-to-left, innermost-to- /// outermost order, unless `nil` is returned, which will skip invoking the /// remaining traits' closures. /// - /// Within `transformer`, you may access the current test or test case (if any) + /// Within `transform`, you may access the current test or test case (if any) /// using ``Test/current`` ``Test/Case/current``, respectively. You may also /// record new issues, although they will only be handled by issue handling /// traits which precede this trait or were inherited from a containing suite. - public static func transformIssues(_ transformer: @escaping @Sendable (Issue) -> Issue?) -> Self { - Self(transformer: transformer) + /// + /// - Note: `transform` will never be passed an issue for which the value of + /// ``Issue/kind`` is ``Issue/Kind/system``, and may not return such an + /// issue. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) + /// } + public static func compactMapIssues(_ transform: @escaping @Sendable (Issue) -> Issue?) -> Self { + Self(handler: transform) } /// Constructs a trait that filters issues recorded by a test. @@ -142,6 +198,8 @@ extension Trait where Self == IssueHandlingTrait { /// should return `true` if the issue should be included, or `false` if it /// should be suppressed. /// + /// - Returns: An instance of ``IssueHandlingTrait`` that filters issues. + /// /// The `isIncluded` closure is called synchronously each time an issue is /// recorded by the test this trait is applied to. The closure is passed the /// recorded issue, and if it returns `true`, the issue will be preserved in @@ -159,6 +217,14 @@ extension Trait where Self == IssueHandlingTrait { /// using ``Test/current`` ``Test/Case/current``, respectively. You may also /// record new issues, although they will only be handled by issue handling /// traits which precede this trait or were inherited from a containing suite. + /// + /// - Note: `isIncluded` will never be passed an issue for which the value of + /// ``Issue/kind`` is ``Issue/Kind/system``. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// @Available(Xcode, introduced: 26.0) + /// } public static func filterIssues(_ isIncluded: @escaping @Sendable (Issue) -> Bool) -> Self { Self { issue in isIncluded(issue) ? issue : nil diff --git a/Sources/Testing/Traits/ParallelizationTrait.swift b/Sources/Testing/Traits/ParallelizationTrait.swift index 9eb1bd2a5..c91e01761 100644 --- a/Sources/Testing/Traits/ParallelizationTrait.swift +++ b/Sources/Testing/Traits/ParallelizationTrait.swift @@ -31,6 +31,14 @@ public struct ParallelizationTrait: TestTrait, SuiteTrait {} // MARK: - TestScoping extension ParallelizationTrait: TestScoping { + public func scopeProvider(for test: Test, testCase: Test.Case?) -> Self? { + // When applied to a test function, this trait should provide scope to the + // test function itself, not its individual test cases, since that allows + // Runner to correctly interpret the configuration setting to disable + // parallelization. + test.isSuite || testCase == nil ? self : nil + } + public func provideScope(for test: Test, testCase: Test.Case?, performing function: @Sendable () async throws -> Void) async throws { guard var configuration = Configuration.current else { throw SystemError(description: "There is no current Configuration when attempting to provide scope for test '\(test.name)'. Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new") diff --git a/Sources/Testing/Traits/TimeLimitTrait.swift b/Sources/Testing/Traits/TimeLimitTrait.swift index 4e84a1f92..10f3fc31a 100644 --- a/Sources/Testing/Traits/TimeLimitTrait.swift +++ b/Sources/Testing/Traits/TimeLimitTrait.swift @@ -8,6 +8,8 @@ // See https://swift.org/CONTRIBUTORS.txt for Swift project authors // +private import _TestingInternals + /// A type that defines a time limit to apply to a test. /// /// To add this trait to a test, use ``Trait/timeLimit(_:)-4kzjp``. @@ -81,6 +83,13 @@ extension Trait where Self == TimeLimitTrait { /// test cases individually. If a test has more than one time limit associated /// with it, the shortest one is used. A test run may also be configured with /// a maximum time limit per test case. + /// + /// If you apply this trait to a test suite, then it sets the time limit for + /// each test in the suite, or each test case in parameterized tests in the + /// suite. + /// For example, if a suite contains five tests and you apply a time limit trait + /// with a duration of one minute, then each test in the suite may run for up to + /// one minute. @_spi(Experimental) public static func timeLimit(_ timeLimit: Duration) -> Self { return Self(timeLimit: timeLimit) @@ -114,6 +123,13 @@ extension Trait where Self == TimeLimitTrait { /// If a test is parameterized, this time limit is applied to each of its /// test cases individually. If a test has more than one time limit associated /// with it, the testing library uses the shortest time limit. + /// + /// If you apply this trait to a test suite, then it sets the time limit for + /// each test in the suite, or each test case in parameterized tests in the + /// suite. + /// For example, if a suite contains five tests and you apply a time limit trait + /// with a duration of one minute, then each test in the suite may run for up to + /// one minute. public static func timeLimit(_ timeLimit: Self.Duration) -> Self { return Self(timeLimit: timeLimit.underlyingDuration) } @@ -126,7 +142,7 @@ extension TimeLimitTrait.Duration { /// This function is unavailable and is provided for diagnostic purposes only. @available(*, unavailable, message: "Time limit must be specified in minutes") public static func seconds(_ seconds: some BinaryInteger) -> Self { - fatalError("Unsupported") + swt_unreachable() } /// Construct a time limit duration given a number of seconds. @@ -134,7 +150,7 @@ extension TimeLimitTrait.Duration { /// This function is unavailable and is provided for diagnostic purposes only. @available(*, unavailable, message: "Time limit must be specified in minutes") public static func seconds(_ seconds: Double) -> Self { - fatalError("Unsupported") + swt_unreachable() } /// Construct a time limit duration given a number of milliseconds. @@ -142,7 +158,7 @@ extension TimeLimitTrait.Duration { /// This function is unavailable and is provided for diagnostic purposes only. @available(*, unavailable, message: "Time limit must be specified in minutes") public static func milliseconds(_ milliseconds: some BinaryInteger) -> Self { - fatalError("Unsupported") + swt_unreachable() } /// Construct a time limit duration given a number of milliseconds. @@ -150,7 +166,7 @@ extension TimeLimitTrait.Duration { /// This function is unavailable and is provided for diagnostic purposes only. @available(*, unavailable, message: "Time limit must be specified in minutes") public static func milliseconds(_ milliseconds: Double) -> Self { - fatalError("Unsupported") + swt_unreachable() } /// Construct a time limit duration given a number of microseconds. @@ -158,7 +174,7 @@ extension TimeLimitTrait.Duration { /// This function is unavailable and is provided for diagnostic purposes only. @available(*, unavailable, message: "Time limit must be specified in minutes") public static func microseconds(_ microseconds: some BinaryInteger) -> Self { - fatalError("Unsupported") + swt_unreachable() } /// Construct a time limit duration given a number of microseconds. @@ -166,7 +182,7 @@ extension TimeLimitTrait.Duration { /// This function is unavailable and is provided for diagnostic purposes only. @available(*, unavailable, message: "Time limit must be specified in minutes") public static func microseconds(_ microseconds: Double) -> Self { - fatalError("Unsupported") + swt_unreachable() } /// Construct a time limit duration given a number of nanoseconds. @@ -174,7 +190,7 @@ extension TimeLimitTrait.Duration { /// This function is unavailable and is provided for diagnostic purposes only. @available(*, unavailable, message: "Time limit must be specified in minutes") public static func nanoseconds(_ nanoseconds: some BinaryInteger) -> Self { - fatalError("Unsupported") + swt_unreachable() } } @@ -248,6 +264,7 @@ struct TimeoutError: Error, CustomStringConvertible { /// /// - Parameters: /// - timeLimit: The amount of time until the closure times out. +/// - taskName: The name of the child task that runs `body`, if any. /// - body: The function to invoke. /// - timeoutHandler: A function to invoke if `body` times out. /// @@ -261,18 +278,19 @@ struct TimeoutError: Error, CustomStringConvertible { @available(_clockAPI, *) func withTimeLimit( _ timeLimit: Duration, + taskName: String? = nil, _ body: @escaping @Sendable () async throws -> Void, timeoutHandler: @escaping @Sendable () -> Void ) async throws { - try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { + try await withThrowingTaskGroup { group in + group.addTask(name: decorateTaskName(taskName, withAction: "waiting for timeout")) { // If sleep() returns instead of throwing a CancellationError, that means // the timeout was reached before this task could be cancelled, so call // the timeout handler. try await Test.Clock.sleep(for: timeLimit) timeoutHandler() } - group.addTask(operation: body) + group.addTask(name: decorateTaskName(taskName, withAction: "running"), operation: body) defer { group.cancelAll() diff --git a/Sources/TestingMacros/CMakeLists.txt b/Sources/TestingMacros/CMakeLists.txt index c9a579eaf..458bd4170 100644 --- a/Sources/TestingMacros/CMakeLists.txt +++ b/Sources/TestingMacros/CMakeLists.txt @@ -3,8 +3,8 @@ # Copyright (c) 2024 Apple Inc. and the Swift project authors # Licensed under Apache License v2.0 with Runtime Library Exception # -# See http://swift.org/LICENSE.txt for license information -# See http://swift.org/CONTRIBUTORS.txt for Swift project authors +# See https://swift.org/LICENSE.txt for license information +# See https://swift.org/CONTRIBUTORS.txt for Swift project authors cmake_minimum_required(VERSION 3.19.6...3.29) @@ -12,6 +12,8 @@ if(POLICY CMP0157) cmake_policy(SET CMP0157 NEW) endif() +set(SWT_SOURCE_ROOT_DIR ${CMAKE_SOURCE_DIR}/../..) + project(TestingMacros LANGUAGES Swift) @@ -31,7 +33,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 340f8400262d494c7c659cd838223990195d7fed) # 602.0.0-prerelease-2025-04-10 + GIT_TAG 07bf225e198119c23b2b9a0a3432bdb534498873) # 603.0.0-prerelease-2025-08-11 FetchContent_MakeAvailable(SwiftSyntax) endif() @@ -81,6 +83,7 @@ endif() target_sources(TestingMacros PRIVATE ConditionMacro.swift + ExitTestCapturedValueMacro.swift PragmaMacro.swift SourceLocationMacro.swift SuiteDeclarationMacro.swift diff --git a/Sources/TestingMacros/ConditionMacro.swift b/Sources/TestingMacros/ConditionMacro.swift index 49630cfc9..6ba8ff124 100644 --- a/Sources/TestingMacros/ConditionMacro.swift +++ b/Sources/TestingMacros/ConditionMacro.swift @@ -437,21 +437,23 @@ extension ExitTestConditionMacro { var bodyArgumentExpr = arguments[trailingClosureIndex].expression bodyArgumentExpr = removeParentheses(from: bodyArgumentExpr) ?? bodyArgumentExpr - // 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) + // Before building the macro expansion, look for any problems and return + // early if found. + guard _diagnoseIssues(with: macro, body: bodyArgumentExpr, in: context) else { + if Self.isThrowing { + return #"{ () async throws -> Testing.ExitTest.Result in \#(ExprSyntax.unreachable) }()"# + } else { + return #"{ () async -> Testing.ExitTest.Result in \#(ExprSyntax.unreachable) }()"# } + } - } else if let closureExpr = bodyArgumentExpr.as(ClosureExprSyntax.self), - let captureClause = closureExpr.signature?.capture, - !captureClause.items.isEmpty { - context.diagnose(.captureClauseUnsupported(captureClause, in: closureExpr, inExitTest: macro)) + // Find any captured values and extract them from the trailing closure. + var capturedValues = [CapturedValueInfo]() + 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) } // Generate a unique identifier for this exit test. @@ -499,7 +501,7 @@ extension ExitTestConditionMacro { recordDecl = """ enum \(legacyEnumName): Testing.__TestContentRecordContainer { nonisolated static var __testContentRecord: Testing.__TestContentRecord { - \(enumName).testContentRecord + unsafe \(enumName).testContentRecord } } """ @@ -550,7 +552,7 @@ extension ExitTestConditionMacro { label: "encodingCapturedValues", expression: TupleExprSyntax { for capturedValue in capturedValues { - LabeledExprSyntax(expression: capturedValue.expression.trimmed) + LabeledExprSyntax(expression: capturedValue.typeCheckedExpression) } } ) @@ -610,22 +612,46 @@ extension ExitTestConditionMacro { return ExprSyntax(tupleExpr) } } -} -extension ExitTestExpectMacro { - /// Whether or not experimental value capturing via explicit capture lists is - /// enabled. + /// Diagnose issues with an exit test macro call. /// - /// This member is declared on ``ExitTestExpectMacro`` but also applies to - /// ``ExitTestRequireMacro``. - @TaskLocal - static var isValueCapturingEnabled: Bool = { -#if ExperimentalExitTestValueCapture - return true -#else - return false -#endif - }() + /// - Parameters: + /// - macro: The exit test macro call. + /// - bodyArgumentExpr: The exit test's body. + /// - context: The macro context in which the expression is being parsed. + /// + /// - Returns: Whether or not macro expansion should continue (i.e. stopping + /// if a fatal error was diagnosed.) + private static func _diagnoseIssues( + with macro: some FreestandingMacroExpansionSyntax, + body bodyArgumentExpr: ExprSyntax, + in context: some MacroExpansionContext + ) -> Bool { + var diagnostics = [DiagnosticMessage]() + + // Disallow exit tests in generic types and functions as they cannot be + // correctly expanded due to the use of a nested type with static members. + for lexicalContext in context.lexicalContext { + if let lexicalContext = lexicalContext.asProtocol((any WithGenericParametersSyntax).self) { + if let genericClause = lexicalContext.genericParameterClause { + diagnostics.append(.expressionMacroUnsupported(macro, inGenericContextBecauseOf: genericClause, on: lexicalContext)) + } else if let whereClause = lexicalContext.genericWhereClause { + diagnostics.append(.expressionMacroUnsupported(macro, inGenericContextBecauseOf: whereClause, on: lexicalContext)) + } else if let functionDecl = lexicalContext.as(FunctionDeclSyntax.self) { + for parameter in functionDecl.signature.parameterClause.parameters { + if parameter.type.isSome { + diagnostics.append(.expressionMacroUnsupported(macro, inGenericContextBecauseOf: parameter, on: functionDecl)) + } + } + } + } + } + + for diagnostic in diagnostics { + context.diagnose(diagnostic) + } + return diagnostics.isEmpty + } } /// A type describing the expansion of the `#expect(processExitsWith:)` macro. diff --git a/Sources/TestingMacros/ExitTestCapturedValueMacro.swift b/Sources/TestingMacros/ExitTestCapturedValueMacro.swift new file mode 100644 index 000000000..bd346bb3b --- /dev/null +++ b/Sources/TestingMacros/ExitTestCapturedValueMacro.swift @@ -0,0 +1,78 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +import SwiftParser +public import SwiftSyntax +import SwiftSyntaxBuilder +public import SwiftSyntaxMacros + +/// The implementation of the `#__capturedValue()` macro when the value conforms +/// to the necessary protocols. +/// +/// This type is used to implement the `#__capturedValue()` macro. Do not use it +/// directly. +public struct ExitTestCapturedValueMacro: ExpressionMacro, Sendable { + public static func expansion( + of macro: some FreestandingMacroExpansionSyntax, + in context: some MacroExpansionContext + ) throws -> ExprSyntax { + let arguments = Array(macro.arguments) + let expr = arguments[0].expression + + // No additional processing is required as this expression's type meets our + // requirements. + + return expr + } +} + +/// The implementation of the `#__capturedValue()` macro when the value does +/// _not_ conform to the necessary protocols. +/// +/// This type is used to implement the `#__capturedValue()` macro. Do not use it +/// directly. +public struct ExitTestBadCapturedValueMacro: ExpressionMacro, Sendable { + public static func expansion( + of macro: some FreestandingMacroExpansionSyntax, + in context: some MacroExpansionContext + ) throws -> ExprSyntax { + let arguments = Array(macro.arguments) + let expr = arguments[0].expression + let nameExpr = arguments[1].expression.cast(StringLiteralExprSyntax.self) + + // Diagnose that the type of 'expr' is invalid. + context.diagnose(.capturedValueMustBeSendableAndCodable(expr, name: nameExpr)) + + return .unreachable + } +} + +/// The implementation of the `#__capturedValue()` macro when the type we +/// inferred for the value was incorrect. +/// +/// This type is used to implement the `#__capturedValue()` macro. Do not use it +/// directly. +public struct ExitTestIncorrectlyCapturedValueMacro: ExpressionMacro, Sendable { + public static func expansion( + of macro: some FreestandingMacroExpansionSyntax, + in context: some MacroExpansionContext + ) throws -> ExprSyntax { + let arguments = Array(macro.arguments) + let expr = arguments[0].expression + let nameExpr = arguments[1].expression.cast(StringLiteralExprSyntax.self) + + // Diagnose that the type of 'expr' is invalid. + let name = nameExpr.representedLiteralValue ?? expr.trimmedDescription + let capture = ClosureCaptureSyntax(name: .identifier(name)) + context.diagnose(.typeOfCaptureIsAmbiguous(capture)) + + return expr + } +} diff --git a/Sources/TestingMacros/SuiteDeclarationMacro.swift b/Sources/TestingMacros/SuiteDeclarationMacro.swift index 60a276689..4bee4c30f 100644 --- a/Sources/TestingMacros/SuiteDeclarationMacro.swift +++ b/Sources/TestingMacros/SuiteDeclarationMacro.swift @@ -174,7 +174,7 @@ public struct SuiteDeclarationMacro: MemberMacro, PeerMacro, Sendable { @available(*, deprecated, message: "This type is an implementation detail of the testing library. Do not use it directly.") enum \(enumName): Testing.__TestContentRecordContainer { nonisolated static var __testContentRecord: Testing.__TestContentRecord { - \(testContentRecordName) + unsafe \(testContentRecordName) } } """ diff --git a/Sources/TestingMacros/Support/Additions/EditorPlaceholderExprSyntaxAdditions.swift b/Sources/TestingMacros/Support/Additions/EditorPlaceholderExprSyntaxAdditions.swift index 9a0d31ab3..360b5260e 100644 --- a/Sources/TestingMacros/Support/Additions/EditorPlaceholderExprSyntaxAdditions.swift +++ b/Sources/TestingMacros/Support/Additions/EditorPlaceholderExprSyntaxAdditions.swift @@ -9,6 +9,7 @@ // import SwiftSyntax +import SwiftSyntaxBuilder extension EditorPlaceholderExprSyntax { /// Initialize an instance of this type with the given placeholder string and @@ -39,7 +40,7 @@ extension EditorPlaceholderExprSyntax { // Manually concatenate the string to avoid it being interpreted as a // placeholder when editing this file. - self.init(placeholder: .identifier("<#\(placeholderContent)#" + ">")) + self.init(placeholder: .identifier(_editorPlaceholder(containing: placeholderContent))) } /// Initialize an instance of this type with the given type, using that as the @@ -62,6 +63,32 @@ extension TypeSyntax { /// /// - Returns: A new `TypeSyntax` instance representing a placeholder. static func placeholder(_ placeholder: String) -> Self { - return Self(IdentifierTypeSyntax(name: .identifier("<#\(placeholder)#" + ">"))) + Self(IdentifierTypeSyntax(name: .identifier(_editorPlaceholder(containing: placeholder)))) } } + +extension StringLiteralExprSyntax { + /// Construct a string literal expression syntax node containing an editor + /// placeholder string. + /// + /// - Parameters + /// - placeholder: The placeholder string, not including surrounding angle + /// brackets or pound characters. + init(placeholder: String) { + self.init(content: _editorPlaceholder(containing: placeholder)) + } +} + +/// Format a source editor placeholder string with the specified content. +/// +/// - Parameters: +/// - content: The placeholder string, not including surrounding angle +/// brackets or pound characters +/// +/// - Returns: A fully-formatted formatted editor placeholder string, including +/// necessary surrounding punctuation. +private func _editorPlaceholder(containing content: String) -> String { + // Manually concatenate the string to avoid it being interpreted as a + // placeholder when editing this file. + "<#\(content)#" + ">" +} diff --git a/Sources/TestingMacros/Support/Additions/FunctionDeclSyntaxAdditions.swift b/Sources/TestingMacros/Support/Additions/FunctionDeclSyntaxAdditions.swift index 9b9378283..8065d299e 100644 --- a/Sources/TestingMacros/Support/Additions/FunctionDeclSyntaxAdditions.swift +++ b/Sources/TestingMacros/Support/Additions/FunctionDeclSyntaxAdditions.swift @@ -34,6 +34,16 @@ extension FunctionDeclSyntax { .contains(.keyword(.nonisolated)) } + /// Whether or not this function declares an operator. + var isOperator: Bool { + switch name.tokenKind { + case .binaryOperator, .prefixOperator, .postfixOperator: + true + default: + false + } + } + /// The name of this function including parentheses, parameter labels, and /// colons. var completeName: DeclReferenceExprSyntax { @@ -77,14 +87,8 @@ extension FunctionDeclSyntax { var xcTestCompatibleSelector: ObjCSelectorPieceListSyntax? { // First, look for an @objc attribute with an explicit selector, and use // that if found. - let objcAttribute = attributes.lazy - .compactMap { - if case let .attribute(attribute) = $0 { - return attribute - } - return nil - }.first { $0.attributeNameText == "objc" } - if let objcAttribute, case let .objCName(objCName) = objcAttribute.arguments { + if let objcAttribute = attributes(named: "objc", inModuleNamed: "Swift").first, + case let .objCName(objCName) = objcAttribute.arguments { if true == objCName.first?.name?.textWithoutBackticks.hasPrefix("test") { return objCName } @@ -178,3 +182,15 @@ extension FunctionParameterSyntax { return baseType.trimmedDescription } } + +// MARK: - + +extension ExprSyntax { + /// An expression representing an unreachable code path. + /// + /// Use this expression when a macro will emit an error diagnostic but the + /// compiler still requires us to produce a valid expression. + static var unreachable: Self { + #"Swift.fatalError("Unreachable")"# + } +} diff --git a/Sources/TestingMacros/Support/Additions/TokenSyntaxAdditions.swift b/Sources/TestingMacros/Support/Additions/TokenSyntaxAdditions.swift index 447a18dee..26b9d1923 100644 --- a/Sources/TestingMacros/Support/Additions/TokenSyntaxAdditions.swift +++ b/Sources/TestingMacros/Support/Additions/TokenSyntaxAdditions.swift @@ -39,12 +39,6 @@ extension TokenSyntax { return textWithoutBackticks } - // TODO: remove this mock path once the toolchain fully supports raw IDs. - let mockPrefix = "__raw__$" - if backticksRemoved, textWithoutBackticks.starts(with: mockPrefix) { - return String(textWithoutBackticks.dropFirst(mockPrefix.count)) - } - return nil } } diff --git a/Sources/TestingMacros/Support/Additions/TypeSyntaxProtocolAdditions.swift b/Sources/TestingMacros/Support/Additions/TypeSyntaxProtocolAdditions.swift index e1bd346ed..e9bf03a02 100644 --- a/Sources/TestingMacros/Support/Additions/TypeSyntaxProtocolAdditions.swift +++ b/Sources/TestingMacros/Support/Additions/TypeSyntaxProtocolAdditions.swift @@ -36,6 +36,13 @@ extension TypeSyntaxProtocol { .contains(.keyword(.some)) } + /// Whether or not this type is `any T` or a type derived from such a type. + var isAny: Bool { + tokens(viewMode: .fixedUp).lazy + .map(\.tokenKind) + .contains(.keyword(.any)) + } + /// Check whether or not this type is named with the specified name and /// module. /// diff --git a/Sources/TestingMacros/Support/Additions/WithAttributesSyntaxAdditions.swift b/Sources/TestingMacros/Support/Additions/WithAttributesSyntaxAdditions.swift index 52d85bbd4..3c42b6fb3 100644 --- a/Sources/TestingMacros/Support/Additions/WithAttributesSyntaxAdditions.swift +++ b/Sources/TestingMacros/Support/Additions/WithAttributesSyntaxAdditions.swift @@ -61,16 +61,17 @@ extension WithAttributesSyntax { }.first var lastPlatformName: TokenSyntax? = nil - var asteriskEncountered = false + var wildcardEncountered = false + let hasWildcard = entries.contains(where: \.isWildcard) return entries.compactMap { entry in switch entry { case let .availabilityVersionRestriction(restriction) where whenKeyword == .introduced: - return Availability(attribute: attribute, platformName: restriction.platform, version: restriction.version, message: message) + return Availability(attribute: attribute, platformName: restriction.platform, version: restriction.version, mayNeedTrailingWildcard: hasWildcard, message: message) case let .token(token): if case .identifier = token.tokenKind { lastPlatformName = token - } else if case let .binaryOperator(op) = token.tokenKind, op == "*" { - asteriskEncountered = true + } else if entry.isWildcard { + wildcardEncountered = true // It is syntactically valid to specify a platform name without a // version in an availability declaration, and it's used to resolve // a custom availability definition specified via the @@ -81,7 +82,7 @@ extension WithAttributesSyntax { return Availability(attribute: attribute, platformName: lastPlatformName, version: nil, message: message) } } else if case let .keyword(keyword) = token.tokenKind, keyword == whenKeyword { - if asteriskEncountered { + if wildcardEncountered { // Match the "always this availability" construct, i.e. // `@available(*, deprecated)` and `@available(*, unavailable)`. return Availability(attribute: attribute, platformName: lastPlatformName, version: nil, message: message) @@ -105,13 +106,13 @@ extension WithAttributesSyntax { /// The first `@available(*, noasync)` or `@_unavailableFromAsync` attribute /// on this instance, if any. var noasyncAttribute: AttributeSyntax? { - availability(when: .noasync).first?.attribute ?? attributes.lazy - .compactMap { attribute in - if case let .attribute(attribute) = attribute { - return attribute - } - return nil - }.first { $0.attributeNameText == "_unavailableFromAsync" } + availability(when: .noasync).first?.attribute + ?? attributes(named: "_unavailableFromAsync", inModuleNamed: "Swift").first + } + + /// The first `@_unavailableInEmbedded` attribute on this instance, if any. + var unavailableInEmbeddedAttribute: AttributeSyntax? { + attributes(named: "_unavailableInEmbedded", inModuleNamed: "Swift").first } /// Find all attributes on this node, if any, with the given name. @@ -144,3 +145,13 @@ extension AttributeSyntax { .joined() } } + +extension AvailabilityArgumentSyntax.Argument { + var isWildcard: Bool { + if case let .token(token) = self, + case let .binaryOperator(op) = token.tokenKind, op == "*" { + return true + } + return false + } +} diff --git a/Sources/TestingMacros/Support/AttributeDiscovery.swift b/Sources/TestingMacros/Support/AttributeDiscovery.swift index a61989aef..fdf4b7e93 100644 --- a/Sources/TestingMacros/Support/AttributeDiscovery.swift +++ b/Sources/TestingMacros/Support/AttributeDiscovery.swift @@ -11,6 +11,7 @@ import SwiftSyntax import SwiftSyntaxBuilder import SwiftSyntaxMacros +import SwiftParser /// A syntax rewriter that removes leading `Self.` tokens from member access /// expressions in a syntax tree. @@ -144,8 +145,19 @@ struct AttributeInfo { let rawIdentifier = namedDecl.name.rawIdentifier { if let displayName, let displayNameArgument { context.diagnose(.declaration(namedDecl, hasExtraneousDisplayName: displayName, fromArgument: displayNameArgument, using: attribute)) + } else { + displayName = StringLiteralExprSyntax(content: rawIdentifier) } - displayName = StringLiteralExprSyntax(content: rawIdentifier) + } + + // If there was a display name but it's completely empty, emit a diagnostic + // since this can cause confusion isn't generally recommended. Note that + // this is only possible for string literal display names; the compiler + // enforces that raw identifiers must be non-empty. + if let namedDecl = declaration.asProtocol((any NamedDeclSyntax).self), + let displayName, let displayNameArgument, + displayName.representedLiteralValue?.isEmpty == true { + context.diagnose(.declaration(namedDecl, hasEmptyDisplayName: displayName, fromArgument: displayNameArgument, using: attribute)) } // Remove leading "Self." expressions from the arguments of the attribute. diff --git a/Sources/TestingMacros/Support/AvailabilityGuards.swift b/Sources/TestingMacros/Support/AvailabilityGuards.swift index e9f4ba762..93451170c 100644 --- a/Sources/TestingMacros/Support/AvailabilityGuards.swift +++ b/Sources/TestingMacros/Support/AvailabilityGuards.swift @@ -24,6 +24,10 @@ struct Availability { /// The platform version, such as 1.2.3, if any. var version: VersionTupleSyntax? + /// Whether or not this availability attribute may need a trailing wildcard + /// (`*`) when it is expanded into `@available()` or `#available()`. + var mayNeedTrailingWildcard = true + /// The `message` argument to the attribute, if any. var message: SimpleStringLiteralExprSyntax? @@ -70,13 +74,14 @@ private func _createAvailabilityTraitExpr( "(\(literal: components.major), \(literal: components.minor), \(literal: components.patch))" } ?? "nil" let message = availability.message.map(\.trimmed).map(ExprSyntax.init) ?? "nil" + let trailingWildcard = availability.mayNeedTrailingWildcard ? ", *" : "" let sourceLocationExpr = createSourceLocationExpr(of: availability.attribute, context: context) switch (whenKeyword, availability.isSwift) { case (.introduced, false): return """ .__available(\(literal: availability.platformName!.textWithoutBackticks), introduced: \(version), message: \(message), sourceLocation: \(sourceLocationExpr)) { - if #available(\(availability.platformVersion!), *) { + if #available(\(availability.platformVersion!)\(raw: trailingWildcard)) { return true } return false @@ -169,6 +174,11 @@ func createAvailabilityTraitExprs( _createAvailabilityTraitExpr(from: availability, when: .obsoleted, in: context) } + if let attribute = decl.unavailableInEmbeddedAttribute { + let sourceLocationExpr = createSourceLocationExpr(of: attribute, context: context) + result += [".__unavailableInEmbedded(sourceLocation: \(sourceLocationExpr))"] + } + return result } @@ -202,8 +212,8 @@ func createSyntaxNode( do { let availableExprs: [ExprSyntax] = decl.availability(when: .introduced).lazy .filter { !$0.isSwift } - .compactMap(\.platformVersion) - .map { "#available(\($0), *)" } + .compactMap { ($0.platformVersion, $0.mayNeedTrailingWildcard ? ", *" : "") } + .map { "#available(\($0.0)\(raw: $0.1))" } if !availableExprs.isEmpty { let conditionList = ConditionElementListSyntax { for availableExpr in availableExprs { @@ -290,5 +300,16 @@ func createSyntaxNode( } } + // Handle Embedded Swift. + if decl.unavailableInEmbeddedAttribute != nil { + result = """ + #if !hasFeature(Embedded) + \(result) + #else + \(exitStatement) + #endif + """ + } + return result } diff --git a/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift b/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift index 41abe711c..d7c89d0ce 100644 --- a/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift +++ b/Sources/TestingMacros/Support/ClosureCaptureListParsing.swift @@ -34,10 +34,15 @@ struct CapturedValueInfo { /// The type of the captured value. var type: TypeSyntax + /// The expression to assign to the captured value with type-checking applied. + var typeCheckedExpression: ExprSyntax { + #"#__capturedValue(\#(expression.trimmed), \#(literal: name.trimmedDescription), (\#(type.trimmed)).self)"# + } + init(_ capture: ClosureCaptureSyntax, in context: some MacroExpansionContext) { self.capture = capture - self.expression = "()" - self.type = "Swift.Void" + self.expression = .unreachable + self.type = "Swift.Never" // We don't support capture specifiers at this time. if let specifier = capture.specifier { @@ -45,44 +50,213 @@ struct CapturedValueInfo { 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 (expr, type) = Self._inferExpressionAndType(of: capture, in: context) { + self.expression = expr + self.type = type + } else { + // Not enough contextual information to derive the type here. + context.diagnose(.typeOfCaptureIsAmbiguous(capture)) + } + } + /// Infer the captured expression and the type of a closure capture list item. + /// + /// - Parameters: + /// - capture: The closure capture list item to inspect. + /// - context: The macro context in which the expression is being parsed. + /// + /// - Returns: A tuple containing the expression and type of `capture`, or + /// `nil` if they could not be inferred. + private static func _inferExpressionAndType(of capture: ClosureCaptureSyntax, in context: some MacroExpansionContext) -> (ExprSyntax, TypeSyntax)? { if let initializer = capture.initializer { // Found an initializer clause. Extract the expression it captures. - self.expression = removeParentheses(from: initializer.value) ?? initializer.value + let finder = _ExprTypeFinder(in: context) + finder.walk(initializer.value) + if let inferredType = finder.inferredType { + return (initializer.value, inferredType) + } + } else if capture.name.tokenKind == .keyword(.self), + let typeNameOfLexicalContext = Self._inferSelf(from: context) { + // Capturing self. + return (ExprSyntax(DeclReferenceExprSyntax(baseName: .keyword(.self))), typeNameOfLexicalContext) + } else if let parameterType = Self._findTypeOfParameter(named: capture.name, in: context.lexicalContext) { + return (ExprSyntax(DeclReferenceExprSyntax(baseName: capture.name.trimmed)), parameterType) + } + + return nil + } + + private final class _ExprTypeFinder: SyntaxAnyVisitor where C: MacroExpansionContext { + var context: C - // 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 { + /// The type that was inferred from the visited syntax tree, if any. + /// + /// This type has not been fixed up yet. Use ``inferredType`` for the final + /// derived type. + private var _inferredType: TypeSyntax? + + /// Whether or not the inferred type has been made optional by e.g. `try?`. + private var _needsOptionalApplied = false + + /// The type that was inferred from the visited syntax tree, if any. + var inferredType: TypeSyntax? { + _inferredType.flatMap { inferredType in + if inferredType.isSome || inferredType.isAny { + // `some` and `any` types are not concrete and cannot be inferred. + nil + } else if _needsOptionalApplied { + TypeSyntax(OptionalTypeSyntax(wrappedType: inferredType.trimmed)) + } else { + inferredType + } + } + } + + init(in context: C) { + self.context = context + super.init(viewMode: .sourceAccurate) + } + + override func visitAny(_ node: Syntax) -> SyntaxVisitorContinueKind { + if inferredType != nil { + // Another part of the syntax tree has already provided a type. Stop. + return .skipChildren + } + + switch node.kind { + case .asExpr: + let asExpr = node.cast(AsExprSyntax.self) + if let type = asExpr.type.as(IdentifierTypeSyntax.self), type.name.tokenKind == .keyword(.Self) { + // `Self` should resolve to the lexical context's type. + _inferredType = CapturedValueInfo._inferSelf(from: context) + } else if asExpr.questionOrExclamationMark?.tokenKind == .postfixQuestionMark { // If the caller is using as?, make the type optional. - TypeSyntax(OptionalTypeSyntax(wrappedType: asExpr.type.trimmed)) + _inferredType = TypeSyntax(OptionalTypeSyntax(wrappedType: asExpr.type.trimmed)) } else { - asExpr.type + _inferredType = 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)) + return .skipChildren + + case .awaitExpr, .unsafeExpr: + // These effect keywords do not affect the type of the expression. + return .visitChildren + + case .tryExpr: + let tryExpr = node.cast(TryExprSyntax.self) + if tryExpr.questionOrExclamationMark?.tokenKind == .postfixQuestionMark { + // The resulting type from the inner expression will be optionalized. + _needsOptionalApplied = true + } + return .visitChildren + + case .tupleExpr: + // If the tuple contains exactly one element, it's just parentheses + // around that expression. + let tupleExpr = node.cast(TupleExprSyntax.self) + if tupleExpr.elements.count == 1 { + return .visitChildren + } + + // Otherwise, we need to try to compose the type as a tuple type from + // the types of all elements in the tuple expression. Note that tuples + // do not conform to Sendable or Codable, so our current use of this + // code in exit tests will still diagnose an error, but the error ("must + // conform") will be more useful than "couldn't infer". + let elements = tupleExpr.elements.compactMap { element in + let finder = Self(in: context) + finder.walk(element.expression) + return finder.inferredType.map { type in + TupleTypeElementSyntax(firstName: element.label?.trimmed, type: type.trimmed) + } + } + if elements.count == tupleExpr.elements.count { + _inferredType = TypeSyntax( + TupleTypeSyntax(elements: TupleTypeElementListSyntax { elements }) + ) + } + return .skipChildren + + case .declReferenceExpr: + // If the reference is to `self` without any arguments, its type can be + // inferred from the lexical context. + let expr = node.cast(DeclReferenceExprSyntax.self) + if expr.baseName.tokenKind == .keyword(.self), expr.argumentNames == nil { + _inferredType = CapturedValueInfo._inferSelf(from: context) + } + return .skipChildren + + case .integerLiteralExpr: + _inferredType = TypeSyntax(IdentifierTypeSyntax(name: .identifier("IntegerLiteralType"))) + return .skipChildren + + case .floatLiteralExpr: + _inferredType = TypeSyntax(IdentifierTypeSyntax(name: .identifier("FloatLiteralType"))) + return .skipChildren + + case .booleanLiteralExpr: + _inferredType = TypeSyntax(IdentifierTypeSyntax(name: .identifier("BooleanLiteralType"))) + return .skipChildren + + case .stringLiteralExpr, .simpleStringLiteralExpr: + _inferredType = TypeSyntax(IdentifierTypeSyntax(name: .identifier("StringLiteralType"))) + return .skipChildren + + default: + // We don't know how to infer a type from this syntax node, so do not + // proceed further. + return .skipChildren } + } + } - } else if capture.name.tokenKind == .keyword(.self), - let typeNameOfLexicalContext { - // Capturing self. - self.expression = "self" - self.type = typeNameOfLexicalContext + /// Get the type of `self` inferred from the given context. + /// + /// - Parameters: + /// - context: The macro context in which the expression is being parsed. + /// + /// - Returns: The type in `lexicalContext` corresponding to `Self`, or `nil` + /// if it could not be determined. + private static func _inferSelf(from context: some MacroExpansionContext) -> TypeSyntax? { + let lexicalContext = context.lexicalContext.drop { !$0.isProtocol((any DeclGroupSyntax).self) } + return context.type(ofLexicalContext: lexicalContext) + } - } else { - // Not enough contextual information to derive the type here. - context.diagnose(.typeOfCaptureIsAmbiguous(capture)) + /// Find a function or closure parameter in the given lexical context with a + /// given name and return its type. + /// + /// - Parameters: + /// - parameterName: The name of the parameter of interest. + /// - lexicalContext: The lexical context to examine. + /// + /// - Returns: The Swift type of first parameter found whose name matches, or + /// `nil` if none was found. The lexical context is searched in the order + /// provided which, by default, starts with the innermost scope. + private static func _findTypeOfParameter(named parameterName: TokenSyntax, in lexicalContext: [Syntax]) -> TypeSyntax? { + for lexicalContext in lexicalContext { + var parameterType: TypeSyntax? + if let functionDecl = lexicalContext.as(FunctionDeclSyntax.self) { + parameterType = functionDecl.signature.parameterClause.parameters + .first { ($0.secondName ?? $0.firstName).tokenKind == parameterName.tokenKind } + .map(\.type) + } else if let closureExpr = lexicalContext.as(ClosureExprSyntax.self) { + if case let .parameterClause(parameterClause) = closureExpr.signature?.parameterClause { + parameterType = parameterClause.parameters + .first { ($0.secondName ?? $0.firstName).tokenKind == parameterName.tokenKind } + .flatMap(\.type) + } + } else if lexicalContext.is(DeclSyntax.self) { + // If we've reached any other enclosing declaration, then any parameters + // beyond it won't be capturable and thus it isn't possible to infer + // types from them (any capture of `x`, for instance, must refer to some + // more-local variable with that name, not to a parameter named `x`.) + return nil + } + + if let parameterType { + return parameterType + } } + + return nil } } diff --git a/Sources/TestingMacros/Support/ConditionArgumentParsing.swift b/Sources/TestingMacros/Support/ConditionArgumentParsing.swift index e0ccda9a7..254f3d0aa 100644 --- a/Sources/TestingMacros/Support/ConditionArgumentParsing.swift +++ b/Sources/TestingMacros/Support/ConditionArgumentParsing.swift @@ -517,10 +517,8 @@ 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) + // we cannot expand it. + let effectKeywordsToApply = findEffectKeywords(in: expr).union(findEffectKeywords(in: context)) guard effectKeywordsToApply.intersection([.unsafe, .try, .await]).isEmpty else { return Condition(expression: expr) } diff --git a/Sources/TestingMacros/Support/DiagnosticMessage.swift b/Sources/TestingMacros/Support/DiagnosticMessage.swift index 36186ec4b..7d7ee1f31 100644 --- a/Sources/TestingMacros/Support/DiagnosticMessage.swift +++ b/Sources/TestingMacros/Support/DiagnosticMessage.swift @@ -93,7 +93,11 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { let result: (value: String, article: String) switch node.kind { case .functionDecl: - result = ("function", "a") + if node.cast(FunctionDeclSyntax.self).isOperator { + result = ("operator", "an") + } else { + result = ("function", "a") + } case .classDecl: result = ("class", "a") case .structDecl: @@ -639,6 +643,41 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { ) } + /// Create a diagnostic message stating that a string literal expression + /// passed as the display name to a `@Test` or `@Suite` attribute is empty + /// but should not be. + /// + /// - Parameters: + /// - decl: The declaration that has an empty display name. + /// - displayNameExpr: The display name string literal expression. + /// - argumentContainingDisplayName: The argument node containing the node + /// `displayNameExpr`. + /// - attribute: The `@Test` or `@Suite` attribute. + /// + /// - Returns: A diagnostic message. + static func declaration( + _ decl: some NamedDeclSyntax, + hasEmptyDisplayName displayNameExpr: StringLiteralExprSyntax, + fromArgument argumentContainingDisplayName: LabeledExprListSyntax.Element, + using attribute: AttributeSyntax + ) -> Self { + Self( + syntax: Syntax(displayNameExpr), + message: "Attribute \(_macroName(attribute)) specifies an empty display name for this \(_kindString(for: decl))", + severity: .error, + fixIts: [ + FixIt( + message: MacroExpansionFixItMessage("Remove display name argument"), + changes: [.replace(oldNode: Syntax(argumentContainingDisplayName), newNode: Syntax("" as ExprSyntax))] + ), + FixIt( + message: MacroExpansionFixItMessage("Add display name"), + changes: [.replace(oldNode: Syntax(argumentContainingDisplayName), newNode: Syntax(StringLiteralExprSyntax(placeholder: "display name")))] + ), + ] + ) + } + /// Create a diagnostic message stating that a declaration has two display /// names. /// @@ -657,10 +696,16 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { fromArgument argumentContainingDisplayName: LabeledExprListSyntax.Element, using attribute: AttributeSyntax ) -> Self { - Self( + // If the name of the ambiguously-named symbol should be derived from a raw + // identifier, this situation is an error. If the name is not raw but is + // still surrounded by backticks (e.g. "func `foo`()" or "struct `if`") then + // lower the severity to a warning. That way, existing code structured this + // way doesn't suddenly fail to build. + let severity: DiagnosticSeverity = (decl.name.rawIdentifier != nil) ? .error : .warning + return Self( syntax: Syntax(decl), - message: "Attribute \(_macroName(attribute)) specifies display name '\(displayNameFromAttribute.representedLiteralValue!)' for \(_kindString(for: decl)) with implicit display name '\(decl.name.rawIdentifier!)'", - severity: .error, + message: "Attribute \(_macroName(attribute)) specifies display name '\(displayNameFromAttribute.representedLiteralValue!)' for \(_kindString(for: decl)) with implicit display name '\(decl.name.textWithoutBackticks)'", + severity: severity, fixIts: [ FixIt( message: MacroExpansionFixItMessage("Remove '\(displayNameFromAttribute.representedLiteralValue!)'"), @@ -827,47 +872,53 @@ extension DiagnosticMessage { ) } - /// Create a diagnostic message stating that a capture clause cannot be used - /// in an exit test. + /// Create a diagnostic message stating that a captured value must conform to + /// `Sendable` and `Codable`. /// /// - Parameters: - /// - captureClause: The invalid capture clause. - /// - closure: The closure containing `captureClause`. - /// - exitTestMacro: The containing exit test macro invocation. + /// - valueExpr: The captured value. + /// - nameExpr: The name of the capture list item corresponding to + /// `valueExpr`. /// /// - Returns: A diagnostic message. - static func captureClauseUnsupported(_ captureClause: ClosureCaptureClauseSyntax, in closure: ClosureExprSyntax, inExitTest exitTestMacro: some FreestandingMacroExpansionSyntax) -> Self { - let changes: [FixIt.Change] - if let signature = closure.signature, - Array(signature.with(\.capture, nil).tokens(viewMode: .sourceAccurate)).count == 1 { - // The only remaining token in the signature is `in`, so remove the whole - // signature tree instead of just the capture clause. - changes = [ - .replaceTrailingTrivia(token: closure.leftBrace, newTrivia: ""), - .replace( - oldNode: Syntax(signature), - newNode: Syntax("" as ExprSyntax) - ) - ] - } else { - changes = [ - .replace( - oldNode: Syntax(captureClause), - newNode: Syntax("" as ExprSyntax) - ) - ] - } - + static func capturedValueMustBeSendableAndCodable(_ valueExpr: ExprSyntax, name nameExpr: StringLiteralExprSyntax) -> Self { + let name = nameExpr.representedLiteralValue ?? valueExpr.trimmedDescription return Self( - syntax: Syntax(captureClause), - message: "Cannot specify a capture clause in closure passed to \(_macroName(exitTestMacro))", - severity: .error, - fixIts: [ - FixIt( - message: MacroExpansionFixItMessage("Remove '\(captureClause.trimmed)'"), - changes: changes - ), - ] + syntax: Syntax(valueExpr), + message: "Type of captured value '\(name)' must conform to 'Sendable' and 'Codable'", + severity: .error ) } + + /// Create a diagnostic message stating that an expression macro is not + /// supported in a generic context. + /// + /// - Parameters: + /// - macro: The invalid macro. + /// - genericClause: The child node on `genericDecl` that makes it generic. + /// - genericDecl: The generic declaration to which `genericClause` is + /// attached, possibly equal to `decl`. + /// + /// - Returns: A diagnostic message. + static func expressionMacroUnsupported(_ macro: some FreestandingMacroExpansionSyntax, inGenericContextBecauseOf genericClause: some SyntaxProtocol, on genericDecl: some SyntaxProtocol) -> Self { + if let functionDecl = genericDecl.as(FunctionDeclSyntax.self) { + return Self( + syntax: Syntax(macro), + message: "Cannot call macro '\(_macroName(macro))' within generic function '\(functionDecl.completeName)'", + severity: .error + ) + } else if let namedDecl = genericDecl.asProtocol((any NamedDeclSyntax).self) { + return Self( + syntax: Syntax(macro), + message: "Cannot call macro '\(_macroName(macro))' within generic \(_kindString(for: genericDecl)) '\(namedDecl.name.trimmed)'", + severity: .error + ) + } else { + return Self( + syntax: Syntax(macro), + message: "Cannot call macro '\(_macroName(macro))' within a generic \(_kindString(for: genericDecl))", + severity: .error + ) + } + } } diff --git a/Sources/TestingMacros/Support/EffectfulExpressionHandling.swift b/Sources/TestingMacros/Support/EffectfulExpressionHandling.swift index 494d2fcfc..28e0af56d 100644 --- a/Sources/TestingMacros/Support/EffectfulExpressionHandling.swift +++ b/Sources/TestingMacros/Support/EffectfulExpressionHandling.swift @@ -14,6 +14,58 @@ import SwiftSyntaxMacros // MARK: - Finding effect keywords and expressions +/// Get the effect keyword corresponding to a given syntax node, if any. +/// +/// - Parameters: +/// - expr: The syntax node that may represent an effectful expression. +/// +/// - Returns: The effect keyword corresponding to `expr`, if any. +private func _effectKeyword(for expr: ExprSyntax) -> Keyword? { + switch expr.kind { + case .tryExpr: + return .try + case .awaitExpr: + return .await + case .consumeExpr: + return .consume + case .borrowExpr: + return .borrow + case .unsafeExpr: + return .unsafe + default: + return nil + } +} + +/// Determine how to descend further into a syntax node tree from a given node. +/// +/// - Parameters: +/// - node: The syntax node currently being walked. +/// +/// - Returns: Whether or not to descend into `node` and visit its children. +private func _continueKind(for node: Syntax) -> SyntaxVisitorContinueKind { + switch node.kind { + case .tryExpr, .awaitExpr, .consumeExpr, .borrowExpr, .unsafeExpr: + // If this node represents an effectful expression, look inside it for + // additional such expressions. + return .visitChildren + 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 +} + /// A syntax visitor class that looks for effectful keywords in a given /// expression. private final class _EffectFinder: SyntaxAnyVisitor { @@ -21,32 +73,11 @@ private final class _EffectFinder: SyntaxAnyVisitor { 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 - } + if let expr = node.as(ExprSyntax.self), let keyword = _effectKeyword(for: expr) { + effectKeywords.insert(keyword) } - // Recurse into everything else. - return .visitChildren + return _continueKind(for: node) } } @@ -54,7 +85,6 @@ private final class _EffectFinder: SyntaxAnyVisitor { /// /// - 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`. @@ -62,13 +92,27 @@ private final class _EffectFinder: SyntaxAnyVisitor { /// 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 +func findEffectKeywords(in node: some SyntaxProtocol) -> Set { let effectFinder = _EffectFinder(viewMode: .sourceAccurate) effectFinder.walk(node) return effectFinder.effectKeywords } +/// Find effectful keywords in a macro's lexical context. +/// +/// - Parameters: +/// - context: The macro context in which the expression is being parsed. +/// +/// - Returns: A set of effectful keywords such as `await` that are present in +/// `context` and would apply to an expression macro during its expansion. +func findEffectKeywords(in context: some MacroExpansionContext) -> Set { + let result = context.lexicalContext.reversed().lazy + .prefix { _continueKind(for: $0) == .visitChildren } + .compactMap { $0.as(ExprSyntax.self) } + .compactMap(_effectKeyword(for:)) + return Set(result) +} + extension BidirectionalCollection { /// The suffix of syntax nodes in this collection which are effectful /// expressions, such as those for `try` or `await`. @@ -117,35 +161,32 @@ private func _makeCallToEffectfulThunk(_ thunkName: TokenSyntax, passing expr: s /// - Parameters: /// - effectfulKeywords: The effectful keywords to apply. /// - expr: The expression to apply the keywords and thunk functions to. +/// - insertThunkCalls: Whether or not to also insert calls to thunks to +/// ensure the inserted keywords do not generate warnings. If you aren't +/// sure whether thunk calls are needed, pass `true`. /// /// - 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 { +func applyEffectfulKeywords(_ effectfulKeywords: Set, to expr: some ExprSyntaxProtocol, insertThunkCalls: Bool = true) -> 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) - - // The 'unsafe' keyword was introduced in 6.2 as part of SE-0458. Older - // toolchains are not aware of it, so avoid emitting expressions involving - // that keyword when the macro has been built using an older toolchain. -#if compiler(>=6.2) let needUnsafe = effectfulKeywords.contains(.unsafe) && !expr.is(UnsafeExprSyntax.self) -#endif // First, add thunk function calls. - if needAwait { - expr = _makeCallToEffectfulThunk(.identifier("__requiringAwait"), passing: expr) - } - if needTry { - expr = _makeCallToEffectfulThunk(.identifier("__requiringTry"), passing: expr) - } -#if compiler(>=6.2) - if needUnsafe { - expr = _makeCallToEffectfulThunk(.identifier("__requiringUnsafe"), passing: expr) + if insertThunkCalls { + if needAwait { + expr = _makeCallToEffectfulThunk(.identifier("__requiringAwait"), passing: expr) + } + if needTry { + expr = _makeCallToEffectfulThunk(.identifier("__requiringTry"), passing: expr) + } + if needUnsafe { + expr = _makeCallToEffectfulThunk(.identifier("__requiringUnsafe"), passing: expr) + } } -#endif // 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 @@ -153,7 +194,7 @@ func applyEffectfulKeywords(_ effectfulKeywords: Set, to expr: some Exp if needAwait { expr = ExprSyntax( AwaitExprSyntax( - awaitKeyword: .keyword(.await).with(\.trailingTrivia, .space), + awaitKeyword: .keyword(.await, trailingTrivia: .space), expression: expr ) ) @@ -161,21 +202,19 @@ func applyEffectfulKeywords(_ effectfulKeywords: Set, to expr: some Exp if needTry { expr = ExprSyntax( TryExprSyntax( - tryKeyword: .keyword(.try).with(\.trailingTrivia, .space), + tryKeyword: .keyword(.try, trailingTrivia: .space), expression: expr ) ) } -#if compiler(>=6.2) if needUnsafe { expr = ExprSyntax( UnsafeExprSyntax( - unsafeKeyword: .keyword(.unsafe).with(\.trailingTrivia, .space), + unsafeKeyword: .keyword(.unsafe, trailingTrivia: .space), expression: expr ) ) } -#endif expr.leadingTrivia = originalExpr.leadingTrivia expr.trailingTrivia = originalExpr.trailingTrivia diff --git a/Sources/TestingMacros/Support/TestContentGeneration.swift b/Sources/TestingMacros/Support/TestContentGeneration.swift index 9a2529cee..05214d1b8 100644 --- a/Sources/TestingMacros/Support/TestContentGeneration.swift +++ b/Sources/TestingMacros/Support/TestContentGeneration.swift @@ -68,7 +68,7 @@ func makeTestContentRecordDecl(named name: TokenSyntax, in typeName: TypeSyntax? private nonisolated \(staticKeyword(for: typeName)) let \(name): Testing.__TestContentRecord = ( \(kindExpr), \(kind.commentRepresentation) 0, - \(accessorName), + unsafe \(accessorName), \(contextExpr), 0 ) diff --git a/Sources/TestingMacros/TagMacro.swift b/Sources/TestingMacros/TagMacro.swift index 624f812cd..01932ef5d 100644 --- a/Sources/TestingMacros/TagMacro.swift +++ b/Sources/TestingMacros/TagMacro.swift @@ -22,7 +22,7 @@ public struct TagMacro: PeerMacro, AccessorMacro, Sendable { /// This property is used rather than simply returning the empty array in /// order to suppress a compiler diagnostic about not producing any accessors. private static var _fallbackAccessorDecls: [AccessorDeclSyntax] { - [#"get { Swift.fatalError("Unreachable") }"#] + [#"get { \#(ExprSyntax.unreachable) }"#] } public static func expansion( diff --git a/Sources/TestingMacros/TestDeclarationMacro.swift b/Sources/TestingMacros/TestDeclarationMacro.swift index 0b2d43f1e..8007c3aaf 100644 --- a/Sources/TestingMacros/TestDeclarationMacro.swift +++ b/Sources/TestingMacros/TestDeclarationMacro.swift @@ -61,7 +61,7 @@ public struct TestDeclarationMacro: PeerMacro, Sendable { } // The @Test attribute is only supported on function declarations. - guard let function = declaration.as(FunctionDeclSyntax.self) else { + guard let function = declaration.as(FunctionDeclSyntax.self), !function.isOperator else { diagnostics.append(.attributeNotSupported(testAttribute, on: declaration)) return false } @@ -160,6 +160,8 @@ public struct TestDeclarationMacro: PeerMacro, Sendable { for (label, parameter) in parametersWithLabels { if parameter.firstName.tokenKind == .wildcard { LabeledExprSyntax(expression: label) + } else if let rawIdentifier = parameter.firstName.rawIdentifier { + LabeledExprSyntax(label: "`\(rawIdentifier)`", expression: label) } else { LabeledExprSyntax(label: parameter.firstName.textWithoutBackticks, expression: label) } @@ -380,7 +382,7 @@ public struct TestDeclarationMacro: PeerMacro, Sendable { // Get the name of the type containing the function for passing to the test // factory function later. - let typeNameExpr: ExprSyntax = typeName.map { "\($0).self" } ?? "nil" + let typeNameExpr: ExprSyntax = typeName.map { "\($0).self" } ?? "nil as Swift.Never.Type?" if typeName != nil, let genericGuardDecl = makeGenericGuardDecl(guardingAgainst: functionDecl, in: context) { result.append(genericGuardDecl) @@ -499,7 +501,7 @@ public struct TestDeclarationMacro: PeerMacro, Sendable { @available(*, deprecated, message: "This type is an implementation detail of the testing library. Do not use it directly.") enum \(enumName): Testing.__TestContentRecordContainer { nonisolated static var __testContentRecord: Testing.__TestContentRecord { - \(testContentRecordName) + unsafe \(testContentRecordName) } } """ diff --git a/Sources/TestingMacros/TestingMacrosMain.swift b/Sources/TestingMacros/TestingMacrosMain.swift index 1894f4282..074aeb86b 100644 --- a/Sources/TestingMacros/TestingMacrosMain.swift +++ b/Sources/TestingMacros/TestingMacrosMain.swift @@ -28,6 +28,9 @@ struct TestingMacrosMain: CompilerPlugin { RequireThrowsNeverMacro.self, ExitTestExpectMacro.self, ExitTestRequireMacro.self, + ExitTestCapturedValueMacro.self, + ExitTestBadCapturedValueMacro.self, + ExitTestIncorrectlyCapturedValueMacro.self, TagMacro.self, SourceLocationMacro.self, PragmaMacro.self, diff --git a/Sources/_TestDiscovery/CMakeLists.txt b/Sources/_TestDiscovery/CMakeLists.txt index 7d6059792..7da7e0a8c 100644 --- a/Sources/_TestDiscovery/CMakeLists.txt +++ b/Sources/_TestDiscovery/CMakeLists.txt @@ -3,8 +3,8 @@ # Copyright (c) 2023–2025 Apple Inc. and the Swift project authors # Licensed under Apache License v2.0 with Runtime Library Exception # -# See http://swift.org/LICENSE.txt for license information -# See http://swift.org/CONTRIBUTORS.txt for Swift project authors +# See https://swift.org/LICENSE.txt for license information +# See https://swift.org/CONTRIBUTORS.txt for Swift project authors add_library(_TestDiscovery STATIC Additions/WinSDKAdditions.swift diff --git a/Sources/_TestDiscovery/DiscoverableAsTestContent.swift b/Sources/_TestDiscovery/DiscoverableAsTestContent.swift index d4b15f8db..16369f82a 100644 --- a/Sources/_TestDiscovery/DiscoverableAsTestContent.swift +++ b/Sources/_TestDiscovery/DiscoverableAsTestContent.swift @@ -16,7 +16,7 @@ /// because they may be discovered within any isolation context or within /// multiple isolation contexts running concurrently. @_spi(Experimental) @_spi(ForToolsIntegrationOnly) -public protocol DiscoverableAsTestContent: Sendable, ~Copyable { +public protocol DiscoverableAsTestContent: Sendable { /// The value of the `kind` field in test content records associated with this /// type. /// @@ -49,7 +49,7 @@ public protocol DiscoverableAsTestContent: Sendable, ~Copyable { } #if !SWT_NO_LEGACY_TEST_DISCOVERY -extension DiscoverableAsTestContent where Self: ~Copyable { +extension DiscoverableAsTestContent { public static var _testContentTypeNameHint: String { "__🟑$" } diff --git a/Sources/_TestDiscovery/TestContentRecord.swift b/Sources/_TestDiscovery/TestContentRecord.swift index 9224fc2ea..b830026e2 100644 --- a/Sources/_TestDiscovery/TestContentRecord.swift +++ b/Sources/_TestDiscovery/TestContentRecord.swift @@ -44,7 +44,7 @@ private typealias _TestContentRecord = ( reserved2: UInt ) -extension DiscoverableAsTestContent where Self: ~Copyable { +extension DiscoverableAsTestContent { /// Check that the layout of this structure in memory matches its expected /// layout in the test content section. /// @@ -64,15 +64,15 @@ extension DiscoverableAsTestContent where Self: ~Copyable { /// ``DiscoverableAsTestContent/allTestContentRecords()`` on a type that /// conforms to ``DiscoverableAsTestContent``. @_spi(Experimental) @_spi(ForToolsIntegrationOnly) -public struct TestContentRecord where T: DiscoverableAsTestContent & ~Copyable { +public struct TestContentRecord where T: DiscoverableAsTestContent { /// The base address of the image containing this instance, if known. /// /// The type of this pointer is platform-dependent: /// /// | Platform | Pointer Type | /// |-|-| - /// | macOS, iOS, watchOS, tvOS, visionOS | `UnsafePointer` | - /// | Linux, FreeBSD, Android | `UnsafePointer` | + /// | macOS, iOS, watchOS, tvOS, visionOS | `UnsafePointer` | + /// | Linux, FreeBSD, Android | `UnsafePointer` | /// | OpenBSD | `UnsafePointer` | /// | Windows | `HMODULE` | /// @@ -85,7 +85,7 @@ public struct TestContentRecord where T: DiscoverableAsTestContent & ~Copyabl public private(set) nonisolated(unsafe) var imageAddress: UnsafeRawPointer? /// A type defining storage for the underlying test content record. - private enum _RecordStorage: @unchecked Sendable { + private enum _RecordStorage { /// The test content record is stored by address. case atAddress(UnsafePointer<_TestContentRecord>) @@ -94,7 +94,7 @@ public struct TestContentRecord where T: DiscoverableAsTestContent & ~Copyabl } /// Storage for `_record`. - private var _recordStorage: _RecordStorage + private nonisolated(unsafe) var _recordStorage: _RecordStorage /// The underlying test content record. private var _record: _TestContentRecord { @@ -229,24 +229,19 @@ extension TestContentRecord: CustomStringConvertible { // MARK: - Enumeration of test content records -extension DiscoverableAsTestContent where Self: ~Copyable { +extension DiscoverableAsTestContent { /// Get all test content of this type known to Swift and found in the current /// process. /// /// - Returns: A sequence of instances of ``TestContentRecord``. Only test /// content records matching this ``TestContent`` type's requirements are /// included in the sequence. - /// - /// @Comment { - /// - Bug: This function returns an instance of `AnySequence` instead of an - /// opaque type due to a compiler crash. ([143080508](rdar://143080508)) - /// } - public static func allTestContentRecords() -> AnySequence> { + public static func allTestContentRecords() -> some Sequence> { validateMemoryLayout() let kind = testContentKind.rawValue - let result = SectionBounds.all(.testContent).lazy.flatMap { sb in + return SectionBounds.all(.testContent).lazy.flatMap { sb in sb.buffer.withMemoryRebound(to: _TestContentRecord.self) { records in (0 ..< records.count).lazy .map { (records.baseAddress! + $0) as UnsafePointer<_TestContentRecord> } @@ -254,7 +249,6 @@ extension DiscoverableAsTestContent where Self: ~Copyable { .map { TestContentRecord(imageAddress: sb.imageAddress, recordAddress: $0) } } } - return AnySequence(result) } } @@ -263,7 +257,7 @@ extension DiscoverableAsTestContent where Self: ~Copyable { private import _TestingInternals -extension DiscoverableAsTestContent where Self: ~Copyable { +extension DiscoverableAsTestContent { /// Get all test content of this type known to Swift and found in the current /// process using the legacy discovery mechanism. /// @@ -277,15 +271,10 @@ extension DiscoverableAsTestContent where Self: ~Copyable { /// - Returns: A sequence of instances of ``TestContentRecord``. Only test /// content records matching this ``TestContent`` type's requirements are /// included in the sequence. - /// - /// @Comment { - /// - Bug: This function returns an instance of `AnySequence` instead of an - /// opaque type due to a compiler crash. ([143080508](rdar://143080508)) - /// } @available(swift, deprecated: 100000.0, message: "Do not adopt this functionality in new code. It will be removed in a future release.") public static func allTypeMetadataBasedTestContentRecords( loadingWith loader: @escaping @Sendable (Any.Type, UnsafeMutableRawBufferPointer) -> Bool - ) -> AnySequence> { + ) -> some Sequence> { validateMemoryLayout() let typeNameHint = _testContentTypeNameHint @@ -300,7 +289,7 @@ extension DiscoverableAsTestContent where Self: ~Copyable { } } - let result = SectionBounds.all(.typeMetadata).lazy.flatMap { sb in + return SectionBounds.all(.typeMetadata).lazy.flatMap { sb in stride(from: 0, to: sb.buffer.count, by: SWTTypeMetadataRecordByteCount).lazy .map { sb.buffer.baseAddress! + $0 } .compactMap { swt_getType(fromTypeMetadataRecord: $0, ifNameContains: typeNameHint) } @@ -309,7 +298,6 @@ extension DiscoverableAsTestContent where Self: ~Copyable { .filter { $0.kind == kind } .map { TestContentRecord(imageAddress: sb.imageAddress, record: $0) } } - return AnySequence(result) } } #endif diff --git a/Sources/_TestingInternals/CMakeLists.txt b/Sources/_TestingInternals/CMakeLists.txt index 16713ab27..b2bc5b6b1 100644 --- a/Sources/_TestingInternals/CMakeLists.txt +++ b/Sources/_TestingInternals/CMakeLists.txt @@ -3,15 +3,16 @@ # Copyright (c) 2024 Apple Inc. and the Swift project authors # Licensed under Apache License v2.0 with Runtime Library Exception # -# See http://swift.org/LICENSE.txt for license information -# See http://swift.org/CONTRIBUTORS.txt for Swift project authors +# See https://swift.org/LICENSE.txt for license information +# See https://swift.org/CONTRIBUTORS.txt for Swift project authors set(CMAKE_CXX_SCAN_FOR_MODULES 0) -include(LibraryVersion) +include(GitCommit) include(TargetTriple) add_library(_TestingInternals STATIC Discovery.cpp + ExecutablePath.cpp Versions.cpp WillThrow.cpp) target_include_directories(_TestingInternals PUBLIC diff --git a/Sources/_TestingInternals/ExecutablePath.cpp b/Sources/_TestingInternals/ExecutablePath.cpp new file mode 100644 index 000000000..ba0c2b0dd --- /dev/null +++ b/Sources/_TestingInternals/ExecutablePath.cpp @@ -0,0 +1,39 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +#include "ExecutablePath.h" + +#include + +#if defined(__OpenBSD__) +/// Storage for ``swt_getEarlyCWD()``. +static constinit std::atomic earlyCWD { nullptr }; + +/// At process start (before `main()` is called), capture the current working +/// directory. +/// +/// This function is necessary on OpenBSD so that we can (as correctly as +/// possible) resolve the executable path when the first argument is a relative +/// path (which can occur when manually invoking the test executable.) +__attribute__((__constructor__(101), __used__)) +static void captureEarlyCWD(void) { + if (auto cwd = getcwd(nullptr, 0)) { + earlyCWD.store(cwd); + } +} +#endif + +const char *swt_getEarlyCWD(void) { +#if defined(__OpenBSD__) + return earlyCWD.load(); +#else + return nullptr; +#endif +} diff --git a/Sources/_TestingInternals/Versions.cpp b/Sources/_TestingInternals/Versions.cpp index 97eace99e..66fc3c985 100644 --- a/Sources/_TestingInternals/Versions.cpp +++ b/Sources/_TestingInternals/Versions.cpp @@ -10,15 +10,59 @@ #include "Versions.h" +#include +#include +#include +#include + const char *swt_getTestingLibraryVersion(void) { #if defined(SWT_TESTING_LIBRARY_VERSION) + // The current environment explicitly specifies a version string to return. + // All CMake builds should take this path (see CompilerSettings.cmake.) return SWT_TESTING_LIBRARY_VERSION; +#elif __clang_major__ >= 17 && defined(__has_embed) +#if __has_embed("../../VERSION.txt") + // Read the version from version.txt at the root of the package's repo. + static char version[] = { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wc23-extensions" +#embed "../../VERSION.txt" suffix(, '\0') +#pragma clang diagnostic pop + }; + + // Zero out the newline character and anything after it. + static std::once_flag once; + std::call_once(once, [] { + auto i = std::find_if(std::begin(version), std::end(version), [] (char c) { + return c == '\r' || c == '\n'; + }); + std::fill(i, std::end(version), '\0'); + }); + + return version; +#else +#warning SWT_TESTING_LIBRARY_VERSION not defined and VERSION.txt not found: testing library version is unavailable + return nullptr; +#endif #else -#warning SWT_TESTING_LIBRARY_VERSION not defined: testing library version is unavailable +#warning SWT_TESTING_LIBRARY_VERSION not defined and could not read from VERSION.txt at compile time: testing library version is unavailable return nullptr; #endif } +void swt_getTestingLibraryCommit(const char *_Nullable *_Nonnull outHash, bool *outModified) { +#if defined(SWT_TESTING_LIBRARY_COMMIT_HASH) + *outHash = SWT_TESTING_LIBRARY_COMMIT_HASH; +#else + *outHash = nullptr; +#endif +#if defined(SWT_TESTING_LIBRARY_COMMIT_MODIFIED) + *outModified = (SWT_TESTING_LIBRARY_COMMIT_MODIFIED != 0); +#else + *outModified = false; +#endif +} + const char *swt_getTargetTriple(void) { #if defined(SWT_TARGET_TRIPLE) return SWT_TARGET_TRIPLE; diff --git a/Sources/_TestingInternals/include/ExecutablePath.h b/Sources/_TestingInternals/include/ExecutablePath.h new file mode 100644 index 000000000..dfa9b1e7e --- /dev/null +++ b/Sources/_TestingInternals/include/ExecutablePath.h @@ -0,0 +1,31 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +#if !defined(SWT_EXECUTABLE_PATH_H) +#define SWT_EXECUTABLE_PATH_H + +#include "Defines.h" +#include "Includes.h" + +SWT_ASSUME_NONNULL_BEGIN + +/// Get the current working directory as it was set shortly after the process +/// started and before `main()` has been called. +/// +/// This function is necessary on OpenBSD so that we can (as correctly as +/// possible) resolve the executable path when the first argument is a relative +/// path (which can occur when manually invoking the test executable.) +/// +/// On all other platforms, this function always returns `nullptr`. +SWT_EXTERN const char *_Nullable swt_getEarlyCWD(void); + +SWT_ASSUME_NONNULL_END + +#endif diff --git a/Sources/_TestingInternals/include/Includes.h b/Sources/_TestingInternals/include/Includes.h index bfc87b001..3f0433cb4 100644 --- a/Sources/_TestingInternals/include/Includes.h +++ b/Sources/_TestingInternals/include/Includes.h @@ -29,6 +29,12 @@ #include #include #include +/// limits.h must be included before stdlib.h with glibc, otherwise the +/// fortified realpath() in this module will differ from the one in SwiftGlibc. +/// glibc bug: https://sourceware.org/bugzilla/show_bug.cgi?id=30516 +#if __has_include() +#include +#endif /// Guard against including `signal.h` on WASI. The `signal.h` header file /// itself is available in wasi-libc, but it's just a stub that doesn't actually /// do anything. And also including it requires a special macro definition @@ -53,6 +59,10 @@ #include #endif +#if __has_include() +#include +#endif + #if __has_include() && !defined(__wasi__) #include #endif @@ -93,10 +103,6 @@ #include #endif -#if __has_include() -#include -#endif - #if __has_include() #include #endif @@ -147,7 +153,6 @@ #endif #if defined(_WIN32) -#define WIN32_LEAN_AND_MEAN #define NOMINMAX #include #include diff --git a/Sources/_TestingInternals/include/Stubs.h b/Sources/_TestingInternals/include/Stubs.h index 8093a3722..636ea9aff 100644 --- a/Sources/_TestingInternals/include/Stubs.h +++ b/Sources/_TestingInternals/include/Stubs.h @@ -16,6 +16,15 @@ SWT_ASSUME_NONNULL_BEGIN +/// Mark a code path as unreachable. +/// +/// This function is necessary because Swift does not have an equivalent of +/// `__builtin_unreachable()`. +__attribute__((always_inline, noreturn)) +static inline void swt_unreachable(void) { + __builtin_unreachable(); +} + #if !SWT_NO_FILE_IO /// The C file handle type. /// @@ -151,6 +160,26 @@ static int swt_EEXIST(void) { return EEXIST; } +#if defined(F_GETFD) +/// Call `fcntl(F_GETFD)`. +/// +/// This function is provided because `fcntl()` is a variadic function and +/// cannot be imported directly into Swift. +static int swt_getfdflags(int fd) { + return fcntl(fd, F_GETFD); +} +#endif + +#if defined(F_SETFD) +/// Call `fcntl(F_SETFD)`. +/// +/// This function is provided because `fcntl()` is a variadic function and +/// cannot be imported directly into Swift. +static int swt_setfdflags(int fd, int flags) { + return fcntl(fd, F_SETFD, flags); +} +#endif + SWT_ASSUME_NONNULL_END #endif diff --git a/Sources/_TestingInternals/include/TestSupport.h b/Sources/_TestingInternals/include/TestSupport.h index 37d42692e..2d6229ed5 100644 --- a/Sources/_TestingInternals/include/TestSupport.h +++ b/Sources/_TestingInternals/include/TestSupport.h @@ -37,6 +37,12 @@ static inline bool swt_pointersNotEqual4(const char *a, const char *b, const cha return a != b && b != c && c != d; } +#if defined(_WIN32) +static inline LPCSTR swt_IDI_SHIELD(void) { + return IDI_SHIELD; +} +#endif + SWT_ASSUME_NONNULL_END #endif diff --git a/Sources/_TestingInternals/include/Versions.h b/Sources/_TestingInternals/include/Versions.h index 1be02ba33..65fc4170e 100644 --- a/Sources/_TestingInternals/include/Versions.h +++ b/Sources/_TestingInternals/include/Versions.h @@ -16,6 +16,20 @@ SWT_ASSUME_NONNULL_BEGIN +/// Get the version of the compiler used to build the testing library. +/// +/// - Returns: An integer containing the packed major, minor, and patch +/// components of the compiler version. For more information, see +/// [ClangImporter.cpp](https://github.com/swiftlang/swift/blob/36246a2c8e9501cd29a75f34c9631a8f4e2e1e9b/lib/ClangImporter/ClangImporter.cpp#L647) +/// in the Swift repository. +static inline uint64_t swt_getSwiftCompilerVersion(void) { +#if defined(__SWIFT_COMPILER_VERSION) + return __SWIFT_COMPILER_VERSION; +#else + return 0; +#endif +} + /// Get the human-readable version of the testing library. /// /// - Returns: A human-readable string describing the version of the testing @@ -24,30 +38,21 @@ SWT_ASSUME_NONNULL_BEGIN /// other conditions. Do not attempt to parse it. SWT_EXTERN const char *_Nullable swt_getTestingLibraryVersion(void); +/// Get details of the source control (git) commit from which the testing +/// library was built. +/// +/// - Parameters: +/// - outHash: On return, set to a pointer to a string containing the commit +/// hash from which the testing library was built. +/// - outModified: On return, whether or not there were uncommitted changes. +SWT_EXTERN void swt_getTestingLibraryCommit(const char *_Nullable *_Nonnull outHash, bool *outModified); + /// Get the LLVM target triple used to build the testing library. /// /// - Returns: A string containing the LLVM target triple used to build the /// testing library, or `nullptr` if that information is not available. SWT_EXTERN const char *_Nullable swt_getTargetTriple(void); -#if defined(__wasi__) -/// Get the version of the C standard library and runtime used by WASI, if -/// available. -/// -/// This function is provided because `WASI_SDK_VERSION` may or may not be -/// defined and may or may not be a complex macro. -/// -/// For more information about the `WASI_SDK_VERSION` macro, see -/// [wasi-libc-#490](https://github.com/WebAssembly/wasi-libc/issues/490). -static const char *_Nullable swt_getWASIVersion(void) { -#if defined(WASI_SDK_VERSION) - return WASI_SDK_VERSION; -#else - return 0; -#endif -} -#endif - SWT_ASSUME_NONNULL_END #endif diff --git a/Sources/_TestingInterop/CMakeLists.txt b/Sources/_TestingInterop/CMakeLists.txt new file mode 100644 index 000000000..adbf7037b --- /dev/null +++ b/Sources/_TestingInterop/CMakeLists.txt @@ -0,0 +1,24 @@ +# This source file is part of the Swift.org open source project +# +# Copyright (c) 2025 Apple Inc. and the Swift project authors +# Licensed under Apache License v2.0 with Runtime Library Exception +# +# See http://swift.org/LICENSE.txt for license information +# See http://swift.org/CONTRIBUTORS.txt for Swift project authors + +add_library(_TestingInterop + FallbackEventHandler.swift) + +target_link_libraries(_TestingInterop PRIVATE + _TestingInternals) +if(NOT BUILD_SHARED_LIBS) + # When building a static library, tell clients to autolink the internal + # libraries. + target_compile_options(_TestingInterop PRIVATE + "SHELL:-Xfrontend -public-autolink-library -Xfrontend _TestingInternals") +endif() +target_compile_options(_TestingInterop PRIVATE + -enable-library-evolution + -emit-module-interface -emit-module-interface-path $/_TestingInterop.swiftinterface) + +_swift_testing_install_target(_TestingInterop) diff --git a/Sources/_TestingInterop/FallbackEventHandler.swift b/Sources/_TestingInterop/FallbackEventHandler.swift new file mode 100644 index 000000000..9408bbdfb --- /dev/null +++ b/Sources/_TestingInterop/FallbackEventHandler.swift @@ -0,0 +1,124 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +#if !SWT_NO_INTEROP +#if SWT_TARGET_OS_APPLE && !SWT_NO_OS_UNFAIR_LOCK && !hasFeature(Embedded) +private import _TestingInternals +#else +private import Synchronization +#endif + +#if SWT_TARGET_OS_APPLE && !SWT_NO_OS_UNFAIR_LOCK && !hasFeature(Embedded) +/// The installed event handler. +private nonisolated(unsafe) let _fallbackEventHandler = { + let result = ManagedBuffer.create( + minimumCapacity: 1, + makingHeaderWith: { _ in nil } + ) + result.withUnsafeMutablePointerToHeader { $0.initialize(to: nil) } + return result +}() +#else +/// `Atomic`-compatible storage for ``FallbackEventHandler``. +private final class _FallbackEventHandlerStorage: Sendable, RawRepresentable { + let rawValue: FallbackEventHandler + + init(rawValue: FallbackEventHandler) { + self.rawValue = rawValue + } +} + +/// The installed event handler. +private let _fallbackEventHandler = AtomicLazyReference<_FallbackEventHandlerStorage>() +#endif + +/// A type describing a fallback event handler that testing API can invoke as an +/// alternate method of reporting test events to the current test runner. +/// +/// For example, an `XCTAssert` failure in the body of a Swift Testing test +/// cannot record issues directly with the Swift Testing runner. Instead, the +/// framework packages the assertion failure as a JSON `Event` and invokes this +/// handler to report the failure. +/// +/// - Parameters: +/// - recordJSONSchemaVersionNumber: The JSON schema version used to encode +/// the event record. +/// - recordJSONBaseAddress: A pointer to the first byte of the encoded event. +/// - recordJSONByteCount: The size of the encoded event in bytes. +/// - reserved: Reserved for future use. +@usableFromInline +package typealias FallbackEventHandler = @Sendable @convention(c) ( + _ recordJSONSchemaVersionNumber: UnsafePointer, + _ recordJSONBaseAddress: UnsafeRawPointer, + _ recordJSONByteCount: Int, + _ reserved: UnsafeRawPointer? +) -> Void + +/// Get the current fallback event handler. +/// +/// - Returns: The currently-set handler function, if any. +@_cdecl("_swift_testing_getFallbackEventHandler") +@usableFromInline +package func _swift_testing_getFallbackEventHandler() -> FallbackEventHandler? { +#if SWT_TARGET_OS_APPLE && !SWT_NO_OS_UNFAIR_LOCK && !hasFeature(Embedded) + return _fallbackEventHandler.withUnsafeMutablePointers { fallbackEventHandler, lock in + os_unfair_lock_lock(lock) + defer { + os_unfair_lock_unlock(lock) + } + return fallbackEventHandler.pointee + } +#else + // If we had a setter, this load would present a race condition because + // another thread could store a new value in between the load and the call to + // `takeUnretainedValue()`, resulting in a use-after-free on this thread. We + // would need a full lock in order to avoid that problem. However, because we + // instead have a one-time installation function, we can be sure that the + // loaded value (if non-nil) will never be replaced with another value. + return _fallbackEventHandler.load()?.rawValue +#endif +} + +/// Set the current fallback event handler if one has not already been set. +/// +/// - Parameters: +/// - handler: The handler function to set. +/// +/// - Returns: Whether or not `handler` was installed. +/// +/// The fallback event handler can only be installed once per process, typically +/// by the first testing library to run. If this function has already been +/// called and the handler set, it does not replace the previous handler. +@_cdecl("_swift_testing_installFallbackEventHandler") +@usableFromInline +package func _swift_testing_installFallbackEventHandler(_ handler: FallbackEventHandler) -> CBool { + var result = false + +#if SWT_TARGET_OS_APPLE && !SWT_NO_OS_UNFAIR_LOCK && !hasFeature(Embedded) + result = _fallbackEventHandler.withUnsafeMutablePointers { fallbackEventHandler, lock in + os_unfair_lock_lock(lock) + defer { + os_unfair_lock_unlock(lock) + } + guard fallbackEventHandler.pointee == nil else { + return false + } + fallbackEventHandler.pointee = handler + return true + } +#else + let handler = _FallbackEventHandlerStorage(rawValue: handler) + let stored = _fallbackEventHandler.storeIfNil(handler) + result = (handler === stored) +#endif + + return result +} +#endif diff --git a/Tests/TestingMacrosTests/ConditionMacroTests.swift b/Tests/TestingMacrosTests/ConditionMacroTests.swift index dc36af7cd..a451b6f29 100644 --- a/Tests/TestingMacrosTests/ConditionMacroTests.swift +++ b/Tests/TestingMacrosTests/ConditionMacroTests.swift @@ -436,7 +436,22 @@ struct ConditionMacroTests { #expect(diagnostic.message.contains("is redundant")) } -#if ExperimentalExitTestValueCapture + @Test("#expect(processExitsWith:) diagnostics", + arguments: [ + "func f() { #expectExitTest(processExitsWith: x) {} }": + "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within generic function 'f()'", + ] + ) + func exitTestDiagnostics(input: String, expectedMessage: String) throws { + let (_, diagnostics) = try parse(input) + + #expect(diagnostics.count > 0) + for diagnostic in diagnostics { + #expect(diagnostic.diagMessage.severity == .error) + #expect(diagnostic.message == expectedMessage) + } + } + @Test("#expect(processExitsWith:) produces a diagnostic for a bad capture", arguments: [ "#expectExitTest(processExitsWith: x) { [weak a] in }": @@ -445,37 +460,21 @@ struct ConditionMacroTests { "Type of captured value 'a' is ambiguous", "#expectExitTest(processExitsWith: x) { [a = b] in }": "Type of captured value 'a' is ambiguous", + "#expectExitTest(processExitsWith: x) { [a = b as any T] in }": + "Type of captured value 'a' is ambiguous", + "#expectExitTest(processExitsWith: x) { [a = b as some T] in }": + "Type of captured value 'a' is ambiguous", + "struct S { func f() { #expectExitTest(processExitsWith: x) { [a] in } } }": + "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within generic structure 'S'", ] ) 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 + let (_, diagnostics) = try parse(input) - @Test( - "Capture list on an exit test produces a diagnostic", - arguments: [ - "#expectExitTest(processExitsWith: x) { [a] in }": - "Cannot specify a capture clause in closure passed to '#expectExitTest(processExitsWith:_:)'" - ] - ) - func exitTestCaptureListProducesDiagnostic(input: String, expectedMessage: String) throws { - 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/TestingMacrosTests/TestDeclarationMacroTests.swift b/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift index 13ae3d180..aec6d2c10 100644 --- a/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift +++ b/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift @@ -67,6 +67,8 @@ struct TestDeclarationMacroTests { "Attribute 'Test' cannot be applied to a structure", "@Test enum E {}": "Attribute 'Test' cannot be applied to an enumeration", + "@Test func +() {}": + "Attribute 'Test' cannot be applied to an operator", // Availability "@available(*, unavailable) @Suite struct S {}": @@ -217,20 +219,76 @@ struct TestDeclarationMacroTests { ] ), - #"@Test("Goodbye world") func `__raw__$helloWorld`()"#: + #"@Test("Goodbye world") func `hello world`()"#: ( - message: "Attribute 'Test' specifies display name 'Goodbye world' for function with implicit display name 'helloWorld'", + message: "Attribute 'Test' specifies display name 'Goodbye world' for function with implicit display name 'hello world'", fixIts: [ ExpectedFixIt( message: "Remove 'Goodbye world'", changes: [.replace(oldSourceCode: #""Goodbye world""#, newSourceCode: "")] ), ExpectedFixIt( - message: "Rename '__raw__$helloWorld'", - changes: [.replace(oldSourceCode: "`__raw__$helloWorld`", newSourceCode: "\(EditorPlaceholderExprSyntax("name"))")] + message: "Rename 'hello world'", + changes: [.replace(oldSourceCode: "`hello world`", newSourceCode: "\(EditorPlaceholderExprSyntax("name"))")] ), ] ), + + // empty display name string literal + #"@Test("") func f() {}"#: + ( + message: "Attribute 'Test' specifies an empty display name for this function", + fixIts: [ + ExpectedFixIt( + message: "Remove display name argument", + changes: [ + .replace(oldSourceCode: #""""#, newSourceCode: "") + ]), + ExpectedFixIt( + message: "Add display name", + changes: [ + .replace( + oldSourceCode: #""""#, + newSourceCode: #""\#(EditorPlaceholderExprSyntax("display name"))""#) + ]) + ] + ), + ##"@Test(#""#) func f() {}"##: + ( + message: "Attribute 'Test' specifies an empty display name for this function", + fixIts: [ + ExpectedFixIt( + message: "Remove display name argument", + changes: [ + .replace(oldSourceCode: ##"#""#"##, newSourceCode: "") + ]), + ExpectedFixIt( + message: "Add display name", + changes: [ + .replace( + oldSourceCode: ##"#""#"##, + newSourceCode: #""\#(EditorPlaceholderExprSyntax("display name"))""#) + ]) + ] + ), + #"@Suite("") struct S {}"#: + ( + message: "Attribute 'Suite' specifies an empty display name for this structure", + fixIts: [ + ExpectedFixIt( + message: "Remove display name argument", + changes: [ + .replace(oldSourceCode: #""""#, newSourceCode: "") + ]), + ExpectedFixIt( + message: "Add display name", + changes: [ + .replace( + oldSourceCode: #""""#, + newSourceCode: #""\#(EditorPlaceholderExprSyntax("display name"))""#) + ]) + ] + ) ] } @@ -281,10 +339,10 @@ struct TestDeclarationMacroTests { @Test("Raw function name components") func rawFunctionNameComponents() throws { let decl = """ - func `__raw__$hello`(`__raw__$world`: T, etc: U, `blah`: V) {} + func `hello there`(`world of mine`: T, etc: U, `blah`: V) {} """ as DeclSyntax let functionDecl = try #require(decl.as(FunctionDeclSyntax.self)) - #expect(functionDecl.completeName.trimmedDescription == "`hello`(`world`:etc:blah:)") + #expect(functionDecl.completeName.trimmedDescription == "`hello there`(`world of mine`:etc:blah:)") } @Test("Warning diagnostics emitted on API misuse", @@ -357,7 +415,12 @@ struct TestDeclarationMacroTests { [ #"#if os(moofOS)"#, #".__available("moofOS", obsoleted: nil, message: "Moof!", "#, - ] + ], + #"@available(customAvailabilityDomain) @Test func f() {}"#: + [ + #".__available("customAvailabilityDomain", introduced: nil, "#, + #"guard #available (customAvailabilityDomain) else"#, + ], ] ) func availabilityAttributeCapture(input: String, expectedOutputs: [String]) throws { @@ -452,6 +515,7 @@ struct TestDeclarationMacroTests { } @Test("Valid tag expressions are allowed", + .tags(.traitRelated), arguments: [ #"@Test(.tags(.f)) func f() {}"#, #"@Test(Tag.List.tags(.f)) func f() {}"#, @@ -472,6 +536,7 @@ struct TestDeclarationMacroTests { } @Test("Invalid tag expressions are detected", + .tags(.traitRelated), arguments: [ "f()", ".f()", "loose", "WrongType.tag", "WrongType.f()", @@ -490,6 +555,7 @@ struct TestDeclarationMacroTests { } @Test("Valid bug identifiers are allowed", + .tags(.traitRelated), arguments: [ #"@Test(.bug(id: 12345)) func f() {}"#, #"@Test(.bug(id: "12345")) func f() {}"#, @@ -512,6 +578,7 @@ struct TestDeclarationMacroTests { } @Test("Invalid bug URLs are detected", + .tags(.traitRelated), arguments: [ "mailto: a@example.com", "example.com", ] diff --git a/Tests/TestingMacrosTests/TestSupport/Parse.swift b/Tests/TestingMacrosTests/TestSupport/Parse.swift index 2b30df42e..453a631b2 100644 --- a/Tests/TestingMacrosTests/TestSupport/Parse.swift +++ b/Tests/TestingMacrosTests/TestSupport/Parse.swift @@ -19,7 +19,7 @@ import SwiftSyntaxBuilder import SwiftSyntaxMacros import SwiftSyntaxMacroExpansion -fileprivate let allMacros: [String: any Macro.Type] = [ +fileprivate let allMacros: [String: any (Macro & Sendable).Type] = [ "expect": ExpectMacro.self, "require": RequireMacro.self, "requireAmbiguous": AmbiguousRequireMacro.self, // different name needed only for unit testing diff --git a/Tests/TestingMacrosTests/TestSupport/TestingAdditions.swift b/Tests/TestingMacrosTests/TestSupport/TestingAdditions.swift new file mode 100644 index 000000000..68e0fe81b --- /dev/null +++ b/Tests/TestingMacrosTests/TestSupport/TestingAdditions.swift @@ -0,0 +1,16 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +import Testing + +extension Tag { + /// A tag indicating that a test is related to a trait. + @Tag static var traitRelated: Self +} diff --git a/Tests/TestingTests/ABIEntryPointTests.swift b/Tests/TestingTests/ABIEntryPointTests.swift index 9fcda9223..a50f92afa 100644 --- a/Tests/TestingTests/ABIEntryPointTests.swift +++ b/Tests/TestingTests/ABIEntryPointTests.swift @@ -18,74 +18,10 @@ private import _TestingInternals @Suite("ABI entry point tests") struct ABIEntryPointTests { -#if !SWT_NO_SNAPSHOT_TYPES - @available(*, deprecated) - @Test func v0_experimental() async throws { - var arguments = __CommandLineArguments_v0() - arguments.filter = ["NonExistentTestThatMatchesNothingHopefully"] - arguments.eventStreamVersion = 0 - arguments.verbosity = .min - - let result = try await _invokeEntryPointV0Experimental(passing: arguments) { recordJSON in - let record = try! JSON.decode(ABI.Record.self, from: recordJSON) - _ = record.kind - } - - #expect(result == EXIT_SUCCESS) - } - - @available(*, deprecated) - @Test("v0 experimental entry point with a large number of filter arguments") - func v0_experimental_manyFilters() async throws { - var arguments = __CommandLineArguments_v0() - arguments.filter = (1...100).map { "NonExistentTestThatMatchesNothingHopefully_\($0)" } - arguments.eventStreamVersion = 0 - arguments.verbosity = .min - - let result = try await _invokeEntryPointV0Experimental(passing: arguments) - - #expect(result == EXIT_SUCCESS) - } - - @available(*, deprecated) - private func _invokeEntryPointV0Experimental( - passing arguments: __CommandLineArguments_v0, - recordHandler: @escaping @Sendable (_ recordJSON: UnsafeRawBufferPointer) -> Void = { _ in } - ) async throws -> CInt { -#if !SWT_NO_DYNAMIC_LINKING - // Get the ABI entry point by dynamically looking it up at runtime. - let copyABIEntryPoint_v0 = try withTestingLibraryImageAddress { testingLibrary in - try #require( - symbol(in: testingLibrary, named: "swt_copyABIEntryPoint_v0").map { - castCFunction(at: $0, to: (@convention(c) () -> UnsafeMutableRawPointer).self) - } - ) - } -#endif - let abiEntryPoint = copyABIEntryPoint_v0().assumingMemoryBound(to: ABI.Xcode16.EntryPoint.self) - defer { - abiEntryPoint.deinitialize(count: 1) - abiEntryPoint.deallocate() - } - - let argumentsJSON = try JSON.withEncoding(of: arguments) { argumentsJSON in - let result = UnsafeMutableRawBufferPointer.allocate(byteCount: argumentsJSON.count, alignment: 1) - result.copyMemory(from: argumentsJSON) - return result - } - defer { - argumentsJSON.deallocate() - } - - // Call the entry point function. - return try await abiEntryPoint.pointee(.init(argumentsJSON), recordHandler) - } -#endif - @Test func v0() async throws { var arguments = __CommandLineArguments_v0() arguments.filter = ["NonExistentTestThatMatchesNothingHopefully"] - arguments.eventStreamVersion = 0 + arguments.eventStreamSchemaVersion = "0" arguments.verbosity = .min let result = try await _invokeEntryPointV0(passing: arguments) { recordJSON in @@ -100,7 +36,7 @@ struct ABIEntryPointTests { func v0_manyFilters() async throws { var arguments = __CommandLineArguments_v0() arguments.filter = (1...100).map { "NonExistentTestThatMatchesNothingHopefully_\($0)" } - arguments.eventStreamVersion = 0 + arguments.eventStreamSchemaVersion = "0" arguments.verbosity = .min let result = try await _invokeEntryPointV0(passing: arguments) @@ -112,7 +48,7 @@ struct ABIEntryPointTests { func v0_listingTestsOnly() async throws { var arguments = __CommandLineArguments_v0() arguments.listTests = true - arguments.eventStreamVersion = 0 + arguments.eventStreamSchemaVersion = "0" arguments.verbosity = .min try await confirmation("Test matched", expectedCount: 1...) { testMatched in @@ -131,12 +67,12 @@ struct ABIEntryPointTests { passing arguments: __CommandLineArguments_v0, recordHandler: @escaping @Sendable (_ recordJSON: UnsafeRawBufferPointer) -> Void = { _ in } ) async throws -> Bool { -#if !os(Linux) && !os(FreeBSD) && !os(Android) && !SWT_NO_DYNAMIC_LINKING +#if !(os(Linux) || os(FreeBSD) || os(OpenBSD) || os(Android)) && !SWT_NO_DYNAMIC_LINKING // Get the ABI entry point by dynamically looking it up at runtime. // - // NOTE: The standard Linux linker does not allow exporting symbols from - // executables, so dlsym() does not let us find this function on that - // platform when built as an executable rather than a dynamic library. + // NOTE: The standard linkers on these platforms do not export symbols from + // executables, so dlsym() does not let us find this function on these + // platforms when built as an executable rather than a dynamic library. let abiv0_getEntryPoint = try withTestingLibraryImageAddress { testingLibrary in try #require( symbol(in: testingLibrary, named: "swt_abiv0_getEntryPoint").map { @@ -169,7 +105,7 @@ struct ABIEntryPointTests { } @Test func decodeWrongRecordVersion() throws { - let record = ABI.Record(encoding: Test {}) + let record = ABI.Record(encoding: Test {}) let error = try JSON.withEncoding(of: record) { recordJSON in try #require(throws: DecodingError.self) { _ = try JSON.decode(ABI.Record.self, from: recordJSON) @@ -178,9 +114,63 @@ struct ABIEntryPointTests { guard case let .dataCorrupted(context) = error else { throw error } - #expect(context.debugDescription == "Unexpected record version 1 (expected 0).") + #expect(context.debugDescription == "Unexpected record version 6.3 (expected 0).") + } + + @Test func decodeVersionNumber() throws { + let version0 = try JSON.withEncoding(of: 0) { versionJSON in + try JSON.decode(VersionNumber.self, from: versionJSON) + } + #expect(version0 == VersionNumber(0, 0)) + + let version1_2_3 = try JSON.withEncoding(of: "1.2.3") { versionJSON in + try JSON.decode(VersionNumber.self, from: versionJSON) + } + #expect(version1_2_3.majorComponent == 1) + #expect(version1_2_3.minorComponent == 2) + #expect(version1_2_3.patchComponent == 3) + + #expect(throws: DecodingError.self) { + _ = try JSON.withEncoding(of: "not.valid") { versionJSON in + try JSON.decode(VersionNumber.self, from: versionJSON) + } + } } #endif + + @Test(arguments: [ + (VersionNumber(-1, 0), "-1"), + (VersionNumber(0, 0), "0"), + (VersionNumber(1, 0), "1.0"), + (VersionNumber(2, 0), "2.0"), + (VersionNumber("0.0.1"), "0.0.1"), + (VersionNumber("0.1.0"), "0.1"), + ]) func abiVersionStringConversion(version: VersionNumber?, expectedString: String) throws { + let version = try #require(version) + #expect(String(describing: version) == expectedString) + } + + @Test func badABIVersionString() { + let version = VersionNumber("not.valid") + #expect(version == nil) + } + + @Test func abiVersionComparisons() throws { + var versions = [VersionNumber]() + for major in 0 ..< 10 { + let version = try #require(VersionNumber("\(major)")) + versions.append(version) + for minor in 0 ..< 10 { + let version = try #require(VersionNumber("\(major).\(minor)")) + versions.append(version) + for patch in 0 ..< 10 { + let version = try #require(VersionNumber("\(major).\(minor).\(patch)")) + versions.append(version) + } + } + } + #expect(versions == versions.shuffled().sorted()) + } } #if !SWT_NO_DYNAMIC_LINKING @@ -197,11 +187,12 @@ private func withTestingLibraryImageAddress(_ body: (ImageAddress?) throws -> defer { dlclose(testingLibraryAddress) } -#elseif os(Linux) || os(FreeBSD) || os(Android) - // When using glibc, dladdr() is only available if __USE_GNU is specified. +#elseif os(Linux) || os(FreeBSD) || os(OpenBSD) || os(Android) + // We can't dynamically look up a function linked into the test executable on + // ELF-based platforms. #elseif os(Windows) let flags = DWORD(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS) - try addressInTestingLibrary.withMemoryRebound(to: wchar_t.self, capacity: MemoryLayout.stride / MemoryLayout.stride) { addressInTestingLibrary in + try addressInTestingLibrary.withMemoryRebound(to: CWideChar.self, capacity: MemoryLayout.stride / MemoryLayout.stride) { addressInTestingLibrary in try #require(GetModuleHandleExW(flags, addressInTestingLibrary, &testingLibraryAddress)) } defer { diff --git a/Tests/TestingTests/AdvancedConsoleOutputRecorderTests.swift b/Tests/TestingTests/AdvancedConsoleOutputRecorderTests.swift new file mode 100644 index 000000000..2eafb6601 --- /dev/null +++ b/Tests/TestingTests/AdvancedConsoleOutputRecorderTests.swift @@ -0,0 +1,242 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +#if canImport(Foundation) +@testable @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing +import Foundation + +@Suite("Advanced Console Output Recorder Tests") +struct AdvancedConsoleOutputRecorderTests { + final class Stream: TextOutputStream, Sendable { + let buffer = Locked(rawValue: "") + + @Sendable func write(_ string: String) { + buffer.withLock { + $0.append(string) + } + } + } + + @Test("Recorder initialization with default options") + func recorderInitialization() { + let stream = Stream() + let recorder = Event.AdvancedConsoleOutputRecorder(writingUsing: stream.write) + + // Verify the recorder was created successfully and has expected defaults + #expect(recorder.options.base.useANSIEscapeCodes == false) // Default for non-TTY + } + + @Test("Recorder initialization with custom options") + func recorderInitializationWithCustomOptions() { + let stream = Stream() + var options = Event.AdvancedConsoleOutputRecorder.Options() + options.base.useANSIEscapeCodes = true + + let recorder = Event.AdvancedConsoleOutputRecorder( + options: options, + writingUsing: stream.write + ) + + // Verify the custom options were applied + #expect(recorder.options.base.useANSIEscapeCodes == true) + } + + @Test("Basic event recording produces output") + func basicEventRecording() async { + let stream = Stream() + + var configuration = Configuration() + let eventRecorder = Event.AdvancedConsoleOutputRecorder(writingUsing: stream.write) + configuration.eventHandler = { event, context in + eventRecorder.record(event, in: context) + } + + // Run a simple test to generate events + await Test(name: "Sample Test") { + #expect(Bool(true)) + }.run(configuration: configuration) + + let buffer = stream.buffer.rawValue + // Verify that the hierarchical output was generated + #expect(buffer.contains("HIERARCHICAL TEST RESULTS")) + #expect(buffer.contains("Test run started")) + } + + @Test("Hierarchical output structure is generated") + func hierarchicalOutputStructure() async { + let stream = Stream() + + var configuration = Configuration() + let eventRecorder = Event.AdvancedConsoleOutputRecorder(writingUsing: stream.write) + configuration.eventHandler = { event, context in + eventRecorder.record(event, in: context) + } + + // Run tests that will create a hierarchy + await runTest(for: HierarchicalTestSuite.self, configuration: configuration) + + let buffer = stream.buffer.rawValue + + // Verify hierarchical output headers are generated + #expect(buffer.contains("HIERARCHICAL TEST RESULTS")) + #expect(buffer.contains("completed")) + + // Should contain tree structure characters (Unicode or ASCII fallback) + #expect(buffer.contains("β”œβ”€") || buffer.contains("╰─") || buffer.contains("β”Œβ”€") || + buffer.contains("|-") || buffer.contains("`-") || buffer.contains(".-")) + } + + @Test("Failed test details are properly formatted") + func failedTestDetails() async { + let stream = Stream() + + var configuration = Configuration() + let eventRecorder = Event.AdvancedConsoleOutputRecorder(writingUsing: stream.write) + configuration.eventHandler = { event, context in + eventRecorder.record(event, in: context) + } + + // Run tests with failures + await runTest(for: FailingTestSuite.self, configuration: configuration) + + let buffer = stream.buffer.rawValue + + // Verify failure details section is generated + #expect(buffer.contains("FAILED TEST DETAILS")) + + // Should show test hierarchy in failure details + #expect(buffer.contains("FailingTestSuite")) + } + + @Test("Test statistics are correctly calculated") + func testStatisticsCalculation() async { + let stream = Stream() + + var configuration = Configuration() + let eventRecorder = Event.AdvancedConsoleOutputRecorder(writingUsing: stream.write) + configuration.eventHandler = { event, context in + eventRecorder.record(event, in: context) + } + + // Run mixed passing and failing tests + await runTest(for: MixedTestSuite.self, configuration: configuration) + + let buffer = stream.buffer.rawValue + + // Verify that statistics are correctly calculated and displayed + #expect(buffer.contains("completed")) + #expect(buffer.contains("pass:") || buffer.contains("fail:")) + } + + @Test("Duration formatting is consistent") + func durationFormatting() async { + let stream = Stream() + + var configuration = Configuration() + let eventRecorder = Event.AdvancedConsoleOutputRecorder(writingUsing: stream.write) + configuration.eventHandler = { event, context in + eventRecorder.record(event, in: context) + } + + // Run a simple test to generate timing + await Test(name: "Timed Test") { + #expect(Bool(true)) + }.run(configuration: configuration) + + let buffer = stream.buffer.rawValue + + // Should not crash and should generate some output with timing + #expect(!buffer.isEmpty) + #expect(buffer.contains("s")) // Duration formatting should include 's' suffix + } + + @Test("Event consolidation works correctly") + func eventConsolidation() async { + let stream = Stream() + + var configuration = Configuration() + let eventRecorder = Event.AdvancedConsoleOutputRecorder(writingUsing: stream.write) + configuration.eventHandler = { event, context in + eventRecorder.record(event, in: context) + } + + // Run tests to verify the consolidated data structure works + await runTest(for: SimpleTestSuite.self, configuration: configuration) + + let buffer = stream.buffer.rawValue + + // Basic verification that the recorder processes events without crashing + #expect(!buffer.isEmpty) + #expect(buffer.contains("HIERARCHICAL TEST RESULTS")) + } +} + +// MARK: - Test Suites for Testing + +@Suite(.hidden) +struct HierarchicalTestSuite { + @Test(.hidden) + func passingTest() { + #expect(Bool(true)) + } + + @Test(.hidden) + func anotherPassingTest() { + #expect(1 + 1 == 2) + } + + @Suite(.hidden) + struct NestedSuite { + @Test(.hidden) + func nestedTest() { + #expect("hello".count == 5) + } + } +} + +@Suite(.hidden) +struct FailingTestSuite { + @Test(.hidden) + func failingTest() { + #expect(Bool(false), "This test is designed to fail") + } + + @Test(.hidden) + func passingTest() { + #expect(Bool(true)) + } +} + +@Suite(.hidden) +struct MixedTestSuite { + @Test(.hidden) + func test1() { + #expect(Bool(true)) + } + + @Test(.hidden) + func test2() { + #expect(Bool(false), "Intentional failure") + } + + @Test(.hidden) + func test3() { + #expect(1 == 1) + } +} + +@Suite(.hidden) +struct SimpleTestSuite { + @Test(.hidden) + func simpleTest() { + #expect(Bool(true)) + } +} +#endif diff --git a/Tests/TestingTests/AttachmentTests.swift b/Tests/TestingTests/AttachmentTests.swift index be940371e..854ecd133 100644 --- a/Tests/TestingTests/AttachmentTests.swift +++ b/Tests/TestingTests/AttachmentTests.swift @@ -1,7 +1,7 @@ // // 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 @@ -10,17 +10,33 @@ @testable @_spi(ForToolsIntegrationOnly) import Testing private import _TestingInternals -#if canImport(Foundation) +#if canImport(AppKit) && canImport(_Testing_AppKit) +import AppKit +import _Testing_AppKit +#endif +#if canImport(Foundation) && canImport(_Testing_Foundation) import Foundation import _Testing_Foundation #endif -#if canImport(CoreGraphics) +#if canImport(CoreGraphics) && canImport(_Testing_CoreGraphics) import CoreGraphics @_spi(Experimental) import _Testing_CoreGraphics #endif +#if canImport(CoreImage) && canImport(_Testing_CoreImage) +import CoreImage +import _Testing_CoreImage +#endif +#if canImport(UIKit) && canImport(_Testing_UIKit) +import UIKit +import _Testing_UIKit +#endif #if canImport(UniformTypeIdentifiers) import UniformTypeIdentifiers #endif +#if canImport(WinSDK) && canImport(_Testing_WinSDK) +import WinSDK +@testable @_spi(Experimental) import _Testing_WinSDK +#endif @Suite("Attachment Tests") struct AttachmentTests { @@ -37,12 +53,22 @@ struct AttachmentTests { #expect(attachment.description.contains("MySendableAttachable(")) } +#if compiler(>=6.3) || !os(Windows) // WORKAROUND: swift-#84184 @Test func moveOnlyDescription() { let attachableValue = MyAttachable(string: "") let attachment = Attachment(attachableValue, named: "AttachmentTests.saveValue.html") #expect(attachment.description.contains(#""\#(attachment.preferredName)""#)) #expect(attachment.description.contains("'MyAttachable'")) } +#endif + + @Test func preferredNameOfStringAttachment() { + let attachment1 = Attachment("", named: "abc123") + #expect(attachment1.preferredName == "abc123.txt") + + let attachment2 = Attachment("", named: "abc123.html") + #expect(attachment2.preferredName == "abc123.html") + } #if !SWT_NO_FILE_IO func compare(_ attachableValue: borrowing MySendableAttachable, toContentsOfFileAtPath filePath: String) throws { @@ -159,16 +185,12 @@ struct AttachmentTests { } valueAttached() - // BUG: We could use #expect(throws: Never.self) here, but the Swift 6.1 - // compiler crashes trying to expand the macro (rdar://138997009) - do { + #expect(throws: Never.self) { let filePath = try #require(attachment.fileSystemPath) defer { remove(filePath) } try compare(attachableValue, toContentsOfFileAtPath: filePath) - } catch { - Issue.record(error) } } @@ -209,7 +231,7 @@ struct AttachmentTests { return } - #expect(attachment.attachableValue is MySendableAttachable) + #expect((attachment.attachableValue as Any) is AnyAttachable.Wrapped) #expect(attachment.sourceLocation.fileID == #fileID) valueAttached() } @@ -247,7 +269,7 @@ struct AttachmentTests { } } -#if canImport(Foundation) +#if canImport(Foundation) && canImport(_Testing_Foundation) #if !SWT_NO_FILE_IO @Test func attachContentsOfFileURL() async throws { let data = try #require("".data(using: .utf8)) @@ -469,7 +491,7 @@ extension AttachmentTests { try test(value) } -#if canImport(Foundation) +#if canImport(Foundation) && canImport(_Testing_Foundation) @Test func data() throws { let value = try #require("abc123".data(using: .utf8)) try test(value) @@ -487,7 +509,7 @@ extension AttachmentTests { case couldNotCreateCGImage } -#if canImport(CoreGraphics) +#if canImport(CoreGraphics) && canImport(_Testing_CoreGraphics) static let cgImage = Result { let size = CGSize(width: 32.0, height: 32.0) let rgb = CGColorSpaceCreateDeviceRGB() @@ -537,11 +559,29 @@ extension AttachmentTests { Attachment.record(attachment) } + @available(_uttypesAPI, *) + @Test func attachCGImageDirectly() async throws { + await confirmation("Attachment detected") { valueAttached in + var configuration = Configuration() + configuration.eventHandler = { event, _ in + if case .valueAttached = event.kind { + valueAttached() + } + } + + await Test { + let image = try Self.cgImage.get() + Attachment.record(image, named: "diamond.jpg") + }.run(configuration: configuration) + } + } + @available(_uttypesAPI, *) @Test(arguments: [Float(0.0).nextUp, 0.25, 0.5, 0.75, 1.0], [.png as UTType?, .jpeg, .gif, .image, nil]) func attachCGImage(quality: Float, type: UTType?) throws { let image = try Self.cgImage.get() - let attachment = Attachment(image, named: "diamond", as: type, encodingQuality: quality) + let format = type.map { AttachableImageFormat(contentType: $0, encodingQuality: quality) } + let attachment = Attachment(image, named: "diamond", as: format) #expect(attachment.attachableValue === image) try attachment.attachableValue.withUnsafeBytes(for: attachment) { buffer in #expect(buffer.count > 32) @@ -551,15 +591,343 @@ extension AttachmentTests { } } + @available(_uttypesAPI, *) + @Test(arguments: [AttachableImageFormat.png, .jpeg, .jpeg(withEncodingQuality: 0.5), .init(contentType: .tiff)]) + func attachCGImage(format: AttachableImageFormat) throws { + let image = try Self.cgImage.get() + let attachment = Attachment(image, named: "diamond", as: format) + #expect(attachment.attachableValue === image) + try attachment.attachableValue.withUnsafeBytes(for: attachment) { buffer in + #expect(buffer.count > 32) + } + if let ext = format.contentType.preferredFilenameExtension { + #expect(attachment.preferredName == ("diamond" as NSString).appendingPathExtension(ext)) + } + } + + @available(_uttypesAPI, *) + @Test func attachCGImageWithCustomUTType() throws { + let contentType = try #require(UTType(tag: "derived-from-jpeg", tagClass: .filenameExtension, conformingTo: .jpeg)) + let format = AttachableImageFormat(contentType: contentType) + let image = try Self.cgImage.get() + let attachment = Attachment(image, named: "diamond", as: format) + #expect(attachment.attachableValue === image) + try attachment.attachableValue.withUnsafeBytes(for: attachment) { buffer in + #expect(buffer.count > 32) + } + if let ext = format.contentType.preferredFilenameExtension { + #expect(attachment.preferredName == ("diamond" as NSString).appendingPathExtension(ext)) + } + } + + @available(_uttypesAPI, *) + @Test func attachCGImageWithUnsupportedImageType() throws { + let contentType = try #require(UTType(tag: "unsupported-image-format", tagClass: .filenameExtension, conformingTo: .image)) + let format = AttachableImageFormat(contentType: contentType) + let image = try Self.cgImage.get() + let attachment = Attachment(image, named: "diamond", as: format) + #expect(attachment.attachableValue === image) + #expect(throws: ImageAttachmentError.self) { + try attachment.attachableValue.withUnsafeBytes(for: attachment) { _ in } + } + } + #if !SWT_NO_EXIT_TESTS @available(_uttypesAPI, *) @Test func cannotAttachCGImageWithNonImageType() async { await #expect(processExitsWith: .failure) { - let attachment = Attachment(try Self.cgImage.get(), named: "diamond", as: .mp3) + let format = AttachableImageFormat(contentType: .mp3) + let attachment = Attachment(try Self.cgImage.get(), named: "diamond", as: format) try attachment.attachableValue.withUnsafeBytes(for: attachment) { _ in } } } #endif + +#if canImport(CoreImage) && canImport(_Testing_CoreImage) + @available(_uttypesAPI, *) + @Test func attachCIImage() throws { + let image = CIImage(cgImage: try Self.cgImage.get()) + let attachment = Attachment(image, named: "diamond.jpg") + #expect(attachment.attachableValue === image) + try attachment.attachableValue.withUnsafeBytes(for: attachment) { buffer in + #expect(buffer.count > 32) + } + } +#endif + +#if canImport(AppKit) && canImport(_Testing_AppKit) + static var nsImage: NSImage { + get throws { + let cgImage = try cgImage.get() + let size = CGSize(width: CGFloat(cgImage.width), height: CGFloat(cgImage.height)) + return NSImage(cgImage: cgImage, size: size) + } + } + + @available(_uttypesAPI, *) + @Test func attachNSImage() throws { + let image = try Self.nsImage + let attachment = Attachment(image, named: "diamond.jpg") + #expect(attachment.attachableValue.size == image.size) // NSImage makes a copy + try attachment.attachableValue.withUnsafeBytes(for: attachment) { buffer in + #expect(buffer.count > 32) + } + } + + @available(_uttypesAPI, *) + @Test func attachNSImageWithCustomRep() throws { + let image = NSImage(size: NSSize(width: 32.0, height: 32.0), flipped: false) { rect in + NSColor.red.setFill() + rect.fill() + return true + } + let attachment = Attachment(image, named: "diamond.jpg") + #expect(attachment.attachableValue.size == image.size) // NSImage makes a copy + try attachment.attachableValue.withUnsafeBytes(for: attachment) { buffer in + #expect(buffer.count > 32) + } + } + + @available(_uttypesAPI, *) + @Test func attachNSImageWithSubclassedNSImage() throws { + let image = MyImage(size: NSSize(width: 32.0, height: 32.0)) + image.addRepresentation(NSCustomImageRep(size: image.size, flipped: false) { rect in + NSColor.green.setFill() + rect.fill() + return true + }) + + let attachment = Attachment(image, named: "diamond.jpg") + #expect(attachment.attachableValue === image) + #expect(attachment.attachableValue.size == image.size) // NSImage makes a copy + try attachment.attachableValue.withUnsafeBytes(for: attachment) { buffer in + #expect(buffer.count > 32) + } + } + + @available(_uttypesAPI, *) + @Test func attachNSImageWithSubclassedRep() throws { + let image = NSImage(size: NSSize(width: 32.0, height: 32.0)) + image.addRepresentation(MyImageRep()) + + let attachment = Attachment(image, named: "diamond.jpg") + #expect(attachment.attachableValue.size == image.size) // NSImage makes a copy + let firstRep = try #require(attachment.attachableValue.representations.first) + #expect(!(firstRep is MyImageRep)) + try attachment.attachableValue.withUnsafeBytes(for: attachment) { buffer in + #expect(buffer.count > 32) + } + } +#endif + +#if canImport(UIKit) && canImport(_Testing_UIKit) + @available(_uttypesAPI, *) + @Test func attachUIImage() throws { + let image = UIImage(cgImage: try Self.cgImage.get()) + let attachment = Attachment(image, named: "diamond.jpg") + #expect(attachment.attachableValue === image) + try attachment.attachableValue.withUnsafeBytes(for: attachment) { buffer in + #expect(buffer.count > 32) + } + Attachment.record(attachment) + } +#endif +#endif + +#if canImport(WinSDK) && canImport(_Testing_WinSDK) + private func copyHICON() throws -> HICON { + try #require(LoadIconA(nil, swt_IDI_SHIELD())) + } + + @MainActor @Test func attachHICON() throws { + let icon = try copyHICON() + defer { + DestroyIcon(icon) + } + + let attachment = Attachment(icon, named: "diamond.jpeg") + try attachment.withUnsafeBytes { buffer in + #expect(buffer.count > 32) + } + } + + private func copyHBITMAP() throws -> HBITMAP { + let (width, height) = (GetSystemMetrics(SM_CXICON), GetSystemMetrics(SM_CYICON)) + + let icon = try copyHICON() + defer { + DestroyIcon(icon) + } + + let screenDC = try #require(GetDC(nil)) + defer { + ReleaseDC(nil, screenDC) + } + + let dc = try #require(CreateCompatibleDC(nil)) + defer { + DeleteDC(dc) + } + + let bitmap = try #require(CreateCompatibleBitmap(screenDC, width, height)) + let oldSelectedObject = SelectObject(dc, bitmap) + defer { + _ = SelectObject(dc, oldSelectedObject) + } + DrawIcon(dc, 0, 0, icon) + + return bitmap + } + + @MainActor @Test func attachHBITMAP() throws { + let bitmap = try copyHBITMAP() + defer { + DeleteObject(bitmap) + } + + let attachment = Attachment(bitmap, named: "diamond.png") + try attachment.withUnsafeBytes { buffer in + #expect(buffer.count > 32) + } + Attachment.record(attachment) + } + + @MainActor @Test func attachHBITMAPAsJPEG() throws { + let bitmap = try copyHBITMAP() + defer { + DeleteObject(bitmap) + } + let hiFi = Attachment(bitmap, named: "hifi", as: .jpeg(withEncodingQuality: 1.0)) + let loFi = Attachment(bitmap, named: "lofi", as: .jpeg(withEncodingQuality: 0.1)) + + try hiFi.withUnsafeBytes { hiFi in + try loFi.withUnsafeBytes { loFi in + #expect(hiFi.count > loFi.count) + } + } + Attachment.record(loFi) + } + + private func copyIWICBitmap() throws -> UnsafeMutablePointer { + let factory = try IWICImagingFactory.create() + defer { + _ = factory.pointee.lpVtbl.pointee.Release(factory) + } + + let bitmap = try copyHBITMAP() + defer { + DeleteObject(bitmap) + } + + var wicBitmap: UnsafeMutablePointer? + let rCreate = factory.pointee.lpVtbl.pointee.CreateBitmapFromHBITMAP(factory, bitmap, nil, WICBitmapUsePremultipliedAlpha, &wicBitmap) + guard rCreate == S_OK, let wicBitmap else { + throw ImageAttachmentError.comObjectCreationFailed(IWICBitmap.self, rCreate) + } + return wicBitmap + } + + @MainActor @Test func attachIWICBitmap() throws { + let wicBitmap = try copyIWICBitmap() + defer { + _ = wicBitmap.pointee.lpVtbl.pointee.Release(wicBitmap) + } + + let attachment = Attachment(wicBitmap, named: "diamond.png") + try attachment.withUnsafeBytes { buffer in + #expect(buffer.count > 32) + } + Attachment.record(attachment) + } + + @MainActor @Test func attachIWICBitmapSource() throws { + let wicBitmapSource = try copyIWICBitmap().cast(to: IWICBitmapSource.self) + defer { + _ = wicBitmapSource.pointee.lpVtbl.pointee.Release(wicBitmapSource) + } + + let attachment = Attachment(wicBitmapSource, named: "diamond.png") + try attachment.withUnsafeBytes { buffer in + #expect(buffer.count > 32) + } + Attachment.record(attachment) + } + + @MainActor @Test func pathExtensionAndCLSID() { + let pngCLSID = AttachableImageFormat.png.encoderCLSID + let pngFilename = AttachableImageFormat.appendPathExtension(for: pngCLSID, to: "example") + #expect(pngFilename == "example.png") + + let jpegCLSID = AttachableImageFormat.jpeg.encoderCLSID + let jpegFilename = AttachableImageFormat.appendPathExtension(for: jpegCLSID, to: "example") + #expect(jpegFilename == "example.jpeg") + + let pngjpegFilename = AttachableImageFormat.appendPathExtension(for: jpegCLSID, to: "example.png") + #expect(pngjpegFilename == "example.png.jpeg") + + let jpgjpegFilename = AttachableImageFormat.appendPathExtension(for: jpegCLSID, to: "example.jpg") + #expect(jpgjpegFilename == "example.jpg") + } +#endif + +#if (canImport(CoreGraphics) && canImport(_Testing_CoreGraphics)) || (canImport(WinSDK) && canImport(_Testing_WinSDK)) + @available(_uttypesAPI, *) + @Test func imageFormatFromPathExtension() { + let format = AttachableImageFormat(pathExtension: "png") + #expect(format != nil) + #expect(format == .png) + + let badFormat = AttachableImageFormat(pathExtension: "no-such-image-format") + #expect(badFormat == nil) + } + + @available(_uttypesAPI, *) + @Test func imageFormatEquatableConformance() { + let format1 = AttachableImageFormat.png + let format2 = AttachableImageFormat.jpeg +#if canImport(CoreGraphics) && canImport(_Testing_CoreGraphics) + let format3 = AttachableImageFormat(contentType: .tiff) +#elseif canImport(WinSDK) && canImport(_Testing_WinSDK) + let format3 = AttachableImageFormat(encoderCLSID: CLSID_WICTiffEncoder) +#endif + #expect(format1 == format1) + #expect(format2 == format2) + #expect(format3 == format3) + #expect(format1 != format2) + #expect(format2 != format3) + #expect(format1 != format3) + + #expect(format1.hashValue == format1.hashValue) + #expect(format2.hashValue == format2.hashValue) + #expect(format3.hashValue == format3.hashValue) + #expect(format1.hashValue != format2.hashValue) + #expect(format2.hashValue != format3.hashValue) + #expect(format1.hashValue != format3.hashValue) + } + + @available(_uttypesAPI, *) + @Test func imageFormatStringification() { + let format: AttachableImageFormat = AttachableImageFormat.png +#if canImport(CoreGraphics) && canImport(_Testing_CoreGraphics) + #expect(String(describing: format) == UTType.png.localizedDescription!) + #expect(String(reflecting: format) == "\(UTType.png.localizedDescription!) (\(UTType.png.identifier)) at quality 1.0") +#elseif canImport(WinSDK) && canImport(_Testing_WinSDK) + #expect(String(describing: format) == "PNG format") + #expect(String(reflecting: format) == "PNG format (27949969-876a-41d7-9447-568f6a35a4dc) at quality 1.0") +#endif + } + + @available(_uttypesAPI, *) + @Test func imageFormatStringificationWithQuality() { + let format: AttachableImageFormat = AttachableImageFormat.jpeg(withEncodingQuality: 0.5) +#if canImport(CoreGraphics) && canImport(_Testing_CoreGraphics) + #expect(String(describing: format) == "\(UTType.jpeg.localizedDescription!) at 50% quality") + #expect(String(reflecting: format) == "\(UTType.jpeg.localizedDescription!) (\(UTType.jpeg.identifier)) at quality 0.5") +#elseif canImport(WinSDK) && canImport(_Testing_WinSDK) + #expect(String(describing: format) == "JPEG format at 50% quality") + #expect(String(reflecting: format) == "JPEG format (1a34f5c1-4a5a-46dc-b644-1f4567e7a676) at quality 0.5") +#endif + } #endif } } @@ -608,7 +976,7 @@ struct MySendableAttachableWithDefaultByteCount: Attachable, Sendable { } } -#if canImport(Foundation) +#if canImport(Foundation) && canImport(_Testing_Foundation) struct MyCodableAttachable: Codable, Attachable, Sendable { var string: String } @@ -649,3 +1017,42 @@ final class MyCodableAndSecureCodingAttachable: NSObject, Codable, NSSecureCodin } } #endif + +#if canImport(AppKit) && canImport(_Testing_AppKit) +private final class MyImage: NSImage { + override init(size: NSSize) { + super.init(size: size) + } + + required init(pasteboardPropertyList propertyList: Any, ofType type: NSPasteboard.PasteboardType) { + fatalError("Unimplemented") + } + + required init(coder: NSCoder) { + fatalError("Unimplemented") + } + + override func copy(with zone: NSZone?) -> Any { + // Intentionally make a copy as NSImage instead of MyImage to exercise the + // cast-failed code path in the overlay. + NSImage() + } +} + +private final class MyImageRep: NSImageRep { + override init() { + super.init() + size = NSSize(width: 32.0, height: 32.0) + } + + required init?(coder: NSCoder) { + fatalError("Unimplemented") + } + + override func draw() -> Bool { + NSColor.blue.setFill() + NSRect(origin: .zero, size: size).fill() + return true + } +} +#endif diff --git a/Tests/TestingTests/ConfirmationTests.swift b/Tests/TestingTests/ConfirmationTests.swift index c4f076268..454572edc 100644 --- a/Tests/TestingTests/ConfirmationTests.swift +++ b/Tests/TestingTests/ConfirmationTests.swift @@ -10,6 +10,10 @@ @testable @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing +#if canImport(Foundation) +private import Foundation +#endif + @Suite("Confirmation Tests") struct ConfirmationTests { @Test("Successful confirmations") @@ -164,8 +168,10 @@ struct UnsuccessfulConfirmationTests { // MARK: - /// Needed since we don't have generic test functions, so we need a concrete -/// argument type for `confirmedOutOfRange(_:)`, but we can't write -/// `any RangeExpression & Sendable`. ([96960993](rdar://96960993)) +/// argument type for `confirmedOutOfRange(_:)`. Although we can now write +/// `any RangeExpression & Sequence & Sendable` as of Swift 6.2 +/// (per [swiftlang/swift#76705](https://github.com/swiftlang/swift/pull/76705)), +/// attempting to form an array of such values crashes at runtime. ([163980446](rdar://163980446)) protocol ExpectedCount: RangeExpression, Sequence, Sendable where Bound == Int, Element == Int {} extension ClosedRange: ExpectedCount {} extension PartialRangeFrom: ExpectedCount {} diff --git a/Tests/TestingTests/EntryPointTests.swift b/Tests/TestingTests/EntryPointTests.swift index eae7d4b7e..5f77c281c 100644 --- a/Tests/TestingTests/EntryPointTests.swift +++ b/Tests/TestingTests/EntryPointTests.swift @@ -18,7 +18,7 @@ struct EntryPointTests { var arguments = __CommandLineArguments_v0() arguments.filter = ["_someHiddenTest"] arguments.includeHiddenTests = true - arguments.eventStreamVersion = 0 + arguments.eventStreamSchemaVersion = "0" arguments.verbosity = .min await confirmation("Test event started", expectedCount: 1) { testMatched in @@ -30,12 +30,13 @@ struct EntryPointTests { } } - @Test("Entry point with WarningIssues feature enabled exits with success if all issues have severity < .error") - func warningIssues() async throws { + @Test("Entry point using event stream version 0 exits with success if all issues have severity < .error") + func warningIssuesDisabled() async throws { var arguments = __CommandLineArguments_v0() arguments.filter = ["_recordWarningIssue"] arguments.includeHiddenTests = true - arguments.eventStreamVersion = 0 + // WarningIssues is available >= 6.3 + arguments.eventStreamSchemaVersion = "0" arguments.verbosity = .min let exitCode = await confirmation("Test matched", expectedCount: 1) { testMatched in @@ -50,13 +51,13 @@ struct EntryPointTests { #expect(exitCode == EXIT_SUCCESS) } - @Test("Entry point with WarningIssues feature enabled propagates warning issues and exits with success if all issues have severity < .error") + + @Test("Entry point using event stream version 6.3 propagates warning issues and exits with success if all issues have severity < .error") func warningIssuesEnabled() async throws { var arguments = __CommandLineArguments_v0() arguments.filter = ["_recordWarningIssue"] arguments.includeHiddenTests = true - arguments.eventStreamVersion = 0 - arguments.isWarningIssueRecordedEventEnabled = true + arguments.eventStreamSchemaVersion = "6.3" arguments.verbosity = .min let exitCode = await confirmation("Warning issue recorded", expectedCount: 1) { issueRecorded in diff --git a/Tests/TestingTests/EventRecorderTests.swift b/Tests/TestingTests/EventRecorderTests.swift index 18f70186a..d489b2f1e 100644 --- a/Tests/TestingTests/EventRecorderTests.swift +++ b/Tests/TestingTests/EventRecorderTests.swift @@ -94,6 +94,7 @@ struct EventRecorderTests { } @Test("Verbose output") + @available(_regexAPI, *) func verboseOutput() async throws { let stream = Stream() @@ -112,6 +113,18 @@ struct EventRecorderTests { #expect(buffer.contains(#"\#(Event.Symbol.details.unicodeCharacter) lhs: Swift.String β†’ "987""#)) #expect(buffer.contains(#""Animal Crackers" (aka 'WrittenTests')"#)) #expect(buffer.contains(#""Not A Lobster" (aka 'actuallyCrab()')"#)) + do { + let regex = try Regex(".* Test case passing 1 argument i β†’ 0 \\(Swift.Int\\) to multitudeOcelot\\(i:\\) started.") + #expect(try buffer.split(whereSeparator: \.isNewline).compactMap(regex.wholeMatch(in:)).first != nil) + } + do { + let regex = try Regex(".* Test case passing 1 argument i β†’ 0 \\(Swift.Int\\) to multitudeOcelot\\(i:\\) passed after .*.") + #expect(try buffer.split(whereSeparator: \.isNewline).compactMap(regex.wholeMatch(in:)).first != nil) + } + do { + let regex = try Regex(".* Test case passing 1 argument i β†’ 3 \\(Swift.Int\\) to multitudeOcelot\\(i:\\) failed after .* with 1 issue.") + #expect(try buffer.split(whereSeparator: \.isNewline).compactMap(regex.wholeMatch(in:)).first != nil) + } if testsWithSignificantIOAreEnabled { print(buffer, terminator: "") @@ -203,17 +216,15 @@ struct EventRecorderTests { await runTest(for: PredictablyFailingTests.self, configuration: configuration) let buffer = stream.buffer.rawValue - if testsWithSignificantIOAreEnabled { - print(buffer, terminator: "") - } - let aurgmentRegex = try Regex(expectedPattern) + let argumentRegex = try Regex(expectedPattern) #expect( (try buffer .split(whereSeparator: \.isNewline) - .compactMap(aurgmentRegex.wholeMatch(in:)) - .first) != nil + .compactMap(argumentRegex.wholeMatch(in:)) + .first) != nil, + "buffer: \(buffer)" ) } @@ -322,20 +333,32 @@ struct EventRecorderTests { print(buffer, terminator: "") } + let testCount = Reference() + let suiteCount = Reference() + let issueCount = Reference() + let warningCount = Reference() + let knownIssueCount = Reference() + let runFailureRegex = Regex { One(.anyGraphemeCluster) " Test run with " - OneOrMore(.digit) + Capture(as: testCount) { OneOrMore(.digit) } transform: { Int($0) } " test" Optionally("s") + " in " + Capture(as: suiteCount) { OneOrMore(.digit) } transform: { Int($0) } + " suite" + Optionally("s") " failed " ZeroOrMore(.any) " with " - Capture { OneOrMore(.digit) } transform: { Int($0) } + Capture(as: issueCount) { OneOrMore(.digit) } transform: { Int($0) } " issue" Optionally("s") " (including " - Capture { OneOrMore(.digit) } transform: { Int($0) } + Capture(as: warningCount) { OneOrMore(.digit) } transform: { Int($0) } + " warnings and " + Capture(as: knownIssueCount) { OneOrMore(.digit) } transform: { Int($0) } " known issue" Optionally("s") ")." @@ -346,8 +369,11 @@ struct EventRecorderTests { .compactMap(runFailureRegex.wholeMatch(in:)) .first ) - #expect(match.output.1 == 12) - #expect(match.output.2 == 5) + #expect(match[testCount] == 9) + #expect(match[suiteCount] == 2) + #expect(match[issueCount] == 16) + #expect(match[warningCount] == 3) + #expect(match[knownIssueCount] == 6) } @Test("Issue counts are summed correctly on run end for a test with only warning issues") @@ -369,16 +395,24 @@ struct EventRecorderTests { print(buffer, terminator: "") } + let testCount = Reference() + let suiteCount = Reference() + let warningCount = Reference() + let runFailureRegex = Regex { One(.anyGraphemeCluster) " Test run with " - OneOrMore(.digit) + Capture(as: testCount) { OneOrMore(.digit) } transform: { Int($0) } " test" Optionally("s") + " in " + Capture(as: suiteCount) { OneOrMore(.digit) } transform: { Int($0) } + " suite" + Optionally("s") " passed " ZeroOrMore(.any) " with " - Capture { OneOrMore(.digit) } transform: { Int($0) } + Capture(as: warningCount) { OneOrMore(.digit) } transform: { Int($0) } " warning" Optionally("s") "." @@ -390,7 +424,9 @@ struct EventRecorderTests { .first, "buffer: \(buffer)" ) - #expect(match.output.1 == 1) + #expect(match[testCount] == 1) + #expect(match[suiteCount] == 1) + #expect(match[warningCount] == 1) } #endif @@ -691,6 +727,8 @@ struct EventRecorderTests { func n(_ arg: Int) { #expect(arg > 0) } + + @Suite struct PredictableSubsuite {} } @Suite(.hidden) struct PredictablyFailingKnownIssueTests { diff --git a/Tests/TestingTests/ExitTestTests.swift b/Tests/TestingTests/ExitTestTests.swift index 02be1a140..6edabc305 100644 --- a/Tests/TestingTests/ExitTestTests.swift +++ b/Tests/TestingTests/ExitTestTests.swift @@ -8,11 +8,29 @@ // 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 !SWT_NO_EXIT_TESTS @Suite("Exit test tests") struct ExitTestTests { + @Test("Signal names are reported (where supported)") func signalName() { + var hasSignalNames = false +#if SWT_TARGET_OS_APPLE || os(FreeBSD) || os(OpenBSD) || os(Android) +#if !SWT_NO_SYS_SIGNAME + hasSignalNames = true +#endif +#elseif os(Linux) && !SWT_NO_DYNAMIC_LINKING + hasSignalNames = (symbol(named: "sigabbrev_np") != nil) +#endif + + let exitStatus = ExitStatus.signal(SIGABRT) + if Bool(hasSignalNames) { + #expect(String(describing: exitStatus) == ".signal(SIGABRT β†’ \(SIGABRT))") + } else { + #expect(String(describing: exitStatus) == ".signal(\(SIGABRT))") + } + } + @Test("Exit tests (passing)") func passing() async { await #expect(processExitsWith: .failure) { exit(EXIT_FAILURE) @@ -198,6 +216,33 @@ private import _TestingInternals } } + private static let attachmentPayload = [UInt8](0...255) + + @Test("Exit test forwards attachments") func forwardsAttachments() async { + await confirmation("Value attached") { valueAttached in + var configuration = Configuration() + configuration.eventHandler = { event, _ in + guard case let .valueAttached(attachment) = event.kind else { + return + } + #expect(throws: Never.self) { + try attachment.withUnsafeBytes { bytes in + #expect(Array(bytes) == Self.attachmentPayload) + } + } + #expect(attachment.preferredName == "my attachment.bytes") + valueAttached() + } + configuration.exitTestHandler = ExitTest.handlerForEntryPoint() + + await Test { + await #expect(processExitsWith: .success) { + Attachment.record(Self.attachmentPayload, named: "my attachment.bytes") + } + }.run(configuration: configuration) + } + } + #if !os(Linux) @Test("Exit test reports > 8 bits of the exit code") func fullWidthExitCode() async { @@ -306,7 +351,7 @@ private import _TestingInternals await Test { try await #require(processExitsWith: .success) {} - fatalError("Unreachable") + Issue.record("#require(processExitsWith:) should have thrown an error") }.run(configuration: configuration) } } @@ -350,6 +395,7 @@ private import _TestingInternals } #expect(result.exitStatus == .exitCode(EXIT_SUCCESS)) #expect(result.standardOutputContent.contains("STANDARD OUTPUT".utf8)) + #expect(!result.standardOutputContent.contains(ExitTest.barrierValue)) #expect(result.standardErrorContent.isEmpty) result = try await #require(processExitsWith: .success, observing: [\.standardErrorContent]) { @@ -360,6 +406,7 @@ private import _TestingInternals #expect(result.exitStatus == .exitCode(EXIT_SUCCESS)) #expect(result.standardOutputContent.isEmpty) #expect(result.standardErrorContent.contains("STANDARD ERROR".utf8.reversed())) + #expect(!result.standardErrorContent.contains(ExitTest.barrierValue)) } @Test("Arguments to the macro are not captured during expansion (do not need to be literals/const)") @@ -381,7 +428,26 @@ private import _TestingInternals } } -#if ExperimentalExitTestValueCapture + @Test("Issue severity") + func issueSeverity() async { + await confirmation("Recorded issue had warning severity") { wasWarning in + var configuration = Configuration() + configuration.eventHandler = { event, _ in + if case let .issueRecorded(issue) = event.kind, issue.severity == .warning { + wasWarning() + } + } + + // Mock an exit test where the process exits successfully. + configuration.exitTestHandler = ExitTest.handlerForEntryPoint() + await Test { + await #expect(processExitsWith: .success) { + Issue.record("Issue recorded", severity: .warning) + } + }.run(configuration: configuration) + } + } + @Test("Capture list") func captureList() async { let i = 123 @@ -407,9 +473,10 @@ private import _TestingInternals @Test("self in capture list") func captureListWithSelf() async { - await #expect(processExitsWith: .success) { [self, x = self] in + await #expect(processExitsWith: .success) { [self, x = self, y = self as Self] in #expect(self.property == 456) #expect(x.property == 456) + #expect(y.property == 456) } } } @@ -456,6 +523,115 @@ private import _TestingInternals #expect(instance.x == 123) } } + + @Test("Capturing a parameter to the test function") + func captureListWithParameter() async { + let i = Int.random(in: 0 ..< 1000) + + func f(j: Int) async { + await #expect(processExitsWith: .success) { [i = i as Int, j] in + #expect(i == j) + #expect(j >= 0) + #expect(j < 1000) + } + } + await f(j: i) + + await { (j: Int) in + _ = await #expect(processExitsWith: .success) { [i = i as Int, j] in + #expect(i == j) + #expect(j >= 0) + #expect(j < 1000) + } + }(i) + +#if false // intentionally fails to compile + // FAILS TO COMPILE: shadowing `i` with a variable of a different type will + // prevent correct expansion (we need an equivalent of decltype() for that.) + func g(i: Int) async { + let i = String(i) + await #expect(processExitsWith: .success) { [i] in + #expect(!i.isEmpty) + } + } +#endif + } + + @Test("Capturing a literal expression") + func captureListWithLiterals() async { + await #expect(processExitsWith: .success) { [i = 0, f = 1.0, s = "", b = true] in + #expect(i == 0) + #expect(f == 1.0) + #expect(s == "") + #expect(b == true) + } + } + + @Test("Capturing #_sourceLocation") + func captureListPreservesSourceLocationMacro() async { + func sl(_ sl: SourceLocation = #_sourceLocation) -> SourceLocation { + sl + } + await #expect(processExitsWith: .success) { [sl = sl() as SourceLocation] in + #expect(sl.fileID == #fileID) + } + } + + @Test("Capturing an optional value") + func captureListWithOptionalValue() async throws { + await #expect(processExitsWith: .success) { [x = nil as Int?] in + #expect(x != 1) + } + await #expect(processExitsWith: .success) { [x = (0 as Any) as? String] in + #expect(x == nil) + } + } + + @Test("Capturing an effectful expression") + func captureListWithEffectfulExpression() async throws { + func f() async throws -> Int { 0 } + try await #require(processExitsWith: .success) { [f = try await f() as Int] in + #expect(f == 0) + } + try await #expect(processExitsWith: .success) { [f = f() as Int] in + #expect(f == 0) + } + } + +#if false // intentionally fails to compile + @Test("Capturing a tuple") + func captureListWithTuple() async throws { + // A tuple whose elements conform to Codable does not itself conform to + // Codable, so we cannot actually express this capture list in a way that + // works with #expect(). + await #expect(processExitsWith: .success) { [x = (0 as Int, 1 as Double, "2" as String)] in + #expect(x.0 == 0) + #expect(x.1 == 1) + #expect(x.2 == "2") + } + } +#endif + +#if false // intentionally fails to compile + struct NonCodableValue {} + + // We can't capture a value that isn't Codable. A unit test is not possible + // for this case as the type checker needs to get involved. + @Test("Capturing a move-only value") + func captureListWithMoveOnlyValue() async { + let x = NonCodableValue() + await #expect(processExitsWith: .success) { [x = x as NonCodableValue] in + _ = x + } + } +#endif + +#if os(OpenBSD) + @Test("Changing the CWD doesn't break exit tests") + func changeCWD() async throws { + try #require(0 == chdir("/")) + await #expect(processExitsWith: .success) {} + } #endif } diff --git a/Tests/TestingTests/IssueTests.swift b/Tests/TestingTests/IssueTests.swift index cc0a7acf5..6abd384fe 100644 --- a/Tests/TestingTests/IssueTests.swift +++ b/Tests/TestingTests/IssueTests.swift @@ -484,14 +484,13 @@ final class IssueTests: XCTestCase { } func testCastAsAnyProtocol() async { - // Sanity check that we parse types cleanly. + // Check that we parse types cleanly. await Test { #expect((1 as Any) is any Numeric) _ = try #require((1 as Any) as? any Numeric) }.run(configuration: .init()) } -#if !SWT_TARGET_OS_APPLE || SWT_FIXED_149299786 func testErrorCheckingWithExpect() async throws { let expectationFailed = expectation(description: "Expectation failed") expectationFailed.isInverted = true @@ -611,7 +610,6 @@ final class IssueTests: XCTestCase { await fulfillment(of: [expectationFailed], timeout: 0.0) } -#endif func testErrorCheckingWithExpect_mismatchedErrorDescription() async throws { let expectationFailed = expectation(description: "Expectation failed") diff --git a/Tests/TestingTests/MiscellaneousTests.swift b/Tests/TestingTests/MiscellaneousTests.swift index b4b12a217..d47811f90 100644 --- a/Tests/TestingTests/MiscellaneousTests.swift +++ b/Tests/TestingTests/MiscellaneousTests.swift @@ -12,6 +12,10 @@ @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import _TestDiscovery private import _TestingInternals +#if canImport(Foundation) +private import Foundation +#endif + @Test(/* name unspecified */ .hidden) @Sendable func freeSyncFunction() {} @Sendable func freeAsyncFunction() async {} @@ -293,13 +297,27 @@ struct MiscellaneousTests { #expect(testType.displayName == "Named Sendable test type") } - @Test func `__raw__$raw_identifier_provides_a_display_name`() throws { + @Test func `Test with raw identifier gets a display name`() throws { let test = try #require(Test.current) - #expect(test.displayName == "raw_identifier_provides_a_display_name") - #expect(test.name == "`raw_identifier_provides_a_display_name`()") + #expect(test.displayName == "Test with raw identifier gets a display name") + #expect(test.name == "`Test with raw identifier gets a display name`()") let id = test.id #expect(id.moduleName == "TestingTests") - #expect(id.nameComponents == ["MiscellaneousTests", "`raw_identifier_provides_a_display_name`()"]) + #expect(id.nameComponents == ["MiscellaneousTests", "`Test with raw identifier gets a display name`()"]) + } + + @Test func `Suite type with raw identifier gets a display name`() throws { + struct `Suite With De Facto Display Name` {} + let typeInfo = TypeInfo(describing: `Suite With De Facto Display Name`.self) + let suite = Test(traits: [], sourceLocation: #_sourceLocation, containingTypeInfo: typeInfo, isSynthesized: true) + #expect(suite.name == "`Suite With De Facto Display Name`") + let displayName = try #require(suite.displayName) + #expect(displayName == "Suite With De Facto Display Name") + } + + @Test(arguments: [0]) + func `Test with raw identifier and raw identifier parameter labels can compile`(`argument name` i: Int) { + #expect(i == 0) } @Test("Free functions are runnable") @@ -542,7 +560,7 @@ struct MiscellaneousTests { let line = 12345 let column = 67890 let sourceLocation = SourceLocation(fileID: fileID, filePath: filePath, line: line, column: column) - let testFunction = Test.__function(named: "myTestFunction()", in: nil, xcTestCompatibleSelector: nil, displayName: nil, traits: [], sourceLocation: sourceLocation) {} + let testFunction = Test.__function(named: "myTestFunction()", in: nil as Never.Type?, xcTestCompatibleSelector: nil, displayName: nil, traits: [], sourceLocation: sourceLocation) {} #expect(String(describing: testFunction.id) == "Module.myTestFunction()/Y.swift:12345:67890") } diff --git a/Tests/TestingTests/ObjCInteropTests.swift b/Tests/TestingTests/ObjCInteropTests.swift index be12e520d..9c6e42a49 100644 --- a/Tests/TestingTests/ObjCInteropTests.swift +++ b/Tests/TestingTests/ObjCInteropTests.swift @@ -1,12 +1,12 @@ -/* - This source file is part of the Swift.org open source project - - Copyright (c) 2023 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 - */ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// #if canImport(XCTest) import XCTest diff --git a/Tests/TestingTests/PlanTests.swift b/Tests/TestingTests/PlanTests.swift index 0c57a7e03..1a8a8a8f1 100644 --- a/Tests/TestingTests/PlanTests.swift +++ b/Tests/TestingTests/PlanTests.swift @@ -369,7 +369,7 @@ struct PlanTests { #expect(!planTests.contains(testC)) } - @Test("Recursive trait application") + @Test("Recursive trait application", .tags(.traitRelated)) func recursiveTraitApplication() async throws { let outerTestType = try #require(await test(for: OuterTest.self)) // Intentionally omitting intermediate tests here... @@ -387,7 +387,7 @@ struct PlanTests { #expect(testWithTraitAdded.traits.contains { $0 is DummyRecursiveTrait }) } - @Test("Relative order of recursively applied traits") + @Test("Relative order of recursively applied traits", .tags(.traitRelated)) func recursiveTraitOrder() async throws { let testSuiteA = try #require(await test(for: RelativeTraitOrderingTests.A.self)) let testSuiteB = try #require(await test(for: RelativeTraitOrderingTests.A.B.self)) diff --git a/Tests/TestingTests/RunnerTests.swift b/Tests/TestingTests/RunnerTests.swift index 335f8be37..c254e5ba9 100644 --- a/Tests/TestingTests/RunnerTests.swift +++ b/Tests/TestingTests/RunnerTests.swift @@ -819,6 +819,29 @@ final class RunnerTests: XCTestCase { await fulfillment(of: [testStarted], timeout: 0.0) } + @Suite(.hidden) struct UnavailableInEmbeddedTests { + @Test(.hidden) + @_unavailableInEmbedded + func embedded() {} + } + + func testUnavailableInEmbeddedAttribute() async throws { + let testStarted = expectation(description: "Test started") +#if !hasFeature(Embedded) + testStarted.expectedFulfillmentCount = 3 +#else + testStarted.isInverted = true +#endif + var configuration = Configuration() + configuration.eventHandler = { event, _ in + if case .testStarted = event.kind { + testStarted.fulfill() + } + } + await runTest(for: UnavailableInEmbeddedTests.self, configuration: configuration) + await fulfillment(of: [testStarted], timeout: 0.0) + } + #if !SWT_NO_GLOBAL_ACTORS @TaskLocal static var isMainActorIsolationEnforced = false diff --git a/Tests/TestingTests/SourceLocationTests.swift b/Tests/TestingTests/SourceLocationTests.swift index 4145687b8..2f9dc9b7e 100644 --- a/Tests/TestingTests/SourceLocationTests.swift +++ b/Tests/TestingTests/SourceLocationTests.swift @@ -121,8 +121,18 @@ struct SourceLocationTests { } #endif - @Test("SourceLocation._filePath property") + @Test("SourceLocation.filePath property") func sourceLocationFilePath() { + var sourceLocation = #_sourceLocation + #expect(sourceLocation.filePath == #filePath) + + sourceLocation.filePath = "A" + #expect(sourceLocation.filePath == "A") + } + + @available(swift, deprecated: 100000.0) + @Test("SourceLocation._filePath property") + func sourceLocation_FilePath() { var sourceLocation = #_sourceLocation #expect(sourceLocation._filePath == #filePath) diff --git a/Tests/TestingTests/Support/CartesianProductTests.swift b/Tests/TestingTests/Support/CartesianProductTests.swift index b817b37f6..304fa329e 100644 --- a/Tests/TestingTests/Support/CartesianProductTests.swift +++ b/Tests/TestingTests/Support/CartesianProductTests.swift @@ -37,7 +37,7 @@ struct CartesianProductTests { @Test("First element is correct") func firstElement() throws { - // Sanity-check the first element is correct. (This value is also tested in + // Check that the first element is correct. (This value is also tested in // testCompleteEquality().) let (c1, c2, product) = computeCartesianProduct() let first = try #require(product.first(where: { _ in true })) @@ -96,7 +96,7 @@ struct CartesianProductTests { // Test that the product can be iterated multiple times concurrently. let (_, _, product) = computeCartesianProduct() let expectedSum = product.reduce(into: 0) { $0 &+= $1.1 } - await withTaskGroup(of: Int.self) { taskGroup in + await withTaskGroup { taskGroup in for _ in 0 ..< 10 { taskGroup.addTask { product.reduce(into: 0) { $0 &+= $1.1 } diff --git a/Tests/TestingTests/Support/FileHandleTests.swift b/Tests/TestingTests/Support/FileHandleTests.swift index 4be633ad6..fd52678d7 100644 --- a/Tests/TestingTests/Support/FileHandleTests.swift +++ b/Tests/TestingTests/Support/FileHandleTests.swift @@ -201,6 +201,17 @@ struct FileHandleTests { #endif } #endif + + @Test("Root directory path is correct") + func rootDirectoryPathIsCorrect() throws { +#if os(Windows) + if let systemDrive = Environment.variable(named: "SYSTEMDRIVE") { + #expect(rootDirectoryPath.starts(with: systemDrive)) + } +#else + #expect(rootDirectoryPath == "/") +#endif + } } // MARK: - Fixtures @@ -260,7 +271,7 @@ func temporaryDirectory() throws -> String { #elseif os(Android) Environment.variable(named: "TMPDIR") ?? "/data/local/tmp" #elseif os(Windows) - try withUnsafeTemporaryAllocation(of: wchar_t.self, capacity: Int(MAX_PATH + 1)) { buffer in + try withUnsafeTemporaryAllocation(of: CWideChar.self, capacity: Int(MAX_PATH + 1)) { buffer in // NOTE: GetTempPath2W() was introduced in Windows 10 Build 20348. if 0 == GetTempPathW(DWORD(buffer.count), buffer.baseAddress) { throw Win32Error(rawValue: GetLastError()) diff --git a/Tests/TestingTests/Support/LockTests.swift b/Tests/TestingTests/Support/LockTests.swift index 0113745e9..486143e1e 100644 --- a/Tests/TestingTests/Support/LockTests.swift +++ b/Tests/TestingTests/Support/LockTests.swift @@ -13,7 +13,9 @@ private import _TestingInternals @Suite("Locked Tests") struct LockTests { - func testLock(_ lock: LockedWith) { + @Test("Locking and unlocking") + func locking() { + let lock = Locked(rawValue: 0) #expect(lock.rawValue == 0) lock.withLock { value in value = 1 @@ -21,42 +23,16 @@ struct LockTests { #expect(lock.rawValue == 1) } - @Test("Platform-default lock") - func locking() { - testLock(Locked(rawValue: 0)) - } - -#if SWT_TARGET_OS_APPLE && !SWT_NO_OS_UNFAIR_LOCK - @Test("pthread_mutex_t (Darwin alternate)") - func lockingWith_pthread_mutex_t() { - testLock(LockedWith(rawValue: 0)) - } -#endif - - @Test("No lock") - func noLock() async { - let lock = LockedWith(rawValue: 0) - await withTaskGroup(of: Void.self) { taskGroup in + @Test("Repeatedly accessing a lock") + func lockRepeatedly() async { + let lock = Locked(rawValue: 0) + await withTaskGroup { taskGroup in for _ in 0 ..< 100_000 { taskGroup.addTask { lock.increment() } } } - #expect(lock.rawValue != 100_000) - } - - @Test("Get the underlying lock") - func underlyingLock() { - let lock = Locked(rawValue: 0) - testLock(lock) - lock.withUnsafeUnderlyingLock { underlyingLock, _ in - DefaultLock.unsafelyRelinquishLock(at: underlyingLock) - lock.withLock { value in - value += 1000 - } - DefaultLock.unsafelyAcquireLock(at: underlyingLock) - } - #expect(lock.rawValue == 1001) + #expect(lock.rawValue == 100_000) } } diff --git a/Tests/TestingTests/SwiftPMTests.swift b/Tests/TestingTests/SwiftPMTests.swift index 6e7be0f15..2d7001e8f 100644 --- a/Tests/TestingTests/SwiftPMTests.swift +++ b/Tests/TestingTests/SwiftPMTests.swift @@ -11,11 +11,27 @@ @testable @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing private import _TestingInternals +#if canImport(Foundation) +private import Foundation +#endif + private func configurationForEntryPoint(withArguments args: [String]) throws -> Configuration { let args = try parseCommandLineArguments(from: args) return try configurationForEntryPoint(from: args) } +/// Reads event stream output from the provided file matching event stream +/// version `V`. +private func decodedEventStreamRecords(fromPath filePath: String) throws -> [ABI.Record] { + try FileHandle(forReadingAtPath: filePath).readToEnd() + .split(whereSeparator: \.isASCIINewline) + .map { line in + try line.withUnsafeBytes { line in + return try JSON.decode(ABI.Record.self, from: line) + } + } +} + @Suite("Swift Package Manager Integration Tests") struct SwiftPMTests { @Test("Command line arguments are available") @@ -43,6 +59,25 @@ struct SwiftPMTests { #expect(!configuration.isParallelizationEnabled) } + @Test("--experimental-maximum-parallelization-width argument") + func maximumParallelizationWidth() throws { + var configuration = try configurationForEntryPoint(withArguments: ["PATH", "--experimental-maximum-parallelization-width", "12345"]) + #expect(configuration.isParallelizationEnabled) + #expect(configuration.maximumParallelizationWidth == 12345) + + configuration = try configurationForEntryPoint(withArguments: ["PATH", "--experimental-maximum-parallelization-width", "1"]) + #expect(!configuration.isParallelizationEnabled) + #expect(configuration.maximumParallelizationWidth == 1) + + configuration = try configurationForEntryPoint(withArguments: ["PATH", "--experimental-maximum-parallelization-width", "\(Int.max)"]) + #expect(configuration.isParallelizationEnabled) + #expect(configuration.maximumParallelizationWidth == .max) + + #expect(throws: (any Error).self) { + _ = try configurationForEntryPoint(withArguments: ["PATH", "--experimental-maximum-parallelization-width", "0"]) + } + } + @Test("--symbolicate-backtraces argument", arguments: [ (String?.none, Backtrace.SymbolicationMode?.none), @@ -129,7 +164,14 @@ struct SwiftPMTests { #expect(planTests.contains(test2)) } - @Test(".hidden trait") + @Test("--filter or --skip argument as last argument") + @available(_regexAPI, *) + func filterOrSkipAsLast() async throws { + _ = try configurationForEntryPoint(withArguments: ["PATH", "--filter"]) + _ = try configurationForEntryPoint(withArguments: ["PATH", "--skip"]) + } + + @Test(".hidden trait", .tags(.traitRelated)) func hidden() async throws { let configuration = try configurationForEntryPoint(withArguments: ["PATH"]) let test1 = Test(name: "hello") {} @@ -226,21 +268,105 @@ struct SwiftPMTests { #expect(args.parallel == false) } + @available(*, deprecated) + @Test("Deprecated eventStreamVersion property") + func deprecatedEventStreamVersionProperty() async throws { + var args = __CommandLineArguments_v0() + args.eventStreamVersion = 0 + #expect(args.eventStreamVersionNumber == VersionNumber(0, 0)) + #expect(args.eventStreamSchemaVersion == "0") + + args.eventStreamVersion = -1 + #expect(args.eventStreamVersionNumber == VersionNumber(-1, 0)) + #expect(args.eventStreamSchemaVersion == "-1") + + args.eventStreamVersion = 123 + #expect(args.eventStreamVersionNumber == VersionNumber(123, 0)) + #expect(args.eventStreamSchemaVersion == "123.0") + + args.eventStreamVersionNumber = VersionNumber(10, 20, 30) + #expect(args.eventStreamVersion == 10) + #expect(args.eventStreamSchemaVersion == "10.20.30") + + args.eventStreamSchemaVersion = "10.20.30" + #expect(args.eventStreamVersionNumber == VersionNumber(10, 20, 30)) + #expect(args.eventStreamVersion == 10) + +#if !SWT_NO_EXIT_TESTS + await #expect(processExitsWith: .failure) { + var args = __CommandLineArguments_v0() + args.eventStreamSchemaVersion = "invalidVersionString" + } +#endif + } + + @Test("New-but-not-experimental ABI version") + func newButNotExperimentalABIVersion() async throws { + var versionNumber = ABI.CurrentVersion.versionNumber + versionNumber.patchComponent += 1 + let version = try #require(ABI.version(forVersionNumber: versionNumber)) + #expect(version.versionNumber == ABI.v0.versionNumber) + } + + @Test("Unsupported ABI version") + func unsupportedABIVersion() async throws { + let versionNumber = VersionNumber(-100, 0) + let versionTypeInfo = ABI.version(forVersionNumber: versionNumber).map {TypeInfo(describing: $0) } + #expect(versionTypeInfo == nil) + } + + @Test("Future ABI version (should be nil)") + func futureABIVersion() async throws { + #expect(swiftCompilerVersion >= VersionNumber(6, 0)) + #expect(swiftCompilerVersion < VersionNumber(8, 0), "Swift 8.0 is here! Please update this test.") + let versionNumber = VersionNumber(8, 0) + let versionTypeInfo = ABI.version(forVersionNumber: versionNumber).map {TypeInfo(describing: $0) } + #expect(versionTypeInfo == nil) + } + + @Test("Severity and isFailure fields included in version 6.3") + func validateEventStreamContents() async throws { + let tempDirPath = try temporaryDirectory() + let temporaryFilePath = appendPathComponent("\(UInt64.random(in: 0 ..< .max))", to: tempDirPath) + defer { + _ = remove(temporaryFilePath) + } + + do { + let test = Test { + Issue.record("Test warning", severity: .warning) + } + + let configuration = try configurationForEntryPoint(withArguments: + ["PATH", "--event-stream-output-path", temporaryFilePath, "--experimental-event-stream-version", "6.3"] + ) + + await test.run(configuration: configuration) + } + + let issueEventRecords = try decodedEventStreamRecords(fromPath: temporaryFilePath) + .compactMap { (record: ABI.Record) in + if case let .event(event) = record.kind, event.kind == .issueRecorded { + return event + } + return nil + } + + let issue = try #require(issueEventRecords.first?.issue) + #expect(issueEventRecords.count == 1) + #expect(issue.isFailure == false) + #expect(issue.severity == .warning) + } + @Test("--event-stream-output-path argument (writes to a stream and can be read back)", arguments: [ ("--event-stream-output-path", "--event-stream-version", ABI.v0.versionNumber), ("--experimental-event-stream-output", "--experimental-event-stream-version", ABI.v0.versionNumber), - ("--experimental-event-stream-output", "--experimental-event-stream-version", ABI.v1.versionNumber), + ("--experimental-event-stream-output", "--experimental-event-stream-version", ABI.v6_3.versionNumber), ]) - func eventStreamOutput(outputArgumentName: String, versionArgumentName: String, version: Int) async throws { - switch version { - case ABI.v0.versionNumber: - try await eventStreamOutput(outputArgumentName: outputArgumentName, versionArgumentName: versionArgumentName, version: ABI.v0.self) - case ABI.v1.versionNumber: - try await eventStreamOutput(outputArgumentName: outputArgumentName, versionArgumentName: versionArgumentName, version: ABI.v1.self) - default: - Issue.record("Unreachable event stream version \(version)") - } + func eventStreamOutput(outputArgumentName: String, versionArgumentName: String, version: VersionNumber) async throws { + let version = try #require(ABI.version(forVersionNumber: version)) + try await eventStreamOutput(outputArgumentName: outputArgumentName, versionArgumentName: versionArgumentName, version: version) } func eventStreamOutput(outputArgumentName: String, versionArgumentName: String, version: V.Type) async throws where V: ABI.Version { @@ -266,13 +392,7 @@ struct SwiftPMTests { configuration.handleEvent(Event(.runEnded, testID: nil, testCaseID: nil), in: eventContext) } - let decodedRecords = try FileHandle(forReadingAtPath: temporaryFilePath).readToEnd() - .split(whereSeparator: \.isASCIINewline) - .map { line in - try line.withUnsafeBytes { line in - try JSON.decode(ABI.Record.self, from: line) - } - } + let decodedRecords: [ABI.Record] = try decodedEventStreamRecords(fromPath: temporaryFilePath) let testRecords = decodedRecords.compactMap { record in if case let .test(test) = record.kind { @@ -282,7 +402,7 @@ struct SwiftPMTests { } #expect(testRecords.count == 1) for testRecord in testRecords { - if version.versionNumber >= ABI.v1.versionNumber { + if version.includesExperimentalFields { #expect(testRecord._tags != nil) } else { #expect(testRecord._tags == nil) @@ -300,10 +420,18 @@ struct SwiftPMTests { @Test("Experimental ABI version requires --experimental-event-stream-version argument") func experimentalABIVersionNeedsExperimentalFlag() { #expect(throws: (any Error).self) { - let experimentalVersion = ABI.CurrentVersion.versionNumber + 1 + var experimentalVersion = ABI.CurrentVersion.versionNumber + experimentalVersion.minorComponent += 1 _ = try configurationForEntryPoint(withArguments: ["PATH", "--event-stream-version", "\(experimentalVersion)"]) } } + + @Test("Invalid event stream version throws an invalid argument error") + func invalidEventStreamVersionThrows() { + #expect(throws: (any Error).self) { + _ = try configurationForEntryPoint(withArguments: ["PATH", "--event-stream-version", "xyz-invalid"]) + } + } #endif #endif @@ -390,4 +518,25 @@ struct SwiftPMTests { let args = try parseCommandLineArguments(from: ["PATH", "--verbosity", "12345"]) #expect(args.verbosity == 12345) } + + @Test("--foo=bar form") + func equalsSignForm() throws { + // We can split the string and parse the result correctly. + do { + let args = try parseCommandLineArguments(from: ["PATH", "--verbosity=12345"]) + #expect(args.verbosity == 12345) + } + + // We don't overrun the string and correctly handle empty values. + do { + let args = try parseCommandLineArguments(from: ["PATH", "--xunit-output="]) + #expect(args.xunitOutput == "") + } + + // We split at the first equals-sign. + do { + let args = try parseCommandLineArguments(from: ["PATH", "--xunit-output=abc=123"]) + #expect(args.xunitOutput == "abc=123") + } + } } diff --git a/Tests/TestingTests/Test.SnapshotTests.swift b/Tests/TestingTests/Test.SnapshotTests.swift index 12a3f2467..a0c83be1f 100644 --- a/Tests/TestingTests/Test.SnapshotTests.swift +++ b/Tests/TestingTests/Test.SnapshotTests.swift @@ -98,7 +98,7 @@ struct Test_SnapshotTests { private static let bug: Bug = Bug.bug(id: 12345, "Lorem ipsum") @available(_clockAPI, *) - @Test("timeLimit property", _timeLimitIfAvailable(minutes: 999_999_999)) + @Test("timeLimit property", .tags(.traitRelated), _timeLimitIfAvailable(minutes: 999_999_999)) func timeLimit() async throws { let test = try #require(Test.current) let snapshot = Test.Snapshot(snapshotting: test) diff --git a/Tests/TestingTests/TestCancellationTests.swift b/Tests/TestingTests/TestCancellationTests.swift new file mode 100644 index 000000000..06c1375a5 --- /dev/null +++ b/Tests/TestingTests/TestCancellationTests.swift @@ -0,0 +1,242 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +@testable @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing + +@Suite(.serialized) struct `Test cancellation tests` { + func testCancellation( + testCancelled: Int = 0, + testSkipped: Int = 0, + testCaseCancelled: Int = 0, + issueRecorded: Int = 0, + _ body: @Sendable (Configuration) async -> Void, + eventHandler: @escaping @Sendable (borrowing Event, borrowing Event.Context) -> Void = { _, _ in } + ) async { + await confirmation("Test cancelled", expectedCount: testCancelled) { testCancelled in + await confirmation("Test skipped", expectedCount: testSkipped) { testSkipped in + await confirmation("Test case cancelled", expectedCount: testCaseCancelled) { testCaseCancelled in + await confirmation("Issue recorded", expectedCount: issueRecorded) { [issueRecordedCount = issueRecorded] issueRecorded in + var configuration = Configuration() + configuration.eventHandler = { event, eventContext in + switch event.kind { + case .testCancelled: + testCancelled() + case .testSkipped: + testSkipped() + case .testCaseCancelled: + testCaseCancelled() + case let .issueRecorded(issue): + if issueRecordedCount == 0 { + issue.record() + } + issueRecorded() + default: + break + } + eventHandler(event, eventContext) + } +#if !SWT_NO_EXIT_TESTS + configuration.exitTestHandler = ExitTest.handlerForEntryPoint() +#endif + await body(configuration) + } + } + } + } + } + + @Test func `Cancelling a test`() async { + await testCancellation(testCancelled: 1, testCaseCancelled: 1) { configuration in + await Test { + try Test.cancel("Cancelled test") + }.run(configuration: configuration) + } + } + + @Test func `Cancelling a test case in a parameterized test`() async { + await testCancellation(testCaseCancelled: 5, issueRecorded: 5) { configuration in + await Test(arguments: 0 ..< 10) { i in + if (i % 2) == 0 { + try Test.cancel("\(i) is even!") + } + Issue.record("\(i) records an issue!") + }.run(configuration: configuration) + } + } + + @Test func `Cancelling a test propagates its SkipInfo to its test cases`() async { + let sourceLocation = #_sourceLocation + await testCancellation(testCancelled: 1, testCaseCancelled: 1) { configuration in + await Test { + try Test.cancel("Cancelled test", sourceLocation: sourceLocation) + }.run(configuration: configuration) + } eventHandler: { event, _ in + if case let .testCaseCancelled(skipInfo) = event.kind { + #expect(skipInfo.comment?.rawValue == "Cancelled test") + #expect(skipInfo.sourceContext.sourceLocation == sourceLocation) + } + } + } + + @Test func `Cancelling a test by cancelling its task (throwing)`() async { + await testCancellation(testCancelled: 1, testCaseCancelled: 1) { configuration in + await Test { + withUnsafeCurrentTask { $0?.cancel() } + try Task.checkCancellation() + }.run(configuration: configuration) + } + } + + @Test func `Cancelling a test by cancelling its task (returning)`() async { + await testCancellation(testCancelled: 1, testCaseCancelled: 1) { configuration in + await Test { + withUnsafeCurrentTask { $0?.cancel() } + }.run(configuration: configuration) + } + } + + @Test func `Throwing CancellationError without cancelling the test task`() async { + await testCancellation(issueRecorded: 1) { configuration in + await Test { + throw CancellationError() + }.run(configuration: configuration) + } + } + + @Test func `Throwing CancellationError while evaluating traits without cancelling the test task`() async { + await testCancellation(issueRecorded: 1) { configuration in + await Test(CancelledTrait(throwsWithoutCancelling: true)) { + }.run(configuration: configuration) + } + } + + @Test func `Cancelling a test while evaluating traits skips the test`() async { + await testCancellation(testSkipped: 1) { configuration in + await Test(CancelledTrait()) { + Issue.record("Recorded an issue!") + }.run(configuration: configuration) + } + } + + @Test func `Cancelling the current task while evaluating traits skips the test`() async { + await testCancellation(testSkipped: 1) { configuration in + await Test(CancelledTrait(cancelsTask: true)) { + Issue.record("Recorded an issue!") + }.run(configuration: configuration) + } + } + + @Test func `Cancelling a test while evaluating test cases skips the test`() async { + await testCancellation(testSkipped: 1) { configuration in + await Test(arguments: { try await cancelledTestCases(cancelsTask: false) }) { _ in + Issue.record("Recorded an issue!") + }.run(configuration: configuration) + } + } + + @Test func `Cancelling the current task while evaluating test cases skips the test`() async { + await testCancellation(testSkipped: 1) { configuration in + await Test(arguments: { try await cancelledTestCases(cancelsTask: true) }) { _ in + Issue.record("Recorded an issue!") + }.run(configuration: configuration) + } + } + +#if !SWT_NO_EXIT_TESTS + @Test func `Cancelling the current test from within an exit test`() async { + await testCancellation(testCancelled: 1, testCaseCancelled: 1) { configuration in + await Test { + await #expect(processExitsWith: .success) { + try Test.cancel("Cancelled test") + } + #expect(Task.isCancelled) + try Task.checkCancellation() + }.run(configuration: configuration) + } + } + + @Test func `Cancelling the current task in an exit test doesn't cancel the test`() async { + await testCancellation(testCancelled: 0, testCaseCancelled: 0) { configuration in + await Test { + await #expect(processExitsWith: .success) { + withUnsafeCurrentTask { $0?.cancel() } + } + #expect(!Task.isCancelled) + try Task.checkCancellation() + }.run(configuration: configuration) + } + } +#endif +} + +#if canImport(XCTest) +import XCTest + +final class TestCancellationTests: XCTestCase { + func testCancellationFromBackgroundTask() async { + let testCancelled = expectation(description: "Test cancelled") + testCancelled.isInverted = true + + let issueRecorded = expectation(description: "Issue recorded") + + var configuration = Configuration() + configuration.eventHandler = { event, _ in + if case .testCancelled = event.kind { + testCancelled.fulfill() + } else if case .issueRecorded = event.kind { + issueRecorded.fulfill() + } + } + + await Test { + await Task.detached { + _ = try? Test.cancel("Cancelled test") + }.value + }.run(configuration: configuration) + + await fulfillment(of: [testCancelled, issueRecorded], timeout: 0.0) + } +} +#endif + +// MARK: - Fixtures + +struct CancelledTrait: TestTrait { + var throwsWithoutCancelling = false + var cancelsTask = false + + func prepare(for test: Test) async throws { + if throwsWithoutCancelling { + throw CancellationError() + } + if cancelsTask { + withUnsafeCurrentTask { $0?.cancel() } + try Task.checkCancellation() + } + try Test.cancel("Cancelled from trait") + } +} + +func cancelledTestCases(cancelsTask: Bool) async throws -> EmptyCollection { + if cancelsTask { + withUnsafeCurrentTask { $0?.cancel() } + try Task.checkCancellation() + } + try Test.cancel("Cancelled from trait") +} + + +#if !SWT_NO_SNAPSHOT_TYPES +struct `Shows as skipped in Xcode 16` { + @Test func `Cancelled test`() throws { + try Test.cancel("This test should appear cancelled/skipped") + } +} +#endif diff --git a/Tests/TestingTests/TestSupport/TestingAdditions.swift b/Tests/TestingTests/TestSupport/TestingAdditions.swift index 4648f96af..05bb05dc8 100644 --- a/Tests/TestingTests/TestSupport/TestingAdditions.swift +++ b/Tests/TestingTests/TestSupport/TestingAdditions.swift @@ -103,6 +103,25 @@ extension Runner { /// - fileID: The `#fileID` string whose module should be used to locate /// the test function to run. /// - configuration: The configuration to use for running. + init( + selecting testName: String, + inModuleOf fileID: String = #fileID, + configuration: Configuration = .init() + ) async { + let plan = await Runner.Plan(selecting: testName, inModuleOf: fileID, configuration: configuration) + self.init(plan: plan, configuration: configuration) + } +} + +extension Runner.Plan { + /// Initialize an instance of this type that selects the free test function + /// named `testName` in the module specified in `fileID`. + /// + /// - Parameters: + /// - testName: The name of the test function this instance should run. + /// - fileID: The `#fileID` string whose module should be used to locate + /// the test function to run. + /// - configuration: The configuration to use for running. init( selecting testName: String, inModuleOf fileID: String = #fileID, @@ -116,9 +135,7 @@ extension Runner { await self.init(configuration: configuration) } -} -extension Runner.Plan { /// Initialize an instance of this type with the specified suite type. /// /// - Parameters: @@ -170,7 +187,9 @@ extension Test { init( _ traits: any TestTrait..., arguments collection: C, - parameters: [Parameter] = [], + parameters: [Parameter] = [ + Parameter(index: 0, firstName: "x", type: C.Element.self), + ], sourceLocation: SourceLocation = #_sourceLocation, column: Int = #column, name: String = #function, @@ -180,6 +199,23 @@ extension Test { self.init(name: name, displayName: name, traits: traits, sourceLocation: sourceLocation, containingTypeInfo: nil, testCases: caseGenerator, parameters: parameters) } + init( + _ traits: any TestTrait..., + arguments collection: @escaping @Sendable () async throws -> C, + parameters: [Parameter] = [ + Parameter(index: 0, firstName: "x", type: C.Element.self), + ], + sourceLocation: SourceLocation = #_sourceLocation, + column: Int = #column, + name: String = #function, + testFunction: @escaping @Sendable (C.Element) async throws -> Void + ) where C: Collection & Sendable, C.Element: Sendable { + let caseGenerator = { @Sendable in + Case.Generator(arguments: try await collection(), parameters: parameters, testFunction: testFunction) + } + self.init(name: name, displayName: name, traits: traits, sourceLocation: sourceLocation, containingTypeInfo: nil, testCases: caseGenerator, parameters: parameters) + } + /// Initialize an instance of this type with a function or closure to call, /// parameterized over two collections of values. /// @@ -199,7 +235,10 @@ extension Test { init( _ traits: any TestTrait..., arguments collection1: C1, _ collection2: C2, - parameters: [Parameter] = [], + parameters: [Parameter] = [ + Parameter(index: 0, firstName: "x", type: C1.Element.self), + Parameter(index: 1, firstName: "y", type: C2.Element.self), + ], sourceLocation: SourceLocation = #_sourceLocation, name: String = #function, testFunction: @escaping @Sendable (C1.Element, C2.Element) async throws -> Void @@ -222,7 +261,10 @@ extension Test { init( _ traits: any TestTrait..., arguments zippedCollections: Zip2Sequence, - parameters: [Parameter] = [], + parameters: [Parameter] = [ + Parameter(index: 0, firstName: "x", type: C1.Element.self), + Parameter(index: 1, firstName: "y", type: C2.Element.self), + ], sourceLocation: SourceLocation = #_sourceLocation, name: String = #function, testFunction: @escaping @Sendable ((C1.Element, C2.Element)) async throws -> Void diff --git a/Tests/TestingTests/Traits/AttachmentSavingTraitTests.swift b/Tests/TestingTests/Traits/AttachmentSavingTraitTests.swift new file mode 100644 index 000000000..47edd8b25 --- /dev/null +++ b/Tests/TestingTests/Traits/AttachmentSavingTraitTests.swift @@ -0,0 +1,158 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +@testable @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing + +struct `AttachmentSavingTrait tests` { + func runAttachmentSavingTests(with trait: AttachmentSavingTrait?, expectedCount: Int, expectedIssueCount: Int = Self.issueCountFromTestBodies, expectedPreferredName: String?) async throws { + let traitToApply = trait as (any SuiteTrait)? ?? Self.currentAttachmentSavingTrait + try await Self.$currentAttachmentSavingTrait.withValue(traitToApply) { + try await confirmation("Issue recorded", expectedCount: expectedIssueCount) { issueRecorded in + try await confirmation("Attachment detected", expectedCount: expectedCount) { valueAttached in + var configuration = Configuration() + configuration.attachmentsPath = try temporaryDirectory() + configuration.eventHandler = { event, _ in + switch event.kind { + case .issueRecorded: + issueRecorded() + case let .valueAttached(attachment): +#if DEBUG + if trait != nil { + #expect(event.wasDeferred) + } +#endif + if let expectedPreferredName { + #expect(attachment.preferredName == expectedPreferredName) + } + valueAttached() + default: + break + } + } + + await runTest(for: FixtureSuite.self, configuration: configuration) + } + } + } + } + + @Test func `Saving attachments without conditions`() async throws { + try await runAttachmentSavingTests( + with: nil, + expectedCount: Self.totalTestCaseCount, + expectedPreferredName: nil + ) + } + + @Test func `Saving attachments only on test pass`() async throws { + try await runAttachmentSavingTests( + with: .savingAttachments(if: .testPasses), + expectedCount: Self.passingTestCaseCount, + expectedPreferredName: "PASSING TEST" + ) + } + + @Test func `Saving attachments with warning issue`() async throws { + try await runAttachmentSavingTests( + with: .savingAttachments(if: .testRecordsIssue { $0.severity == .warning }), + expectedCount: Self.warningTestCaseCount, + expectedPreferredName: "PASSING TEST" + ) + } + + @Test func `Saving attachments only on test failure`() async throws { + try await runAttachmentSavingTests( + with: .savingAttachments(if: .testFails), + expectedCount: Self.failingTestCaseCount, + expectedPreferredName: "FAILING TEST" + ) + } + + @Test func `Saving attachments with custom condition`() async throws { + try await runAttachmentSavingTests( + with: .savingAttachments(if: true), + expectedCount: Self.totalTestCaseCount, + expectedPreferredName: nil + ) + + try await runAttachmentSavingTests( + with: .savingAttachments(if: false), + expectedCount: 0, + expectedPreferredName: nil + ) + } + + @Test func `Saving attachments with custom async condition`() async throws { + @Sendable func conditionFunction() async -> Bool { + true + } + + try await runAttachmentSavingTests( + with: .savingAttachments(if: conditionFunction), + expectedCount: Self.totalTestCaseCount, + expectedPreferredName: nil + ) + } + + @Test func `Saving attachments but the condition throws`() async throws { + @Sendable func conditionFunction() throws -> Bool { + throw MyError() + } + + try await runAttachmentSavingTests( + with: .savingAttachments(if: conditionFunction), + expectedCount: 0, + expectedIssueCount: Self.issueCountFromTestBodies + Self.totalTestCaseCount /* thrown from conditionFunction */, + expectedPreferredName: nil + ) + } +} + +// MARK: - Fixtures + +extension `AttachmentSavingTrait tests` { + static let totalTestCaseCount = passingTestCaseCount + failingTestCaseCount + static let passingTestCaseCount = 1 + 5 + warningTestCaseCount + static let warningTestCaseCount = 1 + static let failingTestCaseCount = 1 + 7 + static let issueCountFromTestBodies = warningTestCaseCount + failingTestCaseCount + + @TaskLocal + static var currentAttachmentSavingTrait: any SuiteTrait = Comment(rawValue: "") + + @Suite(.hidden, currentAttachmentSavingTrait) + struct FixtureSuite { + @Test(.hidden) func `Records an attachment (passing)`() { + Attachment.record([], named: "PASSING TEST") + } + + @Test(.hidden) func `Records an attachment (warning)`() { + Attachment.record([], named: "PASSING TEST") + Issue.record("", severity: .warning) + } + + @Test(.hidden) func `Records an attachment (failing)`() { + Attachment.record([], named: "FAILING TEST") + Issue.record("") + } + + @Test(.hidden, arguments: 0 ..< 5) + func `Records an attachment (passing, parameterized)`(i: Int) async { + Attachment.record([UInt8(i)], named: "PASSING TEST") + } + + @Test(.hidden, arguments: 0 ..< 7) // intentionally different count + func `Records an attachment (failing, parameterized)`(i: Int) async { + Attachment.record([UInt8(i)], named: "FAILING TEST") + Issue.record("\(i)") + } + } +} + diff --git a/Tests/TestingTests/Traits/ConditionTraitTests.swift b/Tests/TestingTests/Traits/ConditionTraitTests.swift index d957e425b..9747f6b3f 100644 --- a/Tests/TestingTests/Traits/ConditionTraitTests.swift +++ b/Tests/TestingTests/Traits/ConditionTraitTests.swift @@ -12,14 +12,12 @@ @Suite("Condition Trait Tests", .tags(.traitRelated)) struct ConditionTraitTests { - #if compiler(>=6.1) @Test( ".enabled trait", .enabled { true }, .bug("https://github.com/swiftlang/swift/issues/76409", "Verify the custom trait with closure causes @Test macro to fail is fixed") ) func enabledTraitClosure() throws {} - #endif @Test( ".enabled if trait", @@ -27,14 +25,12 @@ struct ConditionTraitTests { ) func enabledTraitIf() throws {} - #if compiler(>=6.1) @Test( ".disabled trait", .disabled { false }, .bug("https://github.com/swiftlang/swift/issues/76409", "Verify the custom trait with closure causes @Test macro to fail is fixed") ) func disabledTraitClosure() throws {} - #endif @Test( ".disabled if trait", diff --git a/Tests/TestingTests/Traits/IssueHandlingTraitTests.swift b/Tests/TestingTests/Traits/IssueHandlingTraitTests.swift index 4d749b07f..2efb08dcc 100644 --- a/Tests/TestingTests/Traits/IssueHandlingTraitTests.swift +++ b/Tests/TestingTests/Traits/IssueHandlingTraitTests.swift @@ -8,9 +8,9 @@ // See https://swift.org/CONTRIBUTORS.txt for Swift project authors // -@_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing +@testable @_spi(ForToolsIntegrationOnly) import Testing -@Suite("IssueHandlingTrait Tests") +@Suite("IssueHandlingTrait Tests", .tags(.traitRelated)) struct IssueHandlingTraitTests { @Test("Transforming an issue by appending a comment") func addComment() async throws { @@ -23,7 +23,7 @@ struct IssueHandlingTraitTests { #expect(issue.comments == ["Foo", "Bar"]) } - let handler = IssueHandlingTrait.transformIssues { issue in + let handler = IssueHandlingTrait.compactMapIssues { issue in var issue = issue issue.comments.append("Bar") return issue @@ -34,8 +34,8 @@ struct IssueHandlingTraitTests { }.run(configuration: configuration) } - @Test("Suppressing an issue by returning `nil` from the transform closure") - func suppressIssueUsingTransformer() async throws { + @Test("Suppressing an issue by returning `nil` from the closure passed to compactMapIssues()") + func suppressIssueUsingCompactMapIssues() async throws { var configuration = Configuration() configuration.eventHandler = { event, context in if case .issueRecorded = event.kind { @@ -43,7 +43,7 @@ struct IssueHandlingTraitTests { } } - let handler = IssueHandlingTrait.transformIssues { _ in + let handler = IssueHandlingTrait.compactMapIssues { _ in // Return nil to suppress the issue. nil } @@ -81,10 +81,10 @@ struct IssueHandlingTraitTests { struct MyError: Error {} - try await confirmation("Transformer closure is called") { transformerCalled in - let transformer: @Sendable (Issue) -> Issue? = { issue in + try await confirmation("Issue handler closure is called") { issueHandlerCalled in + let transform: @Sendable (Issue) -> Issue? = { issue in defer { - transformerCalled() + issueHandlerCalled() } #expect(Test.Case.current == nil) @@ -96,7 +96,7 @@ struct IssueHandlingTraitTests { let test = Test( .enabled(if: try { throw MyError() }()), - .transformIssues(transformer) + .compactMapIssues(transform) ) {} // Use a detached task to intentionally clear task local values for the @@ -108,12 +108,12 @@ struct IssueHandlingTraitTests { } #endif - @Test("Accessing the current Test and Test.Case from a transformer closure") + @Test("Accessing the current Test and Test.Case from an issue handler closure") func currentTestAndCase() async throws { - await confirmation("Transformer closure is called") { transformerCalled in - let handler = IssueHandlingTrait.transformIssues { issue in + await confirmation("Issue handler closure is called") { issueHandlerCalled in + let handler = IssueHandlingTrait.compactMapIssues { issue in defer { - transformerCalled() + issueHandlerCalled() } #expect(Test.current?.name == "fixture()") #expect(Test.Case.current != nil) @@ -140,12 +140,12 @@ struct IssueHandlingTraitTests { #expect(issue.comments == ["Foo", "Bar", "Baz"]) } - let outerHandler = IssueHandlingTrait.transformIssues { issue in + let outerHandler = IssueHandlingTrait.compactMapIssues { issue in var issue = issue issue.comments.append("Baz") return issue } - let innerHandler = IssueHandlingTrait.transformIssues { issue in + let innerHandler = IssueHandlingTrait.compactMapIssues { issue in var issue = issue issue.comments.append("Bar") return issue @@ -156,7 +156,7 @@ struct IssueHandlingTraitTests { }.run(configuration: configuration) } - @Test("Secondary issue recorded from a transformer closure") + @Test("Secondary issue recorded from an issue handler closure") func issueRecordedFromClosure() async throws { await confirmation("Original issue recorded") { originalIssueRecorded in await confirmation("Secondary issue recorded") { secondaryIssueRecorded in @@ -175,14 +175,14 @@ struct IssueHandlingTraitTests { } } - let handler1 = IssueHandlingTrait.transformIssues { issue in + let handler1 = IssueHandlingTrait.compactMapIssues { issue in return issue } - let handler2 = IssueHandlingTrait.transformIssues { issue in + let handler2 = IssueHandlingTrait.compactMapIssues { issue in Issue.record("Something else") return issue } - let handler3 = IssueHandlingTrait.transformIssues { issue in + let handler3 = IssueHandlingTrait.compactMapIssues { issue in // The "Something else" issue should not be passed to this closure. #expect(issue.comments.contains("Foo")) return issue @@ -194,4 +194,74 @@ struct IssueHandlingTraitTests { } } } + + @Test("System issues are not passed to issue handler closures") + func ignoresSystemIssues() async throws { + var configuration = Configuration() + configuration.eventHandler = { event, context in + if case let .issueRecorded(issue) = event.kind, case .unconditional = issue.kind { + issue.record() + } + } + + let handler = IssueHandlingTrait.compactMapIssues { issue in + if case .system = issue.kind { + Issue.record("Unexpectedly received a system issue") + } + return nil + } + + await Test(handler) { + Issue(kind: .system).record() + }.run(configuration: configuration) + } + + @Test("An API misused issue can be returned by issue handler closure when the original issue had that kind") + func returningAPIMisusedIssue() async throws { + var configuration = Configuration() + configuration.eventHandler = { event, context in + if case let .issueRecorded(issue) = event.kind, case .unconditional = issue.kind { + issue.record() + } + } + + let handler = IssueHandlingTrait.compactMapIssues { issue in + guard case .apiMisused = issue.kind else { + return Issue.record("Expected an issue of kind 'apiMisused': \(issue)") + } + return issue + } + + await Test(handler) { + Issue(kind: .apiMisused).record() + }.run(configuration: configuration) + } + +#if !SWT_NO_EXIT_TESTS + @Test("Disallow assigning kind to .system") + func disallowAssigningSystemKind() async throws { + await #expect(processExitsWith: .failure) { + await Test(.compactMapIssues { issue in + var issue = issue + issue.kind = .system + return issue + }) { + Issue.record("A non-system issue") + }.run() + } + } + + @Test("Disallow assigning kind to .apiMisused") + func disallowAssigningAPIMisusedKind() async throws { + await #expect(processExitsWith: .failure) { + await Test(.compactMapIssues { issue in + var issue = issue + issue.kind = .apiMisused + return issue + }) { + Issue.record("A non-system issue") + }.run() + } + } +#endif } diff --git a/Tests/TestingTests/Traits/ParallelizationTraitTests.swift b/Tests/TestingTests/Traits/ParallelizationTraitTests.swift index 776e5c320..6c4963dc5 100644 --- a/Tests/TestingTests/Traits/ParallelizationTraitTests.swift +++ b/Tests/TestingTests/Traits/ParallelizationTraitTests.swift @@ -12,8 +12,11 @@ @Suite("Parallelization Trait Tests", .tags(.traitRelated)) struct ParallelizationTraitTests { - @Test(".serialized trait serializes parameterized test") - func serializesParameterizedTestFunction() async { + @Test(".serialized trait serializes parameterized test", arguments: await [ + Runner.Plan(selecting: OuterSuite.self), + Runner.Plan(selecting: "globalParameterized(i:)"), + ]) + func serializesParameterizedTestFunction(plan: Runner.Plan) async { var configuration = Configuration() configuration.isParallelizationEnabled = true @@ -33,7 +36,6 @@ struct ParallelizationTraitTests { } } - let plan = await Runner.Plan(selecting: OuterSuite.self, configuration: configuration) let runner = Runner(plan: plan, configuration: configuration) await runner.run() @@ -59,3 +61,8 @@ private struct OuterSuite { } } } + +@Test(.hidden, .serialized, arguments: 0 ..< 10_000) +private func globalParameterized(i: Int) { + Issue.record("PARAMETERIZED\(i)") +} diff --git a/Tests/TestingTests/Traits/TagListTests.swift b/Tests/TestingTests/Traits/TagListTests.swift index 1ec8d1248..81cba285c 100644 --- a/Tests/TestingTests/Traits/TagListTests.swift +++ b/Tests/TestingTests/Traits/TagListTests.swift @@ -11,6 +11,10 @@ @testable @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing private import _TestingInternals +#if canImport(Foundation) +private import Foundation +#endif + @Suite("Tag/Tag List Tests", .tags(.traitRelated)) struct TagListTests { @Test(".tags() factory method with one tag") diff --git a/Tests/TestingTests/Traits/TestScopingTraitTests.swift b/Tests/TestingTests/Traits/TestScopingTraitTests.swift index af63deb5e..2fcd7b260 100644 --- a/Tests/TestingTests/Traits/TestScopingTraitTests.swift +++ b/Tests/TestingTests/Traits/TestScopingTraitTests.swift @@ -10,7 +10,7 @@ @testable @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing -@Suite("TestScoping-conforming Trait Tests") +@Suite("TestScoping-conforming Trait Tests", .tags(.traitRelated)) struct TestScopingTraitTests { @Test("Execute code before and after a non-parameterized test.") func executeCodeBeforeAndAfterNonParameterizedTest() async { diff --git a/Tests/TestingTests/Traits/TimeLimitTraitTests.swift b/Tests/TestingTests/Traits/TimeLimitTraitTests.swift index b29ccb93c..7d427f259 100644 --- a/Tests/TestingTests/Traits/TimeLimitTraitTests.swift +++ b/Tests/TestingTests/Traits/TimeLimitTraitTests.swift @@ -10,6 +10,10 @@ @testable @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing +#if canImport(Foundation) +private import Foundation +#endif + @Suite("TimeLimitTrait Tests", .tags(.traitRelated)) struct TimeLimitTraitTests { @available(_clockAPI, *) @@ -181,7 +185,7 @@ struct TimeLimitTraitTests { @Test("Cancelled tests can exit early (cancellation checking works)") func cancelledTestExitsEarly() async throws { let timeAwaited = await Test.Clock().measure { - await withTaskGroup(of: Void.self) { taskGroup in + await withTaskGroup { taskGroup in taskGroup.addTask { await Test { try await Test.Clock.sleep(for: .seconds(60) * 60) diff --git a/Tests/TestingTests/TypeInfoTests.swift b/Tests/TestingTests/TypeInfoTests.swift index b2a79f1ab..2063a7684 100644 --- a/Tests/TestingTests/TypeInfoTests.swift +++ b/Tests/TestingTests/TypeInfoTests.swift @@ -122,6 +122,11 @@ struct TypeInfoTests { #expect(!TypeInfo(describing: String.self).isSwiftEnumeration) #expect(TypeInfo(describing: SomeEnum.self).isSwiftEnumeration) } + + @Test func typeOfMoveOnlyValueIsInferred() { + let value = MoveOnlyType() + #expect(TypeInfo(describingTypeOf: value).unqualifiedName == "MoveOnlyType") + } } // MARK: - Fixtures @@ -131,3 +136,5 @@ extension String { } private enum SomeEnum {} + +private struct MoveOnlyType: ~Copyable {} diff --git a/Tests/_MemorySafeTestingTests/MemorySafeTestDecls.swift b/Tests/_MemorySafeTestingTests/MemorySafeTestDecls.swift new file mode 100644 index 000000000..206c41488 --- /dev/null +++ b/Tests/_MemorySafeTestingTests/MemorySafeTestDecls.swift @@ -0,0 +1,29 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +@testable import Testing + +#if !hasFeature(StrictMemorySafety) +#error("This file requires strict memory safety to be enabled") +#endif + +@Test(.hidden) +func exampleTestFunction() {} + +@Suite(.hidden) +struct ExampleSuite { + @Test func example() {} +} + +#if !SWT_NO_EXIT_TESTS +func exampleExitTest() async { + await #expect(processExitsWith: .success) {} +} +#endif diff --git a/VERSION.txt b/VERSION.txt new file mode 100644 index 000000000..a2b88412f --- /dev/null +++ b/VERSION.txt @@ -0,0 +1 @@ +6.3-dev diff --git a/cmake/modules/GitCommit.cmake b/cmake/modules/GitCommit.cmake new file mode 100644 index 000000000..1e5a286cf --- /dev/null +++ b/cmake/modules/GitCommit.cmake @@ -0,0 +1,37 @@ +## +## This source file is part of the Swift.org open source project +## +## Copyright (c) 2024–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 +## + +find_package(Git QUIET) +if(Git_FOUND) + # Get the commit hash corresponding to the current build. + execute_process( + COMMAND ${GIT_EXECUTABLE} rev-parse --verify HEAD + WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} + OUTPUT_VARIABLE SWT_TESTING_LIBRARY_COMMIT_HASH + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) + + # Check if there are local changes. + execute_process( + COMMAND ${GIT_EXECUTABLE} status -s + WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} + OUTPUT_VARIABLE SWT_TESTING_LIBRARY_COMMIT_MODIFIED + OUTPUT_STRIP_TRAILING_WHITESPACE) +endif() + +if(SWT_TESTING_LIBRARY_COMMIT_HASH) + message(STATUS "Swift Testing commit hash: ${SWT_TESTING_LIBRARY_COMMIT_HASH}") + add_compile_definitions( + "$<$:SWT_TESTING_LIBRARY_COMMIT_HASH=\"${SWT_TESTING_LIBRARY_COMMIT_HASH}\">") + if(SWT_TESTING_LIBRARY_COMMIT_MODIFIED) + add_compile_definitions( + "$<$:SWT_TESTING_LIBRARY_COMMIT_MODIFIED=1>") + endif() +endif() diff --git a/cmake/modules/LibraryVersion.cmake b/cmake/modules/LibraryVersion.cmake index 259ead608..c5c1d6405 100644 --- a/cmake/modules/LibraryVersion.cmake +++ b/cmake/modules/LibraryVersion.cmake @@ -1,43 +1,12 @@ -# 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 http://swift.org/LICENSE.txt for license information -# See http://swift.org/CONTRIBUTORS.txt for Swift project authors - -# The current version of the Swift Testing release. For release branches, -# remember to remove -dev. -set(SWT_TESTING_LIBRARY_VERSION "6.3-dev") - -find_package(Git QUIET) -if(Git_FOUND) - # Get the commit hash corresponding to the current build. Limit length to 15 - # to match `swift --version` output format. - execute_process( - COMMAND ${GIT_EXECUTABLE} rev-parse --short=15 --verify HEAD - WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} - OUTPUT_VARIABLE GIT_VERSION - OUTPUT_STRIP_TRAILING_WHITESPACE - ERROR_QUIET) - - # Check if there are local changes. - execute_process( - COMMAND ${GIT_EXECUTABLE} status -s - WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} - OUTPUT_VARIABLE GIT_STATUS - OUTPUT_STRIP_TRAILING_WHITESPACE) - if(GIT_STATUS) - set(GIT_VERSION "${GIT_VERSION} - modified") - endif() -endif() - -# Combine the hard-coded Swift version with available Git information. -if(GIT_VERSION) -set(SWT_TESTING_LIBRARY_VERSION "${SWT_TESTING_LIBRARY_VERSION} (${GIT_VERSION})") -endif() - -# All done! -message(STATUS "Swift Testing version: ${SWT_TESTING_LIBRARY_VERSION}") -add_compile_definitions( - "$<$:SWT_TESTING_LIBRARY_VERSION=\"${SWT_TESTING_LIBRARY_VERSION}\">") +## +## This source file is part of the Swift.org open source project +## +## Copyright (c) 2024–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 +## + +# The library version is now tracked in VERSION.txt at the root directory of the +# repository. This file will be removed in a future commit. diff --git a/cmake/modules/PlatformInfo.cmake b/cmake/modules/PlatformInfo.cmake index 94c60ef28..13eb736c5 100644 --- a/cmake/modules/PlatformInfo.cmake +++ b/cmake/modules/PlatformInfo.cmake @@ -1,10 +1,12 @@ -# This source file is part of the Swift.org open source project -# -# Copyright (c) 2025 Apple Inc. and the Swift project authors -# Licensed under Apache License v2.0 with Runtime Library Exception -# -# See http://swift.org/LICENSE.txt for license information -# See http://swift.org/CONTRIBUTORS.txt for Swift project authors +## +## This source file is part of the Swift.org open source project +## +## Copyright (c) 2025 Apple Inc. and the Swift project authors +## Licensed under Apache License v2.0 with Runtime Library Exception +## +## See https://swift.org/LICENSE.txt for license information +## See https://swift.org/CONTRIBUTORS.txt for Swift project authors +## set(print_target_info_invocation "${CMAKE_Swift_COMPILER}" -print-target-info) if(CMAKE_Swift_COMPILER_TARGET) diff --git a/cmake/modules/SwiftModuleInstallation.cmake b/cmake/modules/SwiftModuleInstallation.cmake index f9bade57d..6947bb1cd 100644 --- a/cmake/modules/SwiftModuleInstallation.cmake +++ b/cmake/modules/SwiftModuleInstallation.cmake @@ -1,10 +1,12 @@ -# 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 http://swift.org/LICENSE.txt for license information -# See http://swift.org/CONTRIBUTORS.txt for Swift project authors +## +## 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 +## function(_swift_testing_install_target module) install(TARGETS ${module} diff --git a/cmake/modules/TargetTriple.cmake b/cmake/modules/TargetTriple.cmake index e087cc47c..39d17bc2a 100644 --- a/cmake/modules/TargetTriple.cmake +++ b/cmake/modules/TargetTriple.cmake @@ -1,10 +1,12 @@ -# 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 http://swift.org/LICENSE.txt for license information -# See http://swift.org/CONTRIBUTORS.txt for Swift project authors +## +## 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 +## # Ask the Swift compiler what target triple it will be compiling with today. set(SWT_TARGET_INFO_COMMAND "${CMAKE_Swift_COMPILER}" -print-target-info) diff --git a/cmake/modules/shared/AvailabilityDefinitions.cmake b/cmake/modules/shared/AvailabilityDefinitions.cmake index 2124a32be..e6b716657 100644 --- a/cmake/modules/shared/AvailabilityDefinitions.cmake +++ b/cmake/modules/shared/AvailabilityDefinitions.cmake @@ -1,10 +1,12 @@ -# 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 http://swift.org/LICENSE.txt for license information -# See http://swift.org/CONTRIBUTORS.txt for Swift project authors +## +## 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 +## # Settings which define commonly-used OS availability macros. add_compile_options( @@ -15,4 +17,5 @@ add_compile_options( "SHELL:$<$:-Xfrontend -define-availability -Xfrontend \"_regexAPI:macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0\">" "SHELL:$<$:-Xfrontend -define-availability -Xfrontend \"_swiftVersionAPI:macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0\">" "SHELL:$<$:-Xfrontend -define-availability -Xfrontend \"_typedThrowsAPI:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0\">" + "SHELL:$<$:-Xfrontend -define-availability -Xfrontend \"_castingWithNonCopyableGenerics:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0\">" "SHELL:$<$:-Xfrontend -define-availability -Xfrontend \"_distantFuture:macOS 99.0, iOS 99.0, watchOS 99.0, tvOS 99.0, visionOS 99.0\">") diff --git a/cmake/modules/shared/CompilerSettings.cmake b/cmake/modules/shared/CompilerSettings.cmake index 0da4216c5..b3c0fe3aa 100644 --- a/cmake/modules/shared/CompilerSettings.cmake +++ b/cmake/modules/shared/CompilerSettings.cmake @@ -1,10 +1,12 @@ -# 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 http://swift.org/LICENSE.txt for license information -# See http://swift.org/CONTRIBUTORS.txt for Swift project authors +## +## 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 +## # Settings intended to be applied to every Swift target in this project. # Analogous to project-level build settings in an Xcode project. @@ -17,7 +19,8 @@ add_compile_options( add_compile_options( "SHELL:$<$:-Xfrontend -enable-upcoming-feature -Xfrontend ExistentialAny>" "SHELL:$<$:-Xfrontend -enable-upcoming-feature -Xfrontend InternalImportsByDefault>" - "SHELL:$<$:-Xfrontend -enable-upcoming-feature -Xfrontend MemberImportVisibility>") + "SHELL:$<$:-Xfrontend -enable-upcoming-feature -Xfrontend MemberImportVisibility>" + "SHELL:$<$:-Xfrontend -enable-upcoming-feature -Xfrontend InferIsolatedConformances>") # Platform-specific definitions. if(APPLE) @@ -33,8 +36,18 @@ if(CMAKE_SYSTEM_NAME IN_LIST SWT_NO_PROCESS_SPAWNING_LIST) endif() if(NOT APPLE) add_compile_definitions("SWT_NO_SNAPSHOT_TYPES") + add_compile_definitions("SWT_NO_FOUNDATION_FILE_COORDINATION") endif() if(CMAKE_SYSTEM_NAME STREQUAL "WASI") add_compile_definitions("SWT_NO_DYNAMIC_LINKING") add_compile_definitions("SWT_NO_PIPES") endif() +if (NOT (APPLE OR CMAKE_SYSTEM_NAME STREQUAL "Windows")) + add_compile_definitions("SWT_NO_IMAGE_ATTACHMENTS") +endif() + +file(STRINGS "${SWT_SOURCE_ROOT_DIR}/VERSION.txt" SWT_TESTING_LIBRARY_VERSION LIMIT_COUNT 1) +if(SWT_TESTING_LIBRARY_VERSION) + message(STATUS "Swift Testing version: ${SWT_TESTING_LIBRARY_VERSION}") + add_compile_definitions("$<$:SWT_TESTING_LIBRARY_VERSION=\"${SWT_TESTING_LIBRARY_VERSION}\">") +endif()