Skip to content

Commit 432dd51

Browse files
committed
[WIP] Build Swift Testing and XCTest content in a single product.
This PR refactors the previously-experimental Swift Testing support logic so that only a single build product is produced when using both XCTest and Swift Testing, and detection of Swift Testing usage is no longer needed at compile time. On macOS, Xcode 16 is responsible for hosting Swift Testing content, so additional changes may be needed in Xcode to support this refactoring. Such changes are beyond the purview of the Swift open source project.
1 parent 5e54c6b commit 432dd51

File tree

14 files changed

+286
-434
lines changed

14 files changed

+286
-434
lines changed

Sources/Build/BuildManifest/LLBuildManifestBuilder.swift

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,7 @@ public class LLBuildManifestBuilder {
112112
}
113113
}
114114

115-
if self.plan.destinationBuildParameters.testingParameters.library == .xctest {
116-
try self.addTestDiscoveryGenerationCommand()
117-
}
115+
try self.addTestDiscoveryGenerationCommand()
118116
try self.addTestEntryPointGenerationCommand()
119117

120118
// Create command for all products in the plan.
@@ -310,9 +308,7 @@ extension LLBuildManifestBuilder {
310308

311309
let outputs = testEntryPointTarget.target.sources.paths
312310

313-
let mainFileName = TestEntryPointTool.mainFileName(
314-
for: self.plan.destinationBuildParameters.testingParameters.library
315-
)
311+
let mainFileName = TestEntryPointTool.mainFileName
316312
guard let mainOutput = (outputs.first { $0.basename == mainFileName }) else {
317313
throw InternalError("main output (\(mainFileName)) not found")
318314
}

Sources/Build/BuildPlan/BuildPlan+Test.swift

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ extension BuildPlan {
116116
resolvedTargetDependencies: [ResolvedModule.Dependency]
117117
) throws -> SwiftModuleBuildDescription {
118118
let entryPointDerivedDir = destinationBuildParameters.buildPath.appending(components: "\(testProduct.name).derived")
119-
let entryPointMainFileName = TestEntryPointTool.mainFileName(for: destinationBuildParameters.testingParameters.library)
119+
let entryPointMainFileName = TestEntryPointTool.mainFileName
120120
let entryPointMainFile = entryPointDerivedDir.appending(component: entryPointMainFileName)
121121
let entryPointSources = Sources(paths: [entryPointMainFile], root: entryPointDerivedDir)
122122

@@ -153,16 +153,9 @@ extension BuildPlan {
153153
let swiftTargetDependencies: [Module.Dependency]
154154
let resolvedTargetDependencies: [ResolvedModule.Dependency]
155155

156-
switch destinationBuildParameters.testingParameters.library {
157-
case .xctest:
158-
discoveryTargets = try generateDiscoveryTargets()
159-
swiftTargetDependencies = [.module(discoveryTargets!.target, conditions: [])]
160-
resolvedTargetDependencies = [.module(discoveryTargets!.resolved, conditions: [])]
161-
case .swiftTesting:
162-
discoveryTargets = nil
163-
swiftTargetDependencies = testProduct.modules.map { .module($0.underlying, conditions: []) }
164-
resolvedTargetDependencies = testProduct.modules.map { .module($0, conditions: []) }
165-
}
156+
discoveryTargets = try generateDiscoveryTargets()
157+
swiftTargetDependencies = [.module(discoveryTargets!.target, conditions: [])]
158+
resolvedTargetDependencies = [.module(discoveryTargets!.resolved, conditions: [])]
166159

167160
if let entryPointResolvedTarget = testProduct.testEntryPointModule {
168161
if isEntryPointPathSpecifiedExplicitly || explicitlyEnabledDiscovery {

Sources/Build/LLBuildCommands.swift

Lines changed: 107 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ extension IndexStore.TestCaseClass.TestMethod {
5050
}
5151

5252
extension TestEntryPointTool {
53-
public static func mainFileName(for library: BuildParameters.Testing.Library) -> String {
54-
"runner-\(library).swift"
53+
public static var mainFileName: String {
54+
"runner.swift"
5555
}
5656
}
5757

@@ -105,74 +105,67 @@ final class TestDiscoveryCommand: CustomLLBuildCommand, TestBuildCommand {
105105
private func execute(fileSystem: Basics.FileSystem, tool: TestDiscoveryTool) throws {
106106
let outputs = tool.outputs.compactMap { try? AbsolutePath(validating: $0.name) }
107107

108-
switch self.context.productsBuildParameters.testingParameters.library {
109-
case .swiftTesting:
110-
for file in outputs {
111-
try fileSystem.writeIfChanged(path: file, string: "")
112-
}
113-
case .xctest:
114-
let index = self.context.productsBuildParameters.indexStore
115-
let api = try self.context.indexStoreAPI.get()
116-
let store = try IndexStore.open(store: TSCAbsolutePath(index), api: api)
117-
118-
// FIXME: We can speed this up by having one llbuild command per object file.
119-
let tests = try store
120-
.listTests(in: tool.inputs.map { try TSCAbsolutePath(AbsolutePath(validating: $0.name)) })
121-
122-
let testsByModule = Dictionary(grouping: tests, by: { $0.module.spm_mangledToC99ExtendedIdentifier() })
123-
124-
// Find the main file path.
125-
guard let mainFile = outputs.first(where: { path in
126-
path.basename == TestDiscoveryTool.mainFileName
127-
}) else {
128-
throw InternalError("main output (\(TestDiscoveryTool.mainFileName)) not found")
129-
}
108+
let index = self.context.productsBuildParameters.indexStore
109+
let api = try self.context.indexStoreAPI.get()
110+
let store = try IndexStore.open(store: TSCAbsolutePath(index), api: api)
130111

131-
// Write one file for each test module.
132-
//
133-
// We could write everything in one file but that can easily run into type conflicts due
134-
// in complex packages with large number of test modules.
135-
for file in outputs where file != mainFile {
136-
// FIXME: This is relying on implementation detail of the output but passing the
137-
// the context all the way through is not worth it right now.
138-
let module = file.basenameWithoutExt.spm_mangledToC99ExtendedIdentifier()
139-
140-
guard let tests = testsByModule[module] else {
141-
// This module has no tests so just write an empty file for it.
142-
try fileSystem.writeFileContents(file, bytes: "")
143-
continue
144-
}
145-
try write(
146-
tests: tests,
147-
forModule: module,
148-
fileSystem: fileSystem,
149-
path: file
150-
)
112+
// FIXME: We can speed this up by having one llbuild command per object file.
113+
let tests = try store
114+
.listTests(in: tool.inputs.map { try TSCAbsolutePath(AbsolutePath(validating: $0.name)) })
115+
116+
let testsByModule = Dictionary(grouping: tests, by: { $0.module.spm_mangledToC99ExtendedIdentifier() })
117+
118+
// Find the main file path.
119+
guard let mainFile = outputs.first(where: { path in
120+
path.basename == TestDiscoveryTool.mainFileName
121+
}) else {
122+
throw InternalError("main output (\(TestDiscoveryTool.mainFileName)) not found")
123+
}
124+
125+
// Write one file for each test module.
126+
//
127+
// We could write everything in one file but that can easily run into type conflicts due
128+
// in complex packages with large number of test modules.
129+
for file in outputs where file != mainFile {
130+
// FIXME: This is relying on implementation detail of the output but passing the
131+
// the context all the way through is not worth it right now.
132+
let module = file.basenameWithoutExt.spm_mangledToC99ExtendedIdentifier()
133+
134+
guard let tests = testsByModule[module] else {
135+
// This module has no tests so just write an empty file for it.
136+
try fileSystem.writeFileContents(file, bytes: "")
137+
continue
151138
}
139+
try write(
140+
tests: tests,
141+
forModule: module,
142+
fileSystem: fileSystem,
143+
path: file
144+
)
145+
}
152146

153-
let testsKeyword = tests.isEmpty ? "let" : "var"
147+
let testsKeyword = tests.isEmpty ? "let" : "var"
154148

155-
// Write the main file.
156-
let stream = try LocalFileOutputByteStream(mainFile)
149+
// Write the main file.
150+
let stream = try LocalFileOutputByteStream(mainFile)
157151

158-
stream.send(
159-
#"""
160-
import XCTest
152+
stream.send(
153+
#"""
154+
import XCTest
161155
162-
@available(*, deprecated, message: "Not actually deprecated. Marked as deprecated to allow inclusion of deprecated tests (which test deprecated functionality) without warnings")
163-
@MainActor
164-
public func __allDiscoveredTests() -> [XCTestCaseEntry] {
165-
\#(testsKeyword) tests = [XCTestCaseEntry]()
156+
@available(*, deprecated, message: "Not actually deprecated. Marked as deprecated to allow inclusion of deprecated tests (which test deprecated functionality) without warnings")
157+
@MainActor
158+
public func __allDiscoveredTests() -> [XCTestCaseEntry] {
159+
\#(testsKeyword) tests = [XCTestCaseEntry]()
166160
167-
\#(testsByModule.keys.map { "tests += __\($0)__allTests()" }.joined(separator: "\n "))
161+
\#(testsByModule.keys.map { "tests += __\($0)__allTests()" }.joined(separator: "\n "))
168162

169-
return tests
170-
}
171-
"""#
172-
)
163+
return tests
164+
}
165+
"""#
166+
)
173167

174-
stream.flush()
175-
}
168+
stream.flush()
176169
}
177170

178171
override func execute(
@@ -201,9 +194,7 @@ final class TestEntryPointCommand: CustomLLBuildCommand, TestBuildCommand {
201194
let outputs = tool.outputs.compactMap { try? AbsolutePath(validating: $0.name) }
202195

203196
// Find the main output file
204-
let mainFileName = TestEntryPointTool.mainFileName(
205-
for: self.context.productsBuildParameters.testingParameters.library
206-
)
197+
let mainFileName = TestEntryPointTool.mainFileName
207198
guard let mainFile = outputs.first(where: { path in
208199
path.basename == mainFileName
209200
}) else {
@@ -213,62 +204,67 @@ final class TestEntryPointCommand: CustomLLBuildCommand, TestBuildCommand {
213204
// Write the main file.
214205
let stream = try LocalFileOutputByteStream(mainFile)
215206

216-
switch self.context.productsBuildParameters.testingParameters.library {
217-
case .swiftTesting:
218-
stream.send(
219-
#"""
220-
#if canImport(Testing)
221-
import Testing
222-
#endif
207+
// Find the inputs, which are the names of the test discovery module(s)
208+
let inputs = tool.inputs.compactMap { try? AbsolutePath(validating: $0.name) }
209+
let discoveryModuleNames = inputs.map(\.basenameWithoutExt)
223210

224-
@main struct Runner {
225-
static func main() async {
226-
#if canImport(Testing)
227-
await Testing.__swiftPMEntryPoint() as Never
228-
#endif
229-
}
230-
}
231-
"""#
232-
)
233-
case .xctest:
234-
// Find the inputs, which are the names of the test discovery module(s)
235-
let inputs = tool.inputs.compactMap { try? AbsolutePath(validating: $0.name) }
236-
let discoveryModuleNames = inputs.map(\.basenameWithoutExt)
237-
238-
let testObservabilitySetup: String
239-
let buildParameters = self.context.productsBuildParameters
240-
if buildParameters.testingParameters.experimentalTestOutput && buildParameters.triple.supportsTestSummary {
241-
testObservabilitySetup = "_ = SwiftPMXCTestObserver()\n"
242-
} else {
243-
testObservabilitySetup = ""
244-
}
211+
let testObservabilitySetup: String
212+
let buildParameters = self.context.productsBuildParameters
213+
if buildParameters.testingParameters.experimentalTestOutput && buildParameters.triple.supportsTestSummary {
214+
testObservabilitySetup = "_ = SwiftPMXCTestObserver()\n"
215+
} else {
216+
testObservabilitySetup = ""
217+
}
245218

246-
stream.send(
247-
#"""
248-
\#(generateTestObservationCode(buildParameters: buildParameters))
219+
stream.send(
220+
#"""
221+
#if canImport(Testing)
222+
import Testing
223+
#endif
249224
250-
import XCTest
251-
\#(discoveryModuleNames.map { "import \($0)" }.joined(separator: "\n"))
225+
#if canImport(XCTest)
226+
\#(generateTestObservationCode(buildParameters: buildParameters))
252227

253-
@main
254-
@available(*, deprecated, message: "Not actually deprecated. Marked as deprecated to allow inclusion of deprecated tests (which test deprecated functionality) without warnings")
255-
struct Runner {
256-
#if os(WASI)
257-
/// On WASI, we can't block the main thread, so XCTestMain is defined as async.
258-
static func main() async {
259-
\#(testObservabilitySetup)
260-
await XCTMain(__allDiscoveredTests()) as Never
228+
import XCTest
229+
\#(discoveryModuleNames.map { "import \($0)" }.joined(separator: "\n"))
230+
#endif
231+
232+
@main
233+
@available(*, deprecated, message: "Not actually deprecated. Marked as deprecated to allow inclusion of deprecated tests (which test deprecated functionality) without warnings")
234+
struct Runner {
235+
static func which() -> String {
236+
// HACK: abstract this check properly!
237+
let args = CommandLine.arguments
238+
if args.contains("--testing-library=swift-testing") {
239+
return "swift-testing"
240+
} else if args.contains("--testing-library=xctest") {
241+
return "XCTest"
242+
}
243+
// Fallback if not specified: run XCTest (legacy behavior)
244+
return "XCTest"
245+
}
246+
static func main() async {
247+
let which = Self.which()
248+
#if canImport(Testing)
249+
if which == "swift-testing" {
250+
await Testing.__swiftPMEntryPoint() as Never
261251
}
262-
#else
263-
static func main() {
252+
#endif
253+
#if canImport(XCTest)
254+
if which == "XCTest" {
264255
\#(testObservabilitySetup)
256+
#if os(WASI)
257+
/// On WASI, we can't block the main thread, so XCTestMain is defined as async.
258+
await XCTMain(__allDiscoveredTests()) as Never
259+
#else
265260
XCTMain(__allDiscoveredTests()) as Never
261+
#endif
266262
}
267-
#endif
263+
#endif
268264
}
269-
"""#
270-
)
271-
}
265+
}
266+
"""#
267+
)
272268

273269
stream.flush()
274270
}

Sources/Build/LLBuildDescription.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,7 @@ public struct BuildDescription: Codable {
140140
try BuiltTestProduct(
141141
productName: desc.product.name,
142142
binaryPath: desc.binaryPath,
143-
packagePath: desc.package.path,
144-
library: desc.buildParameters.testingParameters.library
143+
packagePath: desc.package.path
145144
)
146145
}
147146
self.pluginDescriptions = pluginDescriptions

Sources/Commands/PackageCommands/Init.swift

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,20 +54,11 @@ extension SwiftPackageCommand {
5454
throw InternalError("Could not find the current working directory")
5555
}
5656

57-
// NOTE: Do not use testLibraryOptions.enabledTestingLibraries(swiftCommandState:) here
58-
// because the package doesn't exist yet, so there are no dependencies for it to query.
59-
var testingLibraries: Set<BuildParameters.Testing.Library> = []
60-
if testLibraryOptions.enableXCTestSupport {
61-
testingLibraries.insert(.xctest)
62-
}
63-
if testLibraryOptions.explicitlyEnableSwiftTestingLibrarySupport == true {
64-
testingLibraries.insert(.swiftTesting)
65-
}
6657
let packageName = self.packageName ?? cwd.basename
6758
let initPackage = try InitPackage(
6859
name: packageName,
6960
packageType: initMode,
70-
supportedTestingLibraries: testingLibraries,
61+
supportedTestingLibraries: testLibraryOptions.enabledTestingLibraries,
7162
destinationPath: cwd,
7263
installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration,
7364
fileSystem: swiftCommandState.fileSystem

Sources/Commands/SwiftBuildCommand.swift

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -175,23 +175,20 @@ public struct SwiftBuildCommand: AsyncSwiftCommand {
175175
}
176176

177177
if case .allIncludingTests = subset {
178-
func updateTestingParameters(of buildParameters: inout BuildParameters, library: BuildParameters.Testing.Library) {
178+
func updateTestingParameters(of buildParameters: inout BuildParameters) {
179179
buildParameters.testingParameters = .init(
180180
configuration: buildParameters.configuration,
181181
targetTriple: buildParameters.triple,
182182
enableCodeCoverage: buildParameters.testingParameters.enableCodeCoverage,
183183
enableTestability: buildParameters.testingParameters.enableTestability,
184184
experimentalTestOutput: buildParameters.testingParameters.experimentalTestOutput,
185185
forceTestDiscovery: globalOptions.build.enableTestDiscovery,
186-
testEntryPointPath: globalOptions.build.testEntryPointPath,
187-
library: library
186+
testEntryPointPath: globalOptions.build.testEntryPointPath
188187
)
189188
}
190-
for library in try options.testLibraryOptions.enabledTestingLibraries(swiftCommandState: swiftCommandState) {
191-
updateTestingParameters(of: &productsBuildParameters, library: library)
192-
updateTestingParameters(of: &toolsBuildParameters, library: library)
193-
try build(swiftCommandState, subset: subset, productsBuildParameters: productsBuildParameters, toolsBuildParameters: toolsBuildParameters)
194-
}
189+
updateTestingParameters(of: &productsBuildParameters)
190+
updateTestingParameters(of: &toolsBuildParameters)
191+
try build(swiftCommandState, subset: subset, productsBuildParameters: productsBuildParameters, toolsBuildParameters: toolsBuildParameters)
195192
} else {
196193
try build(swiftCommandState, subset: subset, productsBuildParameters: productsBuildParameters, toolsBuildParameters: toolsBuildParameters)
197194
}

0 commit comments

Comments
 (0)