Skip to content

Commit 05babe9

Browse files
committed
Allow unit tests to import and link any main modules of executables that are implemented in Swift. This uses a new Swift compiler flag to set the name of the entry point when emitting object code, and then uses linker flags to rename the main executable module's entry point back to _main again when actually linking the executable.
This is guarded by a tools version check, since packages written this way won't be testable on older toolchains. Also, this is currently only done on Darwin and Linux. A supplemental PR will generate a small stub containing code that implements `main` to call the per-module `<module>_main` function, which will be linked into the executable.
1 parent 97a1b22 commit 05babe9

File tree

11 files changed

+219
-28
lines changed

11 files changed

+219
-28
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ Swift v.Next
1111
* Improvements
1212

1313
Adding a dependency requirement can now be done with the convenience initializer `.package(url: String, branch: String)`.
14+
15+
Test targets can now link against executable targets as if they were libraries, so that they can test any data strutures or algorithms in them. All the code in the executable except for the main entry point itself is available to the unit test. Separate executables are still linked, and can be tested as a subprocess in the same way as before. This feature is available to tests defined in packages that have a tools version of `vNext` or newer.
1416

1517

1618

Fixtures/Miscellaneous/ExeTest/Package.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
// swift-tools-version:5.3
1+
// swift-tools-version: 999.0
22
import PackageDescription
33

