Skip to content

Commit 4b0827c

Browse files
committed
Fill in the implementation of running tests from a plugin
This extends SwiftTestTool, where most of the implementation already exists. A future direction would be to factor out this functionality so that it can be used from both plugins and the command line tools (both really have the same purpose).
1 parent 333c9f3 commit 4b0827c

File tree

4 files changed

+277
-13
lines changed

4 files changed

+277
-13
lines changed

Sources/Commands/SwiftPackageTool.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1207,8 +1207,7 @@ final class PluginDelegate: PluginInvocationDelegate {
12071207
}
12081208

12091209
private func doTest(subset: PluginInvocationTestSubset, parameters: PluginInvocationTestParameters) throws -> PluginInvocationTestResult {
1210-
// FIXME: To implement this we should factor out a lot of the code in SwiftTestTool.
1211-
throw StringError("Running tests from a plugin is not yet implemented")
1210+
return try SwiftTestTool.doTest(subset: subset, parameters: parameters, swiftTool: swiftTool)
12121211
}
12131212

12141213
func pluginRequestedSymbolGraph(forTarget targetName: String, options: PluginInvocationSymbolGraphOptions, completion: @escaping (Result<PluginInvocationSymbolGraphResult, Error>) -> Void) {

Sources/Commands/SwiftTestTool.swift

Lines changed: 148 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright 2015 - 2016 Apple Inc. and the Swift project authors
4+
Copyright 2015 - 2022 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See http://swift.org/LICENSE.txt for license information
@@ -11,6 +11,7 @@ See http://swift.org/CONTRIBUTORS.txt for Swift project authors
1111
import ArgumentParser
1212
import Basics
1313
import Build
14+
import Dispatch
1415
import class Foundation.ProcessInfo
1516
import PackageGraph
1617
import SPMBuildCore
@@ -209,7 +210,7 @@ public struct SwiftTestTool: SwiftCommand {
209210
switch options.mode {
210211
case .listTests:
211212
let testProducts = try buildTestsIfNeeded(swiftTool: swiftTool)
212-
let testSuites = try getTestSuites(in: testProducts, swiftTool: swiftTool)
213+
let testSuites = try Self.getTestSuites(in: testProducts, swiftTool: swiftTool, swiftOptions: swiftOptions)
213214
let tests = try testSuites
214215
.filteredTests(specifier: options.testCaseSpecifier)
215216
.skippedTests(specifier: options.testCaseSkip)
@@ -245,7 +246,7 @@ public struct SwiftTestTool: SwiftCommand {
245246
#endif
246247
let graph = try swiftTool.loadPackageGraph()
247248
let testProducts = try buildTestsIfNeeded(swiftTool: swiftTool)
248-
let testSuites = try getTestSuites(in: testProducts, swiftTool: swiftTool)
249+
let testSuites = try Self.getTestSuites(in: testProducts, swiftTool: swiftTool, swiftOptions: swiftOptions)
249250
let allTestSuites = testSuites.values.flatMap { $0 }
250251
let generator = LinuxMainGenerator(graph: graph, testSuites: allTestSuites)
251252
try generator.generate()
@@ -278,7 +279,7 @@ public struct SwiftTestTool: SwiftCommand {
278279
}
279280

280281
// Find the tests we need to run.
281-
let testSuites = try getTestSuites(in: testProducts, swiftTool: swiftTool)
282+
let testSuites = try Self.getTestSuites(in: testProducts, swiftTool: swiftTool, swiftOptions: swiftOptions)
282283
let tests = try testSuites
283284
.filteredTests(specifier: options.testCaseSpecifier)
284285
.skippedTests(specifier: options.testCaseSkip)
@@ -317,7 +318,7 @@ public struct SwiftTestTool: SwiftCommand {
317318
case .runParallel:
318319
let toolchain = try swiftTool.getToolchain()
319320
let testProducts = try buildTestsIfNeeded(swiftTool: swiftTool)
320-
let testSuites = try getTestSuites(in: testProducts, swiftTool: swiftTool)
321+
let testSuites = try Self.getTestSuites(in: testProducts, swiftTool: swiftTool, swiftOptions: swiftOptions)
321322
let tests = try testSuites
322323
.filteredTests(specifier: options.testCaseSpecifier)
323324
.skippedTests(specifier: options.testCaseSkip)
@@ -465,7 +466,7 @@ public struct SwiftTestTool: SwiftCommand {
465466
/// Note: It is a fatalError if we are not able to locate the tool.
466467
///
467468
/// - Returns: Path to XCTestHelper tool.
468-
private func xctestHelperPath(swiftTool: SwiftTool) throws -> AbsolutePath {
469+
private static func xctestHelperPath(swiftTool: SwiftTool) throws -> AbsolutePath {
469470
let xctestHelperBin = "swiftpm-xctest-helper"
470471
let binDirectory = AbsolutePath(CommandLine.arguments.first!,
471472
relativeTo: swiftTool.originalWorkingDirectory).parentDirectory
@@ -483,9 +484,9 @@ public struct SwiftTestTool: SwiftCommand {
483484
throw InternalError("XCTestHelper binary not found.")
484485
}
485486

486-
fileprivate func getTestSuites(in testProducts: [BuiltTestProduct], swiftTool: SwiftTool) throws -> [AbsolutePath: [TestSuite]] {
487+
fileprivate static func getTestSuites(in testProducts: [BuiltTestProduct], swiftTool: SwiftTool, swiftOptions: SwiftToolOptions) throws -> [AbsolutePath: [TestSuite]] {
487488
let testSuitesByProduct = try testProducts
488-
.map { try ($0.bundlePath, self.getTestSuites(fromTestAt: $0.bundlePath, swiftTool: swiftTool)) }
489+
.map { try ($0.bundlePath, Self.getTestSuites(fromTestAt: $0.bundlePath, swiftTool: swiftTool, swiftOptions: swiftOptions)) }
489490
return Dictionary(uniqueKeysWithValues: testSuitesByProduct)
490491
}
491492

@@ -499,7 +500,7 @@ public struct SwiftTestTool: SwiftCommand {
499500
/// - Throws: TestError, SystemError, TSCUtility.Error
500501
///
501502
/// - Returns: Array of TestSuite
502-
fileprivate func getTestSuites(fromTestAt path: AbsolutePath, swiftTool: SwiftTool) throws -> [TestSuite] {
503+
fileprivate static func getTestSuites(fromTestAt path: AbsolutePath, swiftTool: SwiftTool, swiftOptions: SwiftToolOptions) throws -> [TestSuite] {
503504
// Run the correct tool.
504505
#if os(macOS)
505506
let data: String = try withTemporaryFile { tempFile in
@@ -1139,3 +1140,141 @@ private extension SwiftTool {
11391140
return parameters
11401141
}
11411142
}
1143+
1144+
extension SwiftTestTool {
1145+
1146+
public static func doTest(subset: PluginInvocationTestSubset, parameters: PluginInvocationTestParameters, swiftTool: SwiftTool) throws -> PluginInvocationTestResult {
1147+
// Build the tests. Ideally we should only build those that match the subset, but we don't have a way to know which ones they are until we've built them and can examine the binaries.
1148+
let toolchain = try swiftTool.getToolchain()
1149+
var buildParameters = try swiftTool.buildParameters()
1150+
buildParameters.enableTestability = true
1151+
buildParameters.enableCodeCoverage = parameters.enableCodeCoverage
1152+
let buildSystem = try swiftTool.createBuildSystem(buildParameters: buildParameters)
1153+
try buildSystem.build(subset: .allIncludingTests)
1154+
1155+
// Clean out the code coverage directory that may contain stale `profraw` files from a previous run of the code coverage tool.
1156+
if parameters.enableCodeCoverage {
1157+
try localFileSystem.removeFileTree(buildParameters.codeCovPath)
1158+
}
1159+
1160+
// Construct the environment we'll pass down to the tests.
1161+
var environmentOptions = swiftTool.options
1162+
environmentOptions.shouldEnableCodeCoverage = parameters.enableCodeCoverage
1163+
let testEnvironment = try constructTestEnvironment(
1164+
toolchain: toolchain,
1165+
options: environmentOptions,
1166+
buildParameters: buildParameters)
1167+
1168+
// Iterate over the tests and run those that match the filter.
1169+
var testTargetResults: [PluginInvocationTestResult.TestTarget] = []
1170+
var numFailedTests = 0
1171+
for testProduct in buildSystem.builtTestProducts {
1172+
// Get the test suites in the bundle. Each is just a container for test cases.
1173+
let testSuites = try Self.getTestSuites(fromTestAt: testProduct.bundlePath, swiftTool: swiftTool, swiftOptions: swiftTool.options)
1174+
for testSuite in testSuites {
1175+
// Each test suite is just a container for test cases (confusingly called "tests", though they are test cases).
1176+
for testCase in testSuite.tests {
1177+
// Each test case corresponds to a combination of target and a XCTestCase, and is a collection of tests that can actually be run.
1178+
var testResults: [PluginInvocationTestResult.TestTarget.TestCase.Test] = []
1179+
for testName in testCase.tests {
1180+
// Check if we should filter out this test.
1181+
let testSpecifier = testCase.name + "/" + testName
1182+
if case .filtered(let regexes) = subset {
1183+
guard regexes.contains(where: { testSpecifier.range(of: $0, options: .regularExpression) != nil }) else {
1184+
continue
1185+
}
1186+
}
1187+
1188+
// Configure a test runner.
1189+
let testRunner = TestRunner(
1190+
bundlePaths: [testProduct.bundlePath],
1191+
xctestArg: testSpecifier,
1192+
processSet: swiftTool.processSet,
1193+
toolchain: toolchain,
1194+
testEnv: testEnvironment,
1195+
outputStream: swiftTool.outputStream,
1196+
observabilityScope: swiftTool.observabilityScope)
1197+
1198+
// Run the test — for now we run the sequentially so we can capture accurate timing results.
1199+
let startTime = DispatchTime.now()
1200+
let (success, _) = testRunner.test(writeToOutputStream: false)
1201+
let duration = startTime.distance(to: .now()).asSeconds ?? 0.0
1202+
numFailedTests += success ? 0 : 1
1203+
testResults.append(.init(name: testName, result: success ? .succeeded : .failed, duration: duration))
1204+
}
1205+
1206+
// Don't add any results if we didn't run any tests.
1207+
if testResults.isEmpty { continue }
1208+
1209+
// Otherwise we either create a new create a new target result or add to the previous one, depending on whether the target name is the same.
1210+
let testTargetName = testCase.name.prefix(while: { $0 != "." })
1211+
if let lastTestTargetName = testTargetResults.last?.name, testTargetName == lastTestTargetName {
1212+
// Same as last one, just extend its list of cases. We know we have a last one at this point.
1213+
testTargetResults[testTargetResults.count-1].testCases.append(.init(name: testCase.name, tests: testResults))
1214+
}
1215+
else {
1216+
// Not the same, so start a new target result.
1217+
testTargetResults.append(.init(name: String(testTargetName), testCases: [.init(name: testCase.name, tests: testResults)]))
1218+
}
1219+
}
1220+
}
1221+
}
1222+
1223+
// Deal with code coverage, if enabled.
1224+
let codeCoverageDataFile: AbsolutePath?
1225+
if parameters.enableCodeCoverage {
1226+
// Use `llvm-prof` to merge all the `.profraw` files into a single `.profdata` file.
1227+
let mergedCovFile = buildParameters.codeCovDataFile
1228+
let codeCovFileNames = try localFileSystem.getDirectoryContents(buildParameters.codeCovPath)
1229+
var llvmProfCommand = [try toolchain.getLLVMProf().pathString]
1230+
llvmProfCommand += ["merge", "-sparse"]
1231+
for fileName in codeCovFileNames where fileName.hasSuffix(".profraw") {
1232+
let filePath = buildParameters.codeCovPath.appending(component: fileName)
1233+
llvmProfCommand.append(filePath.pathString)
1234+
}
1235+
llvmProfCommand += ["-o", mergedCovFile.pathString]
1236+
try Process.checkNonZeroExit(arguments: llvmProfCommand)
1237+
1238+
// Use `llvm-cov` to export the merged `.profdata` file contents in JSON form.
1239+
var llvmCovCommand = [try toolchain.getLLVMCov().pathString]
1240+
llvmCovCommand += ["export", "-instr-profile=\(mergedCovFile.pathString)"]
1241+
for product in buildSystem.builtTestProducts {
1242+
llvmCovCommand.append("-object")
1243+
llvmCovCommand.append(product.binaryPath.pathString)
1244+
}
1245+
// We get the output on stdout, and have to write it to a JSON ourselves.
1246+
let jsonOutput = try Process.checkNonZeroExit(arguments: llvmCovCommand)
1247+
let jsonCovFile = buildParameters.codeCovDataFile.parentDirectory.appending(component: buildParameters.codeCovDataFile.basenameWithoutExt + ".json")
1248+
try localFileSystem.writeFileContents(jsonCovFile, string: jsonOutput)
1249+
1250+
// Return the path of the exported code coverage data file.
1251+
codeCoverageDataFile = jsonCovFile
1252+
}
1253+
else {
1254+
codeCoverageDataFile = nil
1255+
}
1256+
1257+
// Return the results to the plugin. We only consider the test run a success if no test failed.
1258+
return PluginInvocationTestResult(
1259+
succeeded: (numFailedTests == 0),
1260+
testTargets: testTargetResults,
1261+
codeCoverageDataFile: codeCoverageDataFile?.pathString)
1262+
}
1263+
}
1264+
1265+
fileprivate extension DispatchTimeInterval {
1266+
var asSeconds: Double? {
1267+
switch self {
1268+
case .seconds(let value):
1269+
return Double(value)
1270+
case .milliseconds(let value):
1271+
return Double(value) / 1000
1272+
case .microseconds(let value):
1273+
return Double(value) / 1_000_000
1274+
case .nanoseconds(let value):
1275+
return Double(value) / 1_000_000_000
1276+
default:
1277+
return .none
1278+
}
1279+
}
1280+
}

Sources/Workspace/UserToolchain.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,12 +223,12 @@ public final class UserToolchain: Toolchain {
223223

224224
/// Returns the path to llvm-cov tool.
225225
public func getLLVMCov() throws -> AbsolutePath {
226-
return try UserToolchain.getTool("llvm-cov", binDir: self.destination.binDir)
226+
return try UserToolchain.getTool("llvm-cov", binDir: self.swiftCompilerPath.parentDirectory)
227227
}
228228

229229
/// Returns the path to llvm-prof tool.
230230
public func getLLVMProf() throws -> AbsolutePath {
231-
return try UserToolchain.getTool("llvm-profdata", binDir: self.destination.binDir)
231+
return try UserToolchain.getTool("llvm-profdata", binDir: self.swiftCompilerPath.parentDirectory)
232232
}
233233

234234
public func getSwiftAPIDigester() throws -> AbsolutePath {

0 commit comments

Comments
 (0)