Skip to content

Commit d65c52c

Browse files
authored
Merge pull request #2174 from aciidb0mb3r/linux-test-discovery
[WIP] Implement test discovery on linux
2 parents 2cdb635 + e36d674 commit d65c52c

File tree

12 files changed

+1276
-30
lines changed

12 files changed

+1276
-30
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: 82 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.
@@ -469,13 +474,22 @@ public final class SwiftTargetBuildDescription {
469474
/// If this target is a test target.
470475
public let isTestTarget: Bool
471476

477+
/// True if this is the test discovery target.
478+
public let testDiscoveryTarget: Bool
479+
472480
/// Create a new target description with target and build parameters.
473-
init(target: ResolvedTarget, buildParameters: BuildParameters, isTestTarget: Bool? = nil) {
481+
init(
482+
target: ResolvedTarget,
483+
buildParameters: BuildParameters,
484+
isTestTarget: Bool? = nil,
485+
testDiscoveryTarget: Bool = false
486+
) {
474487
assert(target.underlyingTarget is SwiftTarget, "underlying target type mismatch \(target)")
475488
self.target = target
476489
self.buildParameters = buildParameters
477490
// Unless mentioned explicitly, use the target type to determine if this is a test target.
478491
self.isTestTarget = isTestTarget ?? (target.type == .test)
492+
self.testDiscoveryTarget = testDiscoveryTarget
479493
}
480494

481495
/// The arguments needed to compile this target.
@@ -868,6 +882,65 @@ public class BuildPlan {
868882
/// Diagnostics Engine for emitting diagnostics.
869883
let diagnostics: DiagnosticsEngine
870884

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

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-
}
997+
// Plan the linux main target.
998+
if let result = try Self.planLinuxMain(buildParameters, graph) {
999+
targetMap[result.0] = .swift(result.1)
1000+
self.linuxMainTarget = result.0
9371001
}
9381002

9391003
var productMap: [ResolvedProduct: ProductBuildDescription] = [:]
@@ -953,6 +1017,8 @@ public class BuildPlan {
9531017
try plan()
9541018
}
9551019

1020+
private var linuxMainTarget: ResolvedTarget?
1021+
9561022
static func validateDeploymentVersionOfProductDependency(
9571023
_ product: ResolvedProduct,
9581024
forTarget target: ResolvedTarget,
@@ -1094,7 +1160,7 @@ public class BuildPlan {
10941160

10951161
if buildParameters.triple.isLinux() {
10961162
if product.type == .test {
1097-
product.linuxMainTarget.map({ staticTargets.append($0) })
1163+
linuxMainTarget.map({ staticTargets.append($0) })
10981164
}
10991165
}
11001166

0 commit comments

Comments
 (0)