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)

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
12 changes: 11 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,15 @@ 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"]
}
for elfPlatform in [ProjectModel.BuildSettings.Platform.linux, .android, .openbsd, .freebsd] {
// FIXME: Treating $ORIGIN as part of the soname is not really the right thing to do here
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.

I think we can just not set it at all. The soname on ELF platforms should just be the leaf name. $ORIGIN is only used in rpaths. I realize I might've misled you in my previous comment, I was misremembering what the conventions were before.

(Referencing Qbs, that's what we did there -- libraries set cpp.sonamePrefix (DYLIB_INSTALL_NAME_BASE equivalent) to @rpath on Darwin and nothing elsewhere, while cpp.rpaths (LD_RUNPATH_SEARCH_PATHS equivalent) referenced cpp.rpathOrigin, a helper variable which is @loader_path on Darwin and $ORIGIN on ELF)

settings.platformSpecificSettings[elfPlatform]![.DYLIB_INSTALL_NAME_BASE]! = ["$ORIGIN"]
}

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

Expand Down
105 changes: 104 additions & 1 deletion 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 @@ -128,6 +129,8 @@ extension PackagePIFProjectBuilder {
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
3 changes: 2 additions & 1 deletion Tests/CommandsTests/PackageCommandTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4106,11 +4106,12 @@ class PackageCommandSwiftBuildTests: PackageCommandTestCase {
throw XCTSkip("SWBINTTODO: Build plan is not currently supported")
}

#if !os(macOS)
Copy link
Contributor

Choose a reason for hiding this comment

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

question: why are we not executing this test on macOS?

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()
}
#endif

override func testCommandPluginTargetBuilds() async throws {
try XCTSkipOnWindows(because: "TSCBasic/Path.swift:969: Assertion failed, https://github.com/swiftlang/swift-package-manager/issues/8602")
Expand Down
Loading