Skip to content

Commit beac985

Browse files
authored
Allow running tests from a command plugin (#4002)
This wraps up the last piece for SE-0332, including support for filtering and for capturing code coverage while the tests are running. This uses most of the same implementation that existed in a very specify way in SwiftTestTool.swift, factoring it out into a new shared TestingSupport.swift file and making it more generic. Future refactoring should move it down to a new module that provides the shared test support for use from everywhere.
1 parent 07e6b9c commit beac985

File tree

7 files changed

+405
-143
lines changed

7 files changed

+405
-143
lines changed

Sources/Basics/DispatchTimeInterval+Extensions.swift

Lines changed: 4 additions & 4 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 (c) 2020 Apple Inc. and the Swift project authors
4+
Copyright (c) 2020-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
@@ -12,7 +12,7 @@ import Dispatch
1212
import struct Foundation.TimeInterval
1313

1414
extension DispatchTimeInterval {
15-
func timeInterval() -> TimeInterval? {
15+
public func timeInterval() -> TimeInterval? {
1616
switch self {
1717
case .seconds(let value):
1818
return Double(value)
@@ -27,7 +27,7 @@ extension DispatchTimeInterval {
2727
}
2828
}
2929

30-
func milliseconds() -> Int? {
30+
public func milliseconds() -> Int? {
3131
switch self {
3232
case .seconds(let value):
3333
return value.multipliedReportingOverflow(by: 1000).partialValue
@@ -42,7 +42,7 @@ extension DispatchTimeInterval {
4242
}
4343
}
4444

45-
func seconds() -> Int? {
45+
public func seconds() -> Int? {
4646
switch self {
4747
case .seconds(let value):
4848
return value

Sources/Commands/CMakeLists.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# This source file is part of the Swift.org open source project
22
#
3-
# Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors
3+
# Copyright (c) 2014 - 2022 Apple Inc. and the Swift project authors
44
# Licensed under Apache License v2.0 with Runtime Library Exception
55
#
66
# See http://swift.org/LICENSE.txt for license information
@@ -28,6 +28,7 @@ add_library(Commands
2828
SwiftTestTool.swift
2929
SwiftTool.swift
3030
SymbolGraphExtract.swift
31+
TestingSupport.swift
3132
WatchmanHelper.swift)
3233
target_link_libraries(Commands PUBLIC
3334
ArgumentParser

Sources/Commands/SwiftPackageTool.swift

Lines changed: 121 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,12 +1126,12 @@ final class PluginDelegate: PluginInvocationDelegate {
11261126
// Run the build in the background and call the completion handler when done.
11271127
DispatchQueue.sharedConcurrent.async {
11281128
completion(Result {
1129-
return try self.doBuild(subset: subset, parameters: parameters)
1129+
return try self.performBuildForPlugin(subset: subset, parameters: parameters)
11301130
})
11311131
}
11321132
}
11331133

1134-
private func doBuild(subset: PluginInvocationBuildSubset, parameters: PluginInvocationBuildParameters) throws -> PluginInvocationBuildResult {
1134+
private func performBuildForPlugin(subset: PluginInvocationBuildSubset, parameters: PluginInvocationBuildParameters) throws -> PluginInvocationBuildResult {
11351135
// Configure the build parameters.
11361136
var buildParameters = try self.swiftTool.buildParameters()
11371137
switch parameters.configuration {
@@ -1201,26 +1201,139 @@ final class PluginDelegate: PluginInvocationDelegate {
12011201
// Run the test in the background and call the completion handler when done.
12021202
DispatchQueue.sharedConcurrent.async {
12031203
completion(Result {
1204-
return try self.doTest(subset: subset, parameters: parameters)
1204+
return try self.performTestsForPlugin(subset: subset, parameters: parameters)
12051205
})
12061206
}
12071207
}
12081208

1209-
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")
1209+
func performTestsForPlugin(subset: PluginInvocationTestSubset, parameters: PluginInvocationTestParameters) throws -> PluginInvocationTestResult {
1210+
// 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.
1211+
let toolchain = try swiftTool.getToolchain()
1212+
var buildParameters = try swiftTool.buildParameters()
1213+
buildParameters.enableTestability = true
1214+
buildParameters.enableCodeCoverage = parameters.enableCodeCoverage
1215+
let buildSystem = try swiftTool.createBuildSystem(buildParameters: buildParameters)
1216+
try buildSystem.build(subset: .allIncludingTests)
1217+
1218+
// Clean out the code coverage directory that may contain stale `profraw` files from a previous run of the code coverage tool.
1219+
if parameters.enableCodeCoverage {
1220+
try localFileSystem.removeFileTree(buildParameters.codeCovPath)
1221+
}
1222+
1223+
// Construct the environment we'll pass down to the tests.
1224+
var environmentOptions = swiftTool.options
1225+
environmentOptions.shouldEnableCodeCoverage = parameters.enableCodeCoverage
1226+
let testEnvironment = try TestingSupport.constructTestEnvironment(
1227+
toolchain: toolchain,
1228+
options: environmentOptions,
1229+
buildParameters: buildParameters)
1230+
1231+
// Iterate over the tests and run those that match the filter.
1232+
var testTargetResults: [PluginInvocationTestResult.TestTarget] = []
1233+
var numFailedTests = 0
1234+
for testProduct in buildSystem.builtTestProducts {
1235+
// Get the test suites in the bundle. Each is just a container for test cases.
1236+
let testSuites = try TestingSupport.getTestSuites(fromTestAt: testProduct.bundlePath, swiftTool: swiftTool, swiftOptions: swiftTool.options)
1237+
for testSuite in testSuites {
1238+
// Each test suite is just a container for test cases (confusingly called "tests", though they are test cases).
1239+
for testCase in testSuite.tests {
1240+
// Each test case corresponds to a combination of target and a XCTestCase, and is a collection of tests that can actually be run.
1241+
var testResults: [PluginInvocationTestResult.TestTarget.TestCase.Test] = []
1242+
for testName in testCase.tests {
1243+
// Check if we should filter out this test.
1244+
let testSpecifier = testCase.name + "/" + testName
1245+
if case .filtered(let regexes) = subset {
1246+
guard regexes.contains(where: { testSpecifier.range(of: $0, options: .regularExpression) != nil }) else {
1247+
continue
1248+
}
1249+
}
1250+
1251+
// Configure a test runner.
1252+
let testRunner = TestRunner(
1253+
bundlePaths: [testProduct.bundlePath],
1254+
xctestArg: testSpecifier,
1255+
processSet: swiftTool.processSet,
1256+
toolchain: toolchain,
1257+
testEnv: testEnvironment,
1258+
outputStream: swiftTool.outputStream,
1259+
observabilityScope: swiftTool.observabilityScope)
1260+
1261+
// Run the test — for now we run the sequentially so we can capture accurate timing results.
1262+
let startTime = DispatchTime.now()
1263+
let (success, _) = testRunner.test(writeToOutputStream: false)
1264+
let duration = Double(startTime.distance(to: .now()).milliseconds() ?? 0) / 1000.0
1265+
numFailedTests += success ? 0 : 1
1266+
testResults.append(.init(name: testName, result: success ? .succeeded : .failed, duration: duration))
1267+
}
1268+
1269+
// Don't add any results if we didn't run any tests.
1270+
if testResults.isEmpty { continue }
1271+
1272+
// 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.
1273+
let testTargetName = testCase.name.prefix(while: { $0 != "." })
1274+
if let lastTestTargetName = testTargetResults.last?.name, testTargetName == lastTestTargetName {
1275+
// Same as last one, just extend its list of cases. We know we have a last one at this point.
1276+
testTargetResults[testTargetResults.count-1].testCases.append(.init(name: testCase.name, tests: testResults))
1277+
}
1278+
else {
1279+
// Not the same, so start a new target result.
1280+
testTargetResults.append(.init(name: String(testTargetName), testCases: [.init(name: testCase.name, tests: testResults)]))
1281+
}
1282+
}
1283+
}
1284+
}
1285+
1286+
// Deal with code coverage, if enabled.
1287+
let codeCoverageDataFile: AbsolutePath?
1288+
if parameters.enableCodeCoverage {
1289+
// Use `llvm-prof` to merge all the `.profraw` files into a single `.profdata` file.
1290+
let mergedCovFile = buildParameters.codeCovDataFile
1291+
let codeCovFileNames = try localFileSystem.getDirectoryContents(buildParameters.codeCovPath)
1292+
var llvmProfCommand = [try toolchain.getLLVMProf().pathString]
1293+
llvmProfCommand += ["merge", "-sparse"]
1294+
for fileName in codeCovFileNames where fileName.hasSuffix(".profraw") {
1295+
let filePath = buildParameters.codeCovPath.appending(component: fileName)
1296+
llvmProfCommand.append(filePath.pathString)
1297+
}
1298+
llvmProfCommand += ["-o", mergedCovFile.pathString]
1299+
try Process.checkNonZeroExit(arguments: llvmProfCommand)
1300+
1301+
// Use `llvm-cov` to export the merged `.profdata` file contents in JSON form.
1302+
var llvmCovCommand = [try toolchain.getLLVMCov().pathString]
1303+
llvmCovCommand += ["export", "-instr-profile=\(mergedCovFile.pathString)"]
1304+
for product in buildSystem.builtTestProducts {
1305+
llvmCovCommand.append("-object")
1306+
llvmCovCommand.append(product.binaryPath.pathString)
1307+
}
1308+
// We get the output on stdout, and have to write it to a JSON ourselves.
1309+
let jsonOutput = try Process.checkNonZeroExit(arguments: llvmCovCommand)
1310+
let jsonCovFile = buildParameters.codeCovDataFile.parentDirectory.appending(component: buildParameters.codeCovDataFile.basenameWithoutExt + ".json")
1311+
try localFileSystem.writeFileContents(jsonCovFile, string: jsonOutput)
1312+
1313+
// Return the path of the exported code coverage data file.
1314+
codeCoverageDataFile = jsonCovFile
1315+
}
1316+
else {
1317+
codeCoverageDataFile = nil
1318+
}
1319+
1320+
// Return the results to the plugin. We only consider the test run a success if no test failed.
1321+
return PluginInvocationTestResult(
1322+
succeeded: (numFailedTests == 0),
1323+
testTargets: testTargetResults,
1324+
codeCoverageDataFile: codeCoverageDataFile?.pathString)
12121325
}
12131326

12141327
func pluginRequestedSymbolGraph(forTarget targetName: String, options: PluginInvocationSymbolGraphOptions, completion: @escaping (Result<PluginInvocationSymbolGraphResult, Error>) -> Void) {
12151328
// Extract the symbol graph in the background and call the completion handler when done.
12161329
DispatchQueue.sharedConcurrent.async {
12171330
completion(Result {
1218-
return try self.createSymbolGraph(forTarget: targetName, options: options)
1331+
return try self.createSymbolGraphForPlugin(forTarget: targetName, options: options)
12191332
})
12201333
}
12211334
}
12221335

1223-
private func createSymbolGraph(forTarget targetName: String, options: PluginInvocationSymbolGraphOptions) throws -> PluginInvocationSymbolGraphResult {
1336+
private func createSymbolGraphForPlugin(forTarget targetName: String, options: PluginInvocationSymbolGraphOptions) throws -> PluginInvocationSymbolGraphResult {
12241337
// Current implementation uses `SymbolGraphExtract()` but in the future we should emit the symbol graph while building.
12251338

12261339
// Create a build operation for building the target., skipping the the cache because we need the build plan.

0 commit comments

Comments
 (0)