Skip to content

Commit 50c1f05

Browse files
committed
Fill in the implementation of running tests from a plugin
This uses most of the same implementation that existed in a very specify way in SwiftTestTool.swift, factoring it out into a new shared TestHelpers.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 b4cbd56 commit 50c1f05

File tree

7 files changed

+391
-136
lines changed

7 files changed

+391
-136
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+
TestHelpers.swift
3132
WatchmanHelper.swift)
3233
target_link_libraries(Commands PUBLIC
3334
ArgumentParser

Sources/Commands/SwiftPackageTool.swift

Lines changed: 116 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1206,9 +1206,122 @@ final class PluginDelegate: PluginInvocationDelegate {
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 doTest(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 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 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) {

Sources/Commands/SwiftTestTool.swift

Lines changed: 5 additions & 126 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
@@ -209,7 +209,7 @@ public struct SwiftTestTool: SwiftCommand {
209209
switch options.mode {
210210
case .listTests:
211211
let testProducts = try buildTestsIfNeeded(swiftTool: swiftTool)
212-
let testSuites = try getTestSuites(in: testProducts, swiftTool: swiftTool)
212+
let testSuites = try getTestSuites(in: testProducts, swiftTool: swiftTool, swiftOptions: swiftOptions)
213213
let tests = try testSuites
214214
.filteredTests(specifier: options.testCaseSpecifier)
215215
.skippedTests(specifier: options.testCaseSkip)
@@ -245,7 +245,7 @@ public struct SwiftTestTool: SwiftCommand {
245245
#endif
246246
let graph = try swiftTool.loadPackageGraph()
247247
let testProducts = try buildTestsIfNeeded(swiftTool: swiftTool)
248-
let testSuites = try getTestSuites(in: testProducts, swiftTool: swiftTool)
248+
let testSuites = try getTestSuites(in: testProducts, swiftTool: swiftTool, swiftOptions: swiftOptions)
249249
let allTestSuites = testSuites.values.flatMap { $0 }
250250
let generator = LinuxMainGenerator(graph: graph, testSuites: allTestSuites)
251251
try generator.generate()
@@ -278,7 +278,7 @@ public struct SwiftTestTool: SwiftCommand {
278278
}
279279

280280
// Find the tests we need to run.
281-
let testSuites = try getTestSuites(in: testProducts, swiftTool: swiftTool)
281+
let testSuites = try getTestSuites(in: testProducts, swiftTool: swiftTool, swiftOptions: swiftOptions)
282282
let tests = try testSuites
283283
.filteredTests(specifier: options.testCaseSpecifier)
284284
.skippedTests(specifier: options.testCaseSkip)
@@ -317,7 +317,7 @@ public struct SwiftTestTool: SwiftCommand {
317317
case .runParallel:
318318
let toolchain = try swiftTool.getToolchain()
319319
let testProducts = try buildTestsIfNeeded(swiftTool: swiftTool)
320-
let testSuites = try getTestSuites(in: testProducts, swiftTool: swiftTool)
320+
let testSuites = try getTestSuites(in: testProducts, swiftTool: swiftTool, swiftOptions: swiftOptions)
321321
let tests = try testSuites
322322
.filteredTests(specifier: options.testCaseSpecifier)
323323
.skippedTests(specifier: options.testCaseSkip)
@@ -461,70 +461,6 @@ public struct SwiftTestTool: SwiftCommand {
461461
}
462462
}
463463

464-
/// Locates XCTestHelper tool inside the libexec directory and bin directory.
465-
/// Note: It is a fatalError if we are not able to locate the tool.
466-
///
467-
/// - Returns: Path to XCTestHelper tool.
468-
private func xctestHelperPath(swiftTool: SwiftTool) throws -> AbsolutePath {
469-
let xctestHelperBin = "swiftpm-xctest-helper"
470-
let binDirectory = AbsolutePath(CommandLine.arguments.first!,
471-
relativeTo: swiftTool.originalWorkingDirectory).parentDirectory
472-
// XCTestHelper tool is installed in libexec.
473-
let maybePath = binDirectory.parentDirectory.appending(components: "libexec", "swift", "pm", xctestHelperBin)
474-
if localFileSystem.isFile(maybePath) {
475-
return maybePath
476-
}
477-
// This will be true during swiftpm development.
478-
// FIXME: Factor all of the development-time resource location stuff into a common place.
479-
let path = binDirectory.appending(component: xctestHelperBin)
480-
if localFileSystem.isFile(path) {
481-
return path
482-
}
483-
throw InternalError("XCTestHelper binary not found.")
484-
}
485-
486-
fileprivate func getTestSuites(in testProducts: [BuiltTestProduct], swiftTool: SwiftTool) throws -> [AbsolutePath: [TestSuite]] {
487-
let testSuitesByProduct = try testProducts
488-
.map { try ($0.bundlePath, self.getTestSuites(fromTestAt: $0.bundlePath, swiftTool: swiftTool)) }
489-
return Dictionary(uniqueKeysWithValues: testSuitesByProduct)
490-
}
491-
492-
/// Runs the corresponding tool to get tests JSON and create TestSuite array.
493-
/// On macOS, we use the swiftpm-xctest-helper tool bundled with swiftpm.
494-
/// On Linux, XCTest can dump the json using `--dump-tests-json` mode.
495-
///
496-
/// - Parameters:
497-
/// - path: Path to the XCTest bundle(macOS) or executable(Linux).
498-
///
499-
/// - Throws: TestError, SystemError, TSCUtility.Error
500-
///
501-
/// - Returns: Array of TestSuite
502-
fileprivate func getTestSuites(fromTestAt path: AbsolutePath, swiftTool: SwiftTool) throws -> [TestSuite] {
503-
// Run the correct tool.
504-
#if os(macOS)
505-
let data: String = try withTemporaryFile { tempFile in
506-
let args = [try xctestHelperPath(swiftTool: swiftTool).pathString, path.pathString, tempFile.path.pathString]
507-
var env = try constructTestEnvironment(toolchain: try swiftTool.getToolchain(), options: swiftOptions, buildParameters: swiftTool.buildParametersForTest())
508-
// Add the sdk platform path if we have it. If this is not present, we
509-
// might always end up failing.
510-
if let sdkPlatformFrameworksPath = Destination.sdkPlatformFrameworkPaths() {
511-
// appending since we prefer the user setting (if set) to the one we inject
512-
env.appendPath("DYLD_FRAMEWORK_PATH", value: sdkPlatformFrameworksPath.fwk.pathString)
513-
env.appendPath("DYLD_LIBRARY_PATH", value: sdkPlatformFrameworksPath.lib.pathString)
514-
}
515-
try Process.checkNonZeroExit(arguments: args, environment: env)
516-
// Read the temporary file's content.
517-
return try localFileSystem.readFileContents(tempFile.path).validDescription ?? ""
518-
}
519-
#else
520-
let env = try constructTestEnvironment(toolchain: try swiftTool.getToolchain(), options: swiftOptions, buildParameters: swiftTool.buildParametersForTest())
521-
let args = [path.description, "--dump-tests-json"]
522-
let data = try Process.checkNonZeroExit(arguments: args, environment: env)
523-
#endif
524-
// Parse json and return TestSuites.
525-
return try TestSuite.parse(jsonString: data)
526-
}
527-
528464
/// Private function that validates the commands arguments
529465
///
530466
/// - Throws: if a command argument is invalid
@@ -1022,54 +958,6 @@ fileprivate extension Array where Element == UnitTest {
1022958
}
1023959
}
1024960

1025-
/// Creates the environment needed to test related tools.
1026-
fileprivate func constructTestEnvironment(
1027-
toolchain: UserToolchain,
1028-
options: SwiftToolOptions,
1029-
buildParameters: BuildParameters
1030-
) throws -> EnvironmentVariables {
1031-
var env = EnvironmentVariables.process()
1032-
1033-
// Add the code coverage related variables.
1034-
if options.shouldEnableCodeCoverage {
1035-
// Defines the path at which the profraw files will be written on test execution.
1036-
//
1037-
// `%m` will create a pool of profraw files and append the data from
1038-
// each execution in one of the files. This doesn't matter for serial
1039-
// execution but is required when the tests are running in parallel as
1040-
// SwiftPM repeatedly invokes the test binary with the test case name as
1041-
// the filter.
1042-
let codecovProfile = buildParameters.buildPath.appending(components: "codecov", "default%m.profraw")
1043-
env["LLVM_PROFILE_FILE"] = codecovProfile.pathString
1044-
}
1045-
#if !os(macOS)
1046-
#if os(Windows)
1047-
if let location = toolchain.configuration.xctestPath {
1048-
env.prependPath("Path", value: location.pathString)
1049-
}
1050-
#endif
1051-
return env
1052-
#else
1053-
// Fast path when no sanitizers are enabled.
1054-
if options.sanitizers.isEmpty {
1055-
return env
1056-
}
1057-
1058-
// Get the runtime libraries.
1059-
var runtimes = try options.sanitizers.map({ sanitizer in
1060-
return try toolchain.runtimeLibrary(for: sanitizer).pathString
1061-
})
1062-
1063-
// Append any existing value to the front.
1064-
if let existingValue = env["DYLD_INSERT_LIBRARIES"], !existingValue.isEmpty {
1065-
runtimes.insert(existingValue, at: 0)
1066-
}
1067-
1068-
env["DYLD_INSERT_LIBRARIES"] = runtimes.joined(separator: ":")
1069-
return env
1070-
#endif
1071-
}
1072-
1073961
/// xUnit XML file generator for a swift-test run.
1074962
final class XUnitGenerator {
1075963
typealias TestResult = ParallelTestRunner.TestResult
@@ -1130,12 +1018,3 @@ private extension Basics.Diagnostic {
11301018
.warning("No matching test cases were run")
11311019
}
11321020
}
1133-
1134-
private extension SwiftTool {
1135-
func buildParametersForTest() throws -> BuildParameters {
1136-
var parameters = try self.buildParameters()
1137-
// for test commands, alway enable building with testability enabled
1138-
parameters.enableTestability = true
1139-
return parameters
1140-
}
1141-
}

0 commit comments

Comments
 (0)