Skip to content

Commit 4788df3

Browse files
committed
Implement test discovery on platforms without an Objective-C runtime (Linux, etc.)
This is a WIP PR for implementing test discovery on linux using indexing data. The basic idea is to leverage the IndexStore library to get the test methods and then generate a LinuxMain.swift file during the build process. This PR "works" but there are a number of serious hacks that require resolving before we can merge it.
1 parent f4bbde8 commit 4788df3

File tree

13 files changed

+1278
-31
lines changed

13 files changed

+1278
-31
lines changed

Sources/Basic/Path.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,14 @@ public struct AbsolutePath: Hashable {
121121
return _impl.basename
122122
}
123123

124+
/// Returns the basename without the extension.
125+
public var basenameWithoutExt: String {
126+
if let ext = self.extension {
127+
return String(basename.dropLast(ext.count + 1))
128+
}
129+
return basename
130+
}
131+
124132
/// Suffix (including leading `.` character) if any. Note that a basename
125133
/// that starts with a `.` character is not considered a suffix, nor is a
126134
/// trailing `.` character.

Sources/Build/BuildDelegate.swift

Lines changed: 187 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,183 @@ extension SPMLLBuild.Diagnostic: DiagnosticDataConvertible {
186186
}
187187
}
188188

189+
class CustomLLBuildCommand: ExternalCommand {
190+
let ctx: BuildExecutionContext
191+
192+
required init(_ ctx: BuildExecutionContext) {
193+
self.ctx = ctx
194+
}
195+
196+
func getSignature(_ command: SPMLLBuild.Command) -> [UInt8] {
197+
return []
198+
}
199+
200+
func execute(_ command: SPMLLBuild.Command) -> Bool {
201+
fatalError("subclass responsibility")
202+
}
203+
}
204+
205+
final class TestDiscoveryCommand: CustomLLBuildCommand {
206+
207+
private func write(
208+
tests: [IndexStore.TestCaseClass],
209+
forModule module: String,
210+
to path: AbsolutePath
211+
) throws {
212+
let stream = try LocalFileOutputByteStream(path)
213+
214+
stream <<< "import XCTest" <<< "\n"
215+
stream <<< "@testable import " <<< module <<< "\n"
216+
217+
for klass in tests {
218+
stream <<< "\n"
219+
stream <<< "fileprivate extension " <<< klass.name <<< " {" <<< "\n"
220+
stream <<< indent(4) <<< "static let __allTests__\(klass.name) = [" <<< "\n"
221+
for method in klass.methods {
222+
let method = method.hasSuffix("()") ? String(method.dropLast(2)) : method
223+
stream <<< indent(8) <<< "(\"\(method)\", \(method))," <<< "\n"
224+
}
225+
stream <<< indent(4) <<< "]" <<< "\n"
226+
stream <<< "}" <<< "\n"
227+
}
228+
229+
stream <<< """
230+
func __allTests_\(module)() -> [XCTestCaseEntry] {
231+
return [\n
232+
"""
233+
234+
for klass in tests {
235+
stream <<< indent(8) <<< "testCase(\(klass.name).__allTests__\(klass.name)),\n"
236+
}
237+
238+
stream <<< """
239+
]
240+
}
241+
"""
242+
243+
stream.flush()
244+
}
245+
246+
private func execute(with tool: ToolProtocol) throws {
247+
assert(tool is TestDiscoveryTool, "Unexpected tool \(tool)")
248+
249+
let index = ctx.buildParameters.indexStore
250+
let api = try ctx.indexStoreAPI.dematerialize()
251+
let store = try IndexStore.open(store: index, api: api)
252+
253+
// FIXME: We can speed this up by having one llbuild command per object file.
254+
let tests = try tool.inputs.flatMap {
255+
try store.listTests(inObjectFile: AbsolutePath($0))
256+
}
257+
258+
let outputs = tool.outputs.compactMap{ try? AbsolutePath(validating: $0) }
259+
let testsByModule = Dictionary(grouping: tests, by: { $0.module })
260+
261+
func isMainFile(_ path: AbsolutePath) -> Bool {
262+
return path.basename == "main.swift"
263+
}
264+
265+
// Write one file for each test module.
266+
//
267+
// We could write everything in one file but that can easily run into type conflicts due
268+
// in complex packages with large number of test targets.
269+
for file in outputs {
270+
if isMainFile(file) { continue }
271+
272+
// FIXME: This is relying on implementation detail of the output but passing the
273+
// the context all the way through is not worth it right now.
274+
let module = file.basenameWithoutExt
275+
276+
guard let tests = testsByModule[module] else {
277+
// This module has no tests so just write an empty file for it.
278+
try localFileSystem.writeFileContents(file, bytes: "")
279+
continue
280+
}
281+
try write(tests: tests, forModule: module, to: file)
282+
}
283+
284+
// Write the main file.
285+
let mainFile = outputs.first(where: isMainFile)!
286+
let stream = try LocalFileOutputByteStream(mainFile)
287+
288+
stream <<< "import XCTest" <<< "\n\n"
289+
stream <<< "var tests = [XCTestCaseEntry]()" <<< "\n"
290+
for module in testsByModule.keys {
291+
stream <<< "tests += __allTests_\(module)()" <<< "\n"
292+
}
293+
stream <<< "\n"
294+
stream <<< "XCTMain(tests)" <<< "\n"
295+
296+
stream.flush()
297+
}
298+
299+
private func indent(_ spaces: Int) -> ByteStreamable {
300+
return Format.asRepeating(string: " ", count: spaces)
301+
}
302+
303+
override func execute(_ command: SPMLLBuild.Command) -> Bool {
304+
guard let tool = ctx.buildTimeCmdToolMap[command.name] else {
305+
print("command \(command.name) not registered")
306+
return false
307+
}
308+
do {
309+
try execute(with: tool)
310+
} catch {
311+
// FIXME: Shouldn't use "print" here.
312+
print("error:", error)
313+
return false
314+
}
315+
return true
316+
}
317+
}
318+
319+
private final class InProcessTool: Tool {
320+
let ctx: BuildExecutionContext
321+
322+
init(_ ctx: BuildExecutionContext) {
323+
self.ctx = ctx
324+
}
325+
326+
func createCommand(_ name: String) -> ExternalCommand {
327+
// FIXME: This should be able to dynamically look up the right command.
328+
switch ctx.buildTimeCmdToolMap[name] {
329+
case is TestDiscoveryTool:
330+
return TestDiscoveryCommand(ctx)
331+
default:
332+
fatalError("Unhandled command \(name)")
333+
}
334+
}
335+
}
336+
337+
/// The context available during build execution.
338+
public final class BuildExecutionContext {
339+
340+
/// Mapping of command-name to its tool.
341+
let buildTimeCmdToolMap: [String: ToolProtocol]
342+
343+
var indexStoreAPI: Result<IndexStoreAPI, AnyError> {
344+
indexStoreAPICache.getValue(self)
345+
}
346+
347+
let buildParameters: BuildParameters
348+
349+
public init(_ plan: BuildPlan, buildTimeCmdToolMap: [String: ToolProtocol]) {
350+
self.buildParameters = plan.buildParameters
351+
self.buildTimeCmdToolMap = buildTimeCmdToolMap
352+
}
353+
354+
// MARK:- Private
355+
356+
private var indexStoreAPICache = LazyCache(createIndexStoreAPI)
357+
private func createIndexStoreAPI() -> Result<IndexStoreAPI, AnyError> {
358+
Result {
359+
let ext = buildParameters.triple.dynamicLibraryExtension
360+
let indexStoreLib = buildParameters.toolchain.toolchainLibDir.appending(component: "libIndexStore" + ext)
361+
return try IndexStoreAPI(dylib: indexStoreLib)
362+
}
363+
}
364+
}
365+
189366
private let newLineByte: UInt8 = 10
190367
public final class BuildDelegate: BuildSystemDelegate, SwiftCompilerOutputParserDelegate {
191368
private let diagnostics: DiagnosticsEngine
@@ -201,7 +378,10 @@ public final class BuildDelegate: BuildSystemDelegate, SwiftCompilerOutputParser
201378
/// Target name keyed by llbuild command name.
202379
private let targetNames: [String: String]
203380

381+
let buildExecutionContext: BuildExecutionContext
382+
204383
public init(
384+
bctx: BuildExecutionContext,
205385
plan: BuildPlan,
206386
diagnostics: DiagnosticsEngine,
207387
outputStream: OutputByteStream,
@@ -212,6 +392,7 @@ public final class BuildDelegate: BuildSystemDelegate, SwiftCompilerOutputParser
212392
// https://forums.swift.org/t/allow-self-x-in-class-convenience-initializers/15924
213393
self.outputStream = outputStream as? ThreadSafeOutputByteStream ?? ThreadSafeOutputByteStream(outputStream)
214394
self.progressAnimation = progressAnimation
395+
self.buildExecutionContext = bctx
215396

216397
let buildConfig = plan.buildParameters.configuration.dirname
217398

@@ -231,7 +412,12 @@ public final class BuildDelegate: BuildSystemDelegate, SwiftCompilerOutputParser
231412
}
232413

233414
public func lookupTool(_ name: String) -> Tool? {
234-
return nil
415+
switch name {
416+
case TestDiscoveryTool.name:
417+
return InProcessTool(buildExecutionContext)
418+
default:
419+
return nil
420+
}
235421
}
236422

237423
public func hadCommandFailure() {

Sources/Build/BuildPlan.swift

Lines changed: 83 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ public struct BuildParameters {
128128
/// Whether to enable code coverage.
129129
public let enableCodeCoverage: Bool
130130

131+
/// Whether to enable test discovery on platforms without Objective-C runtime.
132+
public let enableTestDiscovery: Bool
133+
131134
/// Whether to enable generation of `.swiftinterface` files alongside
132135
/// `.swiftmodule`s.
133136
public let enableParseableModuleInterfaces: Bool
@@ -156,7 +159,8 @@ public struct BuildParameters {
156159
sanitizers: EnabledSanitizers = EnabledSanitizers(),
157160
enableCodeCoverage: Bool = false,
158161
indexStoreMode: IndexStoreMode = .auto,
159-
enableParseableModuleInterfaces: Bool = false
162+
enableParseableModuleInterfaces: Bool = false,
163+
enableTestDiscovery: Bool = false
160164
) {
161165
self.dataPath = dataPath
162166
self.configuration = configuration
@@ -170,6 +174,7 @@ public struct BuildParameters {
170174
self.enableCodeCoverage = enableCodeCoverage
171175
self.indexStoreMode = indexStoreMode
172176
self.enableParseableModuleInterfaces = enableParseableModuleInterfaces
177+
self.enableTestDiscovery = enableTestDiscovery
173178
}
174179

175180
/// Returns the compiler arguments for the index store, if enabled.
@@ -445,6 +450,7 @@ public final class SwiftTargetBuildDescription {
445450

446451
/// The objects in this target.
447452
var objects: [AbsolutePath] {
453+
// Otherwise, use sources from the target.
448454
return target.sources.relativePaths.map({ tempsPath.appending(RelativePath("\($0.pathString).o")) })
449455
}
450456

@@ -469,13 +475,22 @@ public final class SwiftTargetBuildDescription {
469475
/// If this target is a test target.
470476
public let isTestTarget: Bool
471477

478+
/// True if this is the test discovery target.
479+
public let testDiscoveryTarget: Bool
480+
472481
/// Create a new target description with target and build parameters.
473-
init(target: ResolvedTarget, buildParameters: BuildParameters, isTestTarget: Bool? = nil) {
482+
init(
483+
target: ResolvedTarget,
484+
buildParameters: BuildParameters,
485+
isTestTarget: Bool? = nil,
486+
testDiscoveryTarget: Bool = false
487+
) {
474488
assert(target.underlyingTarget is SwiftTarget, "underlying target type mismatch \(target)")
475489
self.target = target
476490
self.buildParameters = buildParameters
477491
// Unless mentioned explicitly, use the target type to determine if this is a test target.
478492
self.isTestTarget = isTestTarget ?? (target.type == .test)
493+
self.testDiscoveryTarget = testDiscoveryTarget
479494
}
480495

481496
/// The arguments needed to compile this target.
@@ -868,6 +883,65 @@ public class BuildPlan {
868883
/// Diagnostics Engine for emitting diagnostics.
869884
let diagnostics: DiagnosticsEngine
870885

886+
private static func planLinuxMain(
887+
_ buildParameters: BuildParameters,
888+
_ graph: PackageGraph
889+
) throws -> (ResolvedTarget, SwiftTargetBuildDescription)? {
890+
guard buildParameters.triple.isLinux() else {
891+
return nil
892+
}
893+
894+
// Currently, there can be only one test product in a package graph.
895+
guard let testProduct = graph.allProducts.first(where: { $0.type == .test }) else {
896+
return nil
897+
}
898+
899+
if !buildParameters.enableTestDiscovery {
900+
guard let linuxMainTarget = testProduct.linuxMainTarget else {
901+
throw Error.missingLinuxMain
902+
}
903+
904+
let desc = SwiftTargetBuildDescription(
905+
target: linuxMainTarget,
906+
buildParameters: buildParameters,
907+
isTestTarget: true
908+
)
909+
return (linuxMainTarget, desc)
910+
}
911+
912+
// We'll generate sources containing the test names as part of the build process.
913+
let derivedTestListDir = buildParameters.buildPath.appending(components: "testlist.derived")
914+
let mainFile = derivedTestListDir.appending(component: "main.swift")
915+
916+
var paths: [AbsolutePath] = []
917+
paths.append(mainFile)
918+
let testTargets = graph.rootPackages.flatMap{ $0.targets }.filter{ $0.type == .test }
919+
for testTarget in testTargets {
920+
let path = derivedTestListDir.appending(components: testTarget.name + ".swift")
921+
paths.append(path)
922+
}
923+
924+
let src = Sources(paths: paths, root: derivedTestListDir)
925+
926+
let swiftTarget = SwiftTarget(
927+
testDiscoverySrc: src,
928+
name: testProduct.name,
929+
dependencies: testProduct.underlyingProduct.targets)
930+
let linuxMainTarget = ResolvedTarget(
931+
target: swiftTarget,
932+
dependencies: testProduct.targets.map(ResolvedTarget.Dependency.target)
933+
)
934+
935+
let target = SwiftTargetBuildDescription(
936+
target: linuxMainTarget,
937+
buildParameters: buildParameters,
938+
isTestTarget: true,
939+
testDiscoveryTarget: true
940+
)
941+
942+
return (linuxMainTarget, target)
943+
}
944+
871945
/// Create a build plan with build parameters and a package graph.
872946
public init(
873947
buildParameters: BuildParameters,
@@ -921,19 +995,10 @@ public class BuildPlan {
921995
throw Diagnostics.fatalError
922996
}
923997

924-
if buildParameters.triple.isLinux() {
925-
// FIXME: Create a target for LinuxMain file on linux.
926-
// This will go away once it is possible to auto detect tests.
927-
let testProducts = graph.allProducts.filter({ $0.type == .test })
928-
929-
for product in testProducts {
930-
guard let linuxMainTarget = product.linuxMainTarget else {
931-
throw Error.missingLinuxMain
932-
}
933-
let target = SwiftTargetBuildDescription(
934-
target: linuxMainTarget, buildParameters: buildParameters, isTestTarget: true)
935-
targetMap[linuxMainTarget] = .swift(target)
936-
}
998+
// Plan the linux main target.
999+
if let result = try Self.planLinuxMain(buildParameters, graph) {
1000+
targetMap[result.0] = .swift(result.1)
1001+
self.linuxMainTarget = result.0
9371002
}
9381003

9391004
var productMap: [ResolvedProduct: ProductBuildDescription] = [:]
@@ -953,6 +1018,8 @@ public class BuildPlan {
9531018
try plan()
9541019
}
9551020

1021+
private var linuxMainTarget: ResolvedTarget?
1022+
9561023
static func validateDeploymentVersionOfProductDependency(
9571024
_ product: ResolvedProduct,
9581025
forTarget target: ResolvedTarget,
@@ -1094,7 +1161,7 @@ public class BuildPlan {
10941161

10951162
if buildParameters.triple.isLinux() {
10961163
if product.type == .test {
1097-
product.linuxMainTarget.map({ staticTargets.append($0) })
1164+
linuxMainTarget.map({ staticTargets.append($0) })
10981165
}
10991166
}
11001167

0 commit comments

Comments
 (0)