44
let package = Package(
55
name: "ExeTest",
66
targets: [
7-
.target(
7+
.executableTarget(
88
name: "Exe",
99
dependencies: []
1010
),
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// swift-tools-version: 999.0
2+
import PackageDescription
3+
4+
let package = Package(
5+
name: "TestableExe",
6+
targets: [
7+
.target(
8+
name: "TestableExe1"
9+
),
10+
.target(
11+
name: "TestableExe2"
12+
),
13+
.target(
14+
name: "TestableExe3"
15+
),
16+
.testTarget(
17+
name: "TestableExeTests",
18+
dependencies: [
19+
"TestableExe1",
20+
"TestableExe2",
21+
"TestableExe3",
22+
]
23+
),
24+
]
25+
)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
public func GetGreeting1() -> String {
2+
return "Hello, world"
3+
}
4+
5+
print("\(GetGreeting1())!")
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
public func GetGreeting2() -> String {
2+
return "Hello, planet"
3+
}
4+
5+
print("\(GetGreeting2())!")
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
const char * GetGreeting3();
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#include <stdio.h>
2+
#include "include/TestableExe3.h"
3+
4+
const char * GetGreeting3() {
5+
return "Hello, universe";
6+
}
7+
8+
int main() {
9+
printf("%s!\n", GetGreeting3());
10+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import XCTest
2+
import TestableExe1
3+
import TestableExe2
4+
// import TestableExe3
5+
import class Foundation.Bundle
6+
7+
final class TestableExeTests: XCTestCase {
8+
func testExample() throws {
9+
// This is an example of a functional test case.
10+
// Use XCTAssert and related functions to verify your tests produce the correct
11+
// results.
12+
13+
print(GetGreeting1())
14+
XCTAssertEqual(GetGreeting1(), "Hello, world")
15+
print(GetGreeting2())
16+
XCTAssertEqual(GetGreeting2(), "Hello, planet")
17+
// XCTAssertEqual(String(cString: GetGreeting3()), "Hello, universe")
18+
19+
// Some of the APIs that we use below are available in macOS 10.13 and above.
20+
guard #available(macOS 10.13, *) else {
21+
return
22+
}
23+
24+
var execPath = productsDirectory.appendingPathComponent("TestableExe1")
25+
var process = Process()
26+
process.executableURL = execPath
27+
var pipe = Pipe()
28+
process.standardOutput = pipe
29+
try process.run()
30+
process.waitUntilExit()
31+
var data = pipe.fileHandleForReading.readDataToEndOfFile()
32+
var output = String(data: data, encoding: .utf8)
33+
XCTAssertEqual(output, "Hello, world!\n")
34+
35+
execPath = productsDirectory.appendingPathComponent("TestableExe2")
36+
process = Process()
37+
process.executableURL = execPath
38+
pipe = Pipe()
39+
process.standardOutput = pipe
40+
try process.run()
41+
process.waitUntilExit()
42+
data = pipe.fileHandleForReading.readDataToEndOfFile()
43+
output = String(data: data, encoding: .utf8)
44+
XCTAssertEqual(output, "Hello, planet!\n")
45+
46+
execPath = productsDirectory.appendingPathComponent("TestableExe3")
47+
process = Process()
48+
process.executableURL = execPath
49+
pipe = Pipe()
50+
process.standardOutput = pipe
51+
try process.run()
52+
process.waitUntilExit()
53+
data = pipe.fileHandleForReading.readDataToEndOfFile()
54+
output = String(data: data, encoding: .utf8)
55+
XCTAssertEqual(output, "Hello, universe!\n")
56+
}
57+
58+
/// Returns path to the built products directory.
59+
var productsDirectory: URL {
60+
#if os(macOS)
61+
for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") {
62+
return bundle.bundleURL.deletingLastPathComponent()
63+
}
64+
fatalError("couldn't find the products directory")
65+
#else
66+
return Bundle.main.bundleURL
67+
#endif
68+
}
69+
70+
static var allTests = [
71+
("testExample", testExample),
72+
]
73+
}

Sources/Build/BuildPlan.swift

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,18 @@ extension BuildParameters {
121121
return args
122122
}
123123

124+
/// Computes the linker flags to use in order to rename a module-named main function to 'main' for the target platform, or nil if the linker doesn't support it for the platform.
125+
fileprivate func linkerFlagsForRenamingMainFunction(of target: ResolvedTarget) -> [String]? {
126+
var args: [String] = []
127+
if self.triple.isDarwin() {
128+
args = ["-alias", "_\(target.c99name)_main", "_main"]
129+
}
130+
else if self.triple.isLinux() {
131+
args = ["--defsym", "main=\(target.c99name)_main"]
132+
}
133+
return args.flatMap { ["-Xlinker", $0] }
134+
}
135+
124136
/// Returns the scoped view of build settings for a given target.
125137
fileprivate func createScope(for target: ResolvedTarget) -> BuildSettings.Scope {
126138
return BuildSettings.Scope(target.underlyingTarget.buildSettings, environment: buildEnvironment)
@@ -515,7 +527,9 @@ public final class SwiftTargetBuildDescription {
515527

516528
/// The path to the swiftmodule file after compilation.
517529
var moduleOutputPath: AbsolutePath {
518-
let dirPath = (target.type == .executable) ? tempsPath : buildParameters.buildPath
530+
// If we're an executable and we're not allowing test targets to link against us, we hide the module.
531+
let allowLinkingAgainstExecutables = (buildParameters.triple.isDarwin() || buildParameters.triple.isLinux()) && toolsVersion >= .vNext
532+
let dirPath = (target.type == .executable && !allowLinkingAgainstExecutables) ? tempsPath : buildParameters.buildPath
519533
return dirPath.appending(component: target.c99name + ".swiftmodule")
520534
}
521535

@@ -690,6 +704,24 @@ public final class SwiftTargetBuildDescription {
690704
args += buildParameters.sanitizers.compileSwiftFlags()
691705
args += ["-parseable-output"]
692706

707+
// If we're compiling the main module of an executable other than the one that
708+
// implements a test suite, and if the package tools version indicates that we
709+
// should, we rename the `_main` entry point to `_<modulename>_main`.
710+
//
711+
// This will allow tests to link against the module without any conflicts. And
712+
// when we link the executable, we will ask the linker to rename the entry point
713+
// symbol to just `_main` again (or if the linker doesn't support it, we'll
714+
// generate a source containing a redirect).
715+
if target.type == .executable && !isTestTarget && toolsVersion >= .vNext {
716+
// We only do this if the linker supports it, as indicated by whether we
717+
// can construct the linker flags. In the future we will use a generated
718+
// code stub for the cases in which the linker doesn't support it, so that
719+
// we can rename the symbol unconditionally.
720+
if buildParameters.linkerFlagsForRenamingMainFunction(of: target) != nil {
721+
args += ["-Xfrontend", "-entry-point-function-name", "-Xfrontend", "\(target.c99name)_main"]
722+
}
723+
}
724+
693725
// Only add the build path to the framework search path if there are binary frameworks to link against.
694726
if !libraryBinaryPaths.isEmpty {
695727
args += ["-F", buildParameters.buildPath.pathString]
@@ -1047,7 +1079,7 @@ public final class ProductBuildDescription {
10471079
return buildParameters.binaryPath(for: product)
10481080
}
10491081

1050-
/// The objects in this product.
1082+
/// All object files to link into this product.
10511083
///
10521084
// Computed during build planning.
10531085
public fileprivate(set) var objects = SortedArray<AbsolutePath>()
@@ -1167,6 +1199,20 @@ public final class ProductBuildDescription {
11671199
}
11681200
}
11691201
args += ["-emit-executable"]
1202+
1203+
// If we're linking an executable whose main module is implemented in Swift,
1204+
// we rename the `_<modulename>_main` entry point symbol to `_main` again.
1205+
// This is because executable modules implemented in Swift are compiled with
1206+
// a main symbol named that way to allow tests to link against it without
1207+
// conflicts. If we're using a linker that doesn't support symbol renaming,
1208+
// we will instead have generated a source file containing the redirect.
1209+
// Support for linking tests againsts executables is conditional on the tools
1210+
// version of the package that defines the executable product.
1211+
if product.executableModule.underlyingTarget is SwiftTarget, toolsVersion >= .vNext {
1212+
if let flags = buildParameters.linkerFlagsForRenamingMainFunction(of: product.executableModule) {
1213+
args += flags
1214+
}
1215+
}
11701216
case .plugin:
11711217
throw InternalError("unexpectedly asked to generate linker arguments for a plugin product")
11721218
}
@@ -1669,9 +1715,21 @@ public class BuildPlan {
16691715
switch dependency {
16701716
case .target(let target, _):
16711717
switch target.type {
1672-
// Include executable and tests only if they're top level contents
1673-
// of the product. Otherwise they are just build time dependency.
1674-
case .executable, .test:
1718+
// Executable target have historically only been included if they are directly in the product's
1719+
// target list. Otherwise they have always been just build-time dependencies.
1720+
// In tool version .vNext or greater, we also include executable modules implemented in Swift in
1721+
// any test products... this is to allow testing of executables. Note that they are also still
1722+
// built as separate products that the test can invoke as subprocesses.
1723+
case .executable:
1724+
if product.targets.contains(target) {
1725+
staticTargets.append(target)
1726+
} else if product.type == .test && target.underlyingTarget is SwiftTarget {
1727+
if let toolsVersion = graph.package(for: product)?.manifest.toolsVersion, toolsVersion >= .vNext {
1728+
staticTargets.append(target)
1729+
}
1730+
}
1731+
// Test targets should be included only if they are directly in the product's target list.
1732+
case .test:
16751733
if product.targets.contains(target) {
16761734
staticTargets.append(target)
16771735
}

Tests/FunctionalTests/MiscellaneousTests.swift

Lines changed: 22 additions & 18 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) 2014 - 2017 Apple Inc. and the Swift project authors
4+
Copyright (c) 2014 - 2021 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
@@ -578,25 +578,20 @@ class MiscellaneousTestCase: XCTestCase {
578578
#endif
579579
}
580580

581-
func testErrorMessageWhenTestLinksExecutable() {
582-
fixture(name: "Miscellaneous/ExeTest") { prefix in
581+
func testTestsCanLinkAgainstExecutable() throws {
582+
// Check if the host compiler supports the '-entry-point-function-name' flag.
583+
try XCTSkipUnless(doesHostSwiftCompilerSupportRenamingMainSymbol(), "skipping because host compiler doesn't support '-entry-point-function-name'")
584+
585+
fixture(name: "Miscellaneous/TestableExe") { prefix in
583586
do {
584-
try executeSwiftTest(prefix)
585-
XCTFail()
586-
} catch SwiftPMProductError.executionFailure(let error, let output, let stderr) {
587-
XCTAssertMatch(stderr + output, .contains("Compiling Exe main.swift"))
588-
XCTAssertMatch(stderr + output, .contains("Compiling ExeTests ExeTests.swift"))
589-
XCTAssertMatch(stderr + output, .regex("error: no such module 'Exe'"))
590-
XCTAssertMatch(stderr + output, .regex("note: module 'Exe' is the main module of an executable, and cannot be imported by tests and other targets"))
591-
592-
if case ProcessResult.Error.nonZeroExit(let result) = error {
593-
// if our code crashes we'll get an exit code of 256
594-
XCTAssertEqual(result.exitStatus, .terminated(code: 1))
595-
} else {
596-
XCTFail("\(stderr + output)")
597-
}
587+
let (stdout, _) = try executeSwiftTest(prefix)
588+
XCTAssertMatch(stdout, .contains("Linking TestableExe1"))
589+
XCTAssertMatch(stdout, .contains("Linking TestableExe2"))
590+
XCTAssertMatch(stdout, .contains("Linking TestableExePackageTests"))
591+
XCTAssertMatch(stdout, .contains("Hello, world"))
592+
XCTAssertMatch(stdout, .contains("Hello, planet"))
598593
} catch {
599-
XCTFail()
594+
XCTFail("\(error)")
600595
}
601596
}
602597
}
@@ -613,3 +608,12 @@ class MiscellaneousTestCase: XCTestCase {
613608
}
614609
}
615610
}
611+
612+
func doesHostSwiftCompilerSupportRenamingMainSymbol() throws -> Bool {
613+
try withTemporaryDirectory { tmpDir in
614+
let hostToolchain = try UserToolchain(destination: .hostDestination())
615+
FileManager.default.createFile(atPath: "\(tmpDir)/foo.swift", contents: Data())
616+
let result = try Process.popen(args: hostToolchain.swiftCompiler.pathString, "-c", "-Xfrontend", "-entry-point-function-name", "-Xfrontend", "foo", "\(tmpDir)/foo.swift", "-o", "\(tmpDir)/foo.o")
617+
return try !result.utf8stderrOutput().contains("unknown argument: '-entry-point-function-name'")
618+
}
619+
}

Tests/FunctionalTests/PluginTests.swift

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ import TSCBasic
1414

1515
class PluginTests: XCTestCase {
1616

17-
func testUseOfBuildToolPluginTargetByExecutableInSamePackage() {
17+
func testUseOfBuildToolPluginTargetByExecutableInSamePackage() throws {
18+
// Check if the host compiler supports the '-entry-point-function-name' flag. It's not needed for this test but is needed to build any executable from a package that uses tools version 999.0.
19+
try XCTSkipUnless(doesHostSwiftCompilerSupportRenamingMainSymbol(), "skipping because host compiler doesn't support '-entry-point-function-name'")
1820

1921
fixture(name: "Miscellaneous/Plugins") { path in
2022
do {
@@ -31,7 +33,10 @@ class PluginTests: XCTestCase {
3133
}
3234
}
3335

34-
func testUseOfBuildToolPluginProductByExecutableAcrossPackages() {
36+
func testUseOfBuildToolPluginProductByExecutableAcrossPackages() throws {
37+
// Check if the host compiler supports the '-entry-point-function-name' flag. It's not needed for this test but is needed to build any executable from a package that uses tools version 999.0.
38+
try XCTSkipUnless(doesHostSwiftCompilerSupportRenamingMainSymbol(), "skipping because host compiler doesn't support '-entry-point-function-name'")
39+
3540
fixture(name: "Miscellaneous/Plugins") { path in
3641
do {
3742
let (stdout, _) = try executeSwiftBuild(path.appending(component: "MySourceGenClient"), configuration: .Debug, extraArgs: ["--product", "MyTool"], env: ["SWIFTPM_ENABLE_PLUGINS": "1"])
@@ -47,7 +52,10 @@ class PluginTests: XCTestCase {
4752
}
4853
}
4954

50-
func testUseOfPrebuildPluginTargetByExecutableAcrossPackages() {
55+
func testUseOfPrebuildPluginTargetByExecutableAcrossPackages() throws {
56+
// Check if the host compiler supports the '-entry-point-function-name' flag. It's not needed for this test but is needed to build any executable from a package that uses tools version 999.0.
57+
try XCTSkipUnless(doesHostSwiftCompilerSupportRenamingMainSymbol(), "skipping because host compiler doesn't support '-entry-point-function-name'")
58+
5159
fixture(name: "Miscellaneous/Plugins") { path in
5260
do {
5361
let (stdout, _) = try executeSwiftBuild(path.appending(component: "MySourceGenPlugin"), configuration: .Debug, extraArgs: ["--product", "MyOtherLocalTool"], env: ["SWIFTPM_ENABLE_PLUGINS": "1"])

0 commit comments

Comments
 (0)