Skip to content

Non-darwin test discovery with Swift Build #8722

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions Sources/SPMBuildCore/BuildParameters/BuildParameters.swift
Original file line number Diff line number Diff line change
Expand Up @@ -314,11 +314,26 @@ public struct BuildParameters: Encodable {
case .library(.automatic), .plugin:
fatalError()
case .test:
let base = "\(product.name).xctest"
if self.triple.isDarwin() {
return try RelativePath(validating: "\(base)/Contents/MacOS/\(product.name)")
} else {
return try RelativePath(validating: base)
switch buildSystemKind {
case .native, .xcode:
let base = "\(product.name).xctest"
if self.triple.isDarwin() {
return try RelativePath(validating: "\(base)/Contents/MacOS/\(product.name)")
} else {
return try RelativePath(validating: base)
}
case .swiftbuild:
if self.triple.isDarwin() {
Copy link
Contributor

@jakepetroules jakepetroules Jun 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: could do if-macOS "\(base)/Contents/MacOS/\(product.name)" else-if-Darwin "(base)/(product.name)" so it'll support making swift test work for simulator targets (acknowledging that would need additional changes elsewhere for that to be fully working)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'll probably leave this to future work

let base = "\(product.name).xctest"
return try RelativePath(validating: "\(base)/Contents/MacOS/\(product.name)")
} else {
var base = "\(product.name)-test-runner"
let ext = self.triple.executableExtension
if !ext.isEmpty {
base += ".\(ext)"
}
return try RelativePath(validating: base)
}
}
case .macro:
#if BUILD_MACROS_AS_DYLIBS
Expand Down
4 changes: 4 additions & 0 deletions Sources/SPMBuildCore/BuiltTestProduct.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ public struct BuiltTestProduct: Codable {
/// When the test product is not bundled (for instance, when using XCTest on
/// non-Darwin targets), this path is equal to ``binaryPath``.
public var bundlePath: AbsolutePath {
// If the binary path is a test runner binary, return it as-is.
guard !binaryPath.basenameWithoutExt.hasSuffix("test-runner") else {
return binaryPath
}
// Go up the folder hierarchy until we find the .xctest bundle.
let pathExtension = ".xctest"
let hierarchySequence = sequence(first: binaryPath, next: { $0.isRoot ? nil : $0.parentDirectory })
Expand Down
4 changes: 2 additions & 2 deletions Sources/SwiftBuildSupport/PIFBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -633,13 +633,13 @@ fileprivate func buildAggregateProject(
continue
}
}

aggregateProject[keyPath: allIncludingTestsTargetKeyPath].common.addDependency(
on: target.id,
platformFilters: [],
linkProduct: false
)
if target.productType != .unitTest {
if ![.unitTest, .swiftpmTestRunner].contains(target.productType) {
aggregateProject[keyPath: allExcludingTestsTargetKeyPath].common.addDependency(
on: target.id,
platformFilters: [],
Expand Down
7 changes: 5 additions & 2 deletions Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -897,7 +897,7 @@ extension ProjectModel.BuildSettings {
// Appending implies the setting is resilient to having ["$(inherited)"]
self.platformSpecificSettings[platform]![setting]!.append(contentsOf: values)

case .SWIFT_VERSION:
case .SWIFT_VERSION, .DYLIB_INSTALL_NAME_BASE:
self.platformSpecificSettings[platform]![setting] = values // We are not resilient to $(inherited).

case .ARCHS, .IPHONEOS_DEPLOYMENT_TARGET, .SPECIALIZATION_SDK_OPTIONS:
Expand All @@ -922,6 +922,9 @@ extension ProjectModel.BuildSettings {
case .SWIFT_VERSION:
self[.SWIFT_VERSION] = values.only.unwrap(orAssert: "Invalid values for 'SWIFT_VERSION': \(values)")

case .DYLIB_INSTALL_NAME_BASE:
self[.DYLIB_INSTALL_NAME_BASE] = values.only.unwrap(orAssert: "Invalid values for 'DYLIB_INSTALL_NAME_BASE': \(values)")

case .ARCHS, .IPHONEOS_DEPLOYMENT_TARGET, .SPECIALIZATION_SDK_OPTIONS:
fatalError("Unexpected BuildSettings.Declaration: \(setting)")
// Allow staging in new cases
Expand Down Expand Up @@ -953,7 +956,7 @@ extension ProjectModel.BuildSettings.MultipleValueSetting {
self = .SPECIALIZATION_SDK_OPTIONS
case .SWIFT_ACTIVE_COMPILATION_CONDITIONS:
self = .SWIFT_ACTIVE_COMPILATION_CONDITIONS
case .ARCHS, .IPHONEOS_DEPLOYMENT_TARGET, .SWIFT_VERSION:
case .ARCHS, .IPHONEOS_DEPLOYMENT_TARGET, .SWIFT_VERSION, .DYLIB_INSTALL_NAME_BASE:
return nil
// Allow staging in new cases
default:
Expand Down
8 changes: 7 additions & 1 deletion Sources/SwiftBuildSupport/PackagePIFBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ public final class PackagePIFBuilder {
case framework
case executable
case unitTest
case unitTestRunner
case bundle
case resourceBundle
case packageProduct
Expand All @@ -385,6 +386,7 @@ public final class PackagePIFBuilder {
case .framework: .framework
case .executable: .executable
case .unitTest: .unitTest
case .swiftpmTestRunner: .unitTestRunner
case .bundle: .bundle
case .packageProduct: .packageProduct
case .hostBuildTool: fatalError("Unexpected hostBuildTool type")
Expand Down Expand Up @@ -520,7 +522,11 @@ public final class PackagePIFBuilder {
settings[.WATCHOS_DEPLOYMENT_TARGET] = builder.deploymentTargets[.watchOS] ?? nil
settings[.DRIVERKIT_DEPLOYMENT_TARGET] = builder.deploymentTargets[.driverKit] ?? nil
settings[.XROS_DEPLOYMENT_TARGET] = builder.deploymentTargets[.visionOS] ?? nil
settings[.DYLIB_INSTALL_NAME_BASE] = "@rpath"

for machoPlatform in [ProjectModel.BuildSettings.Platform.macOS, .macCatalyst, .iOS, .watchOS, .tvOS, .xrOS, .driverKit] {
settings.platformSpecificSettings[machoPlatform]![.DYLIB_INSTALL_NAME_BASE]! = ["@rpath"]
}

settings[.USE_HEADERMAP] = "NO"
settings[.OTHER_SWIFT_FLAGS].lazilyInitializeAndMutate(initialValue: ["$(inherited)"]) { $0.append("-DXcode") }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -768,7 +768,7 @@ extension PackagePIFProjectBuilder {
//
// An imparted build setting on C will propagate back to both B and A.
impartedSettings[.LD_RUNPATH_SEARCH_PATHS] =
["@loader_path"] +
["$(RPATH_ORIGIN)"] +
(impartedSettings[.LD_RUNPATH_SEARCH_PATHS] ?? ["$(inherited)"])

var impartedDebugSettings = impartedSettings
Expand Down
109 changes: 106 additions & 3 deletions Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
//===----------------------------------------------------------------------===//

import Foundation
import TSCBasic
import TSCUtility

import struct Basics.AbsolutePath
Expand Down Expand Up @@ -121,13 +122,15 @@ extension PackagePIFProjectBuilder {
if mainModule.type == .test {
// FIXME: we shouldn't always include both the deep and shallow bundle paths here, but for that we'll need rdar://31867023
settings[.LD_RUNPATH_SEARCH_PATHS] = [
"@loader_path/Frameworks",
"@loader_path/../Frameworks",
"$(RPATH_ORIGIN)/Frameworks",
"$(RPATH_ORIGIN)/../Frameworks",
"$(inherited)"
]
settings[.GENERATE_INFOPLIST_FILE] = "YES"
settings[.SKIP_INSTALL] = "NO"
settings[.SWIFT_ACTIVE_COMPILATION_CONDITIONS].lazilyInitialize { ["$(inherited)"] }
// Enable index-while building for Swift compilations to facilitate discovery of XCTest tests.
settings[.SWIFT_INDEX_STORE_ENABLE] = "YES"
} else if mainModule.type == .executable {
// Setup install path for executables if it's in root of a pure Swift package.
if pifBuilder.delegate.hostsOnlyPackages && pifBuilder.delegate.isRootPackage {
Expand Down Expand Up @@ -502,9 +505,13 @@ extension PackagePIFProjectBuilder {
linkedPackageBinaries: linkedPackageBinaries,
swiftLanguageVersion: mainModule.packageSwiftLanguageVersion(manifest: packageManifest),
declaredPlatforms: self.declaredPlatforms,
deploymentTargets: self.deploymentTargets
deploymentTargets: mainTargetDeploymentTargets
)
self.builtModulesAndProducts.append(moduleOrProduct)

if moduleOrProductType == .unitTest {
try makeTestRunnerProduct(for: moduleOrProduct)
}
}

private mutating func handleProduct(
Expand Down Expand Up @@ -995,6 +1002,102 @@ extension PackagePIFProjectBuilder {
)
self.builtModulesAndProducts.append(pluginProductMetadata)
}

// MARK: - Test Runners
mutating func makeTestRunnerProduct(for unitTestProduct: PackagePIFBuilder.ModuleOrProduct) throws {
// Only generate a test runner for root packages with tests.
guard pifBuilder.delegate.isRootPackage else {
return
}

guard let unitTestModuleName = unitTestProduct.moduleName else {
throw StringError("Unit test product '\(unitTestProduct.name)' is missing a module name")
}

let name = "\(unitTestProduct.name)-test-runner"
let moduleName = "\(unitTestModuleName)_test_runner"
let guid = PackagePIFBuilder.targetGUID(forModuleName: moduleName)

let testRunnerTargetKeyPath = try self.project.addTarget { _ in
ProjectModel.Target (
id: guid,
productType: .swiftpmTestRunner,
name: name,
productName: name
)
}

var settings: BuildSettings = self.package.underlying.packageBaseBuildSettings
let impartedSettings = BuildSettings()

settings[.TARGET_NAME] = name
settings[.PACKAGE_RESOURCE_TARGET_KIND] = "regular"
settings[.PRODUCT_NAME] = "$(TARGET_NAME)"
settings[.PRODUCT_MODULE_NAME] = moduleName
settings[.PRODUCT_BUNDLE_IDENTIFIER] = "\(self.package.identity).\(name)"
.spm_mangledToBundleIdentifier()
settings[.EXECUTABLE_NAME] = name
settings[.SKIP_INSTALL] = "NO"
settings[.SWIFT_VERSION] = "5.0"
// This should eventually be set universally for all package targets/products.
settings[.LINKER_DRIVER] = "swiftc"

let deploymentTargets = unitTestProduct.deploymentTargets
settings[.MACOSX_DEPLOYMENT_TARGET] = deploymentTargets?[.macOS] ?? nil
settings[.IPHONEOS_DEPLOYMENT_TARGET] = deploymentTargets?[.iOS] ?? nil
if let deploymentTarget_macCatalyst = deploymentTargets?[.macCatalyst] ?? nil {
settings.platformSpecificSettings[.macCatalyst]![.IPHONEOS_DEPLOYMENT_TARGET] = [deploymentTarget_macCatalyst]
}
settings[.TVOS_DEPLOYMENT_TARGET] = deploymentTargets?[.tvOS] ?? nil
settings[.WATCHOS_DEPLOYMENT_TARGET] = deploymentTargets?[.watchOS] ?? nil
settings[.DRIVERKIT_DEPLOYMENT_TARGET] = deploymentTargets?[.driverKit] ?? nil
settings[.XROS_DEPLOYMENT_TARGET] = deploymentTargets?[.visionOS] ?? nil

// Add an empty sources phase so derived sources are compiled
self.project[keyPath: testRunnerTargetKeyPath].common.addSourcesBuildPhase { id in
ProjectModel.SourcesBuildPhase(id: id)
}

guard let unitTestGUID = unitTestProduct.pifTarget?.id else {
throw StringError("Unit test product '\(unitTestProduct.name)' is missing a PIF GUID")
}
self.project[keyPath: testRunnerTargetKeyPath].common.addDependency(
on: unitTestGUID,
platformFilters: [],
linkProduct: true
)

self.project[keyPath: testRunnerTargetKeyPath].common.addBuildConfig { id in
BuildConfig(
id: id,
name: "Debug",
settings: settings,
impartedBuildSettings: impartedSettings
)
}
self.project[keyPath: testRunnerTargetKeyPath].common.addBuildConfig { id in
BuildConfig(
id: id,
name: "Release",
settings: settings,
impartedBuildSettings: impartedSettings
)
}

let testRunner = PackagePIFBuilder.ModuleOrProduct(
type: .unitTestRunner,
name: name,
moduleName: moduleName,
pifTarget: .target(self.project[keyPath: testRunnerTargetKeyPath]),
indexableFileURLs: [],
headerFiles: [],
linkedPackageBinaries: [],
swiftLanguageVersion: nil,
declaredPlatforms: self.declaredPlatforms,
deploymentTargets: self.deploymentTargets
)
self.builtModulesAndProducts.append(testRunner)
}
}

// MARK: - Helper Types
Expand Down
1 change: 0 additions & 1 deletion Tests/CommandsTests/PackageCommandTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4107,7 +4107,6 @@ class PackageCommandSwiftBuildTests: PackageCommandTestCase {
}

override func testCommandPluginTestingCallbacks() async throws {
throw XCTSkip("SWBINTTODO: Requires PIF generation to adopt new test runner product type")
try XCTSkipOnWindows(because: "TSCBasic/Path.swift:969: Assertion failed, https://github.com/swiftlang/swift-package-manager/issues/8602")
try await super.testCommandPluginTestingCallbacks()
}
Expand Down
Loading