Skip to content

Commit

Permalink
Allow unit tests to import and link any main modules of executables t…
Browse files Browse the repository at this point in the history
…hat 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.
  • Loading branch information
abertelrud committed Mar 3, 2021
1 parent 5b0597d commit 96c5014
Show file tree
Hide file tree
Showing 10 changed files with 193 additions and 27 deletions.
4 changes: 2 additions & 2 deletions Fixtures/Miscellaneous/ExeTest/Package.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// swift-tools-version:5.3
// swift-tools-version: 999.0
import PackageDescription

let package = Package(
name: "ExeTest",
targets: [
.target(
.executableTarget(
name: "Exe",
dependencies: []
),
Expand Down
25 changes: 25 additions & 0 deletions Fixtures/Miscellaneous/TestableExe/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// swift-tools-version:5.3
import PackageDescription

let package = Package(
name: "TestableExe",
targets: [
.target(
name: "TestableExe1"
),
.target(
name: "TestableExe2"
),
.target(
name: "TestableExe3"
),
.testTarget(
name: "TestableExeTests",
dependencies: [
"TestableExe1",
"TestableExe2",
"TestableExe3",
]
),
]
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
public func GetGreeting1() -> String {
return "Hello, world"
}

print("\(GetGreeting1())!")
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
public func GetGreeting2() -> String {
return "Hello, planet"
}

print("\(GetGreeting2())!")
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
const char * GetGreeting3();
10 changes: 10 additions & 0 deletions Fixtures/Miscellaneous/TestableExe/Sources/TestableExe3/main.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#include <stdio.h>
#include "include/TestableExe3.h"

const char * GetGreeting3() {
return "Hello, universe";
}

int main() {
printf("%s!\n", GetGreeting3());
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import XCTest
import TestableExe1
import TestableExe2
// import TestableExe3
import class Foundation.Bundle

final class TestableExeTests: XCTestCase {
func testExample() throws {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct
// results.

print(GetGreeting1())
XCTAssertEqual(GetGreeting1(), "Hello, world")
print(GetGreeting2())
XCTAssertEqual(GetGreeting2(), "Hello, planet")
// XCTAssertEqual(String(cString: GetGreeting3()), "Hello, universe")

// Some of the APIs that we use below are available in macOS 10.13 and above.
guard #available(macOS 10.13, *) else {
return
}

var execPath = productsDirectory.appendingPathComponent("TestableExe1")
var process = Process()
process.executableURL = execPath
var pipe = Pipe()
process.standardOutput = pipe
try process.run()
process.waitUntilExit()
var data = pipe.fileHandleForReading.readDataToEndOfFile()
var output = String(data: data, encoding: .utf8)
XCTAssertEqual(output, "Hello, world!\n")

execPath = productsDirectory.appendingPathComponent("TestableExe2")
process = Process()
process.executableURL = execPath
pipe = Pipe()
process.standardOutput = pipe
try process.run()
process.waitUntilExit()
data = pipe.fileHandleForReading.readDataToEndOfFile()
output = String(data: data, encoding: .utf8)
XCTAssertEqual(output, "Hello, planet!\n")

execPath = productsDirectory.appendingPathComponent("TestableExe3")
process = Process()
process.executableURL = execPath
pipe = Pipe()
process.standardOutput = pipe
try process.run()
process.waitUntilExit()
data = pipe.fileHandleForReading.readDataToEndOfFile()
output = String(data: data, encoding: .utf8)
XCTAssertEqual(output, "Hello, universe!\n")
}

/// Returns path to the built products directory.
var productsDirectory: URL {
#if os(macOS)
for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") {
return bundle.bundleURL.deletingLastPathComponent()
}
fatalError("couldn't find the products directory")
#else
return Bundle.main.bundleURL
#endif
}

static var allTests = [
("testExample", testExample),
]
}
47 changes: 42 additions & 5 deletions Sources/Build/BuildPlan.swift
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,7 @@ public final class SwiftTargetBuildDescription {

/// The path to the swiftmodule file after compilation.
var moduleOutputPath: AbsolutePath {
let dirPath = (target.type == .executable) ? tempsPath : buildParameters.buildPath
let dirPath = (target.type == .executable && toolsVersion < .vNext) ? tempsPath : buildParameters.buildPath
return dirPath.appending(component: target.c99name + ".swiftmodule")
}

Expand Down Expand Up @@ -690,6 +690,14 @@ public final class SwiftTargetBuildDescription {
args += buildParameters.sanitizers.compileSwiftFlags()
args += ["-parseable-output"]

// If we're compiling the main module of an executable, we rename the `_main`
// entry point to `_<modulename>_main`. This will allow tests to link against
// them without conflicts. When we link the executable we will ask the linker
// to rename the entry point symbol to just `_main` again.
if target.type == .executable && !isTestTarget && toolsVersion >= .vNext {
args += ["-Xfrontend", "-entry-point-function-name", "-Xfrontend", "\(target.c99name)_main"]
}

// Only add the build path to the framework search path if there are binary frameworks to link against.
if !libraryBinaryPaths.isEmpty {
args += ["-F", buildParameters.buildPath.pathString]
Expand Down Expand Up @@ -1047,7 +1055,7 @@ public final class ProductBuildDescription {
return buildParameters.binaryPath(for: product)
}

/// The objects in this product.
/// All object files to link into this product.
///
// Computed during build planning.
public fileprivate(set) var objects = SortedArray<AbsolutePath>()
Expand Down Expand Up @@ -1167,6 +1175,23 @@ public final class ProductBuildDescription {
}
}
args += ["-emit-executable"]

// If we're linking an executable whose main module is implemented in Swift,
// we rename the `_<modulename>_main` entry point symbol to `_main` again.
// This is because executable modules implemented in Swift are compiled with
// a main symbol named that way to allow tests to link against it without
// conflicts. If we're using a linker that doesn't support symbol renaming,
// an alternate implementation could use a generated source file with a stub
// implementation of `_main` to call the renamed main symbol.
let execModule = product.executableModule
if execModule.underlyingTarget is SwiftTarget && toolsVersion >= .vNext {
if buildParameters.triple.isDarwin() {
args += ["-Xlinker", "-alias", "-Xlinker", "_\(execModule.c99name)_main", "-Xlinker", "_main"]
}
else {
args += ["-Xlinker", "--defsym", "-Xlinker", "main=\(execModule.c99name)_main"]
}
}
case .plugin:
throw InternalError("unexpectedly asked to generate linker arguments for a plugin product")
}
Expand Down Expand Up @@ -1669,9 +1694,21 @@ public class BuildPlan {
switch dependency {
case .target(let target, _):
switch target.type {
// Include executable and tests only if they're top level contents
// of the product. Otherwise they are just build time dependency.
case .executable, .test:
// Executable target have historically only been included if they are directly in the product's
// target list. Otherwise they have always been just build-time dependencies.
// In tool version .vNext or greater, we also include executable modules implemented in Swift in
// any test products... this is to allow testing of executables. Note that they are also still
// built as separate products that the test can invoke as subprocesses.
case .executable:
if product.targets.contains(target) {
staticTargets.append(target)
} else if product.type == .test && target.underlyingTarget is SwiftTarget {
if let toolsVersion = graph.package(for: product)?.manifest.toolsVersion, toolsVersion >= .vNext {
staticTargets.append(target)
}
}
// Test targets should be included only if they are directly in the product's target list.
case .test:
if product.targets.contains(target) {
staticTargets.append(target)
}
Expand Down
36 changes: 19 additions & 17 deletions Tests/FunctionalTests/MiscellaneousTests.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/*
This source file is part of the Swift.org open source project

Copyright (c) 2014 - 2017 Apple Inc. and the Swift project authors
Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors
Licensed under Apache License v2.0 with Runtime Library Exception

See http://swift.org/LICENSE.txt for license information
Expand Down Expand Up @@ -578,25 +578,18 @@ class MiscellaneousTestCase: XCTestCase {
#endif
}

func testErrorMessageWhenTestLinksExecutable() {
func testTestsCanLinkAgainstExecutable() throws {
// Check if the host compiler supports the '-entry-point-function-name' flag.
try XCTSkipUnless(doesHostSwiftCompilerSupportRenamingMainSymbol(), "skipping because host compiler doesn't support '-entry-point-function-name'")

fixture(name: "Miscellaneous/ExeTest") { prefix in
do {
try executeSwiftTest(prefix)
XCTFail()
} catch SwiftPMProductError.executionFailure(let error, let output, let stderr) {
XCTAssertMatch(stderr + output, .contains("Compiling Exe main.swift"))
XCTAssertMatch(stderr + output, .contains("Compiling ExeTests ExeTests.swift"))
XCTAssertMatch(stderr + output, .regex("error: no such module 'Exe'"))
XCTAssertMatch(stderr + output, .regex("note: module 'Exe' is the main module of an executable, and cannot be imported by tests and other targets"))

if case ProcessResult.Error.nonZeroExit(let result) = error {
// if our code crashes we'll get an exit code of 256
XCTAssertEqual(result.exitStatus, .terminated(code: 1))
} else {
XCTFail("\(stderr + output)")
}
let (stdout, _) = try executeSwiftTest(prefix)
XCTAssertMatch(stdout, .contains("Compiling Exe main.swift"))
XCTAssertMatch(stdout, .contains("Compiling ExeTests ExeTests.swift"))
XCTAssertMatch(stdout, .contains("Linking ExeTestPackageTests"))
} catch {
XCTFail()
XCTFail("\(error)")
}
}
}
Expand All @@ -613,3 +606,12 @@ class MiscellaneousTestCase: XCTestCase {
}
}
}

func doesHostSwiftCompilerSupportRenamingMainSymbol() throws -> Bool {
try withTemporaryDirectory { tmpDir in
let hostToolchain = try UserToolchain(destination: .hostDestination())
FileManager.default.createFile(atPath: "\(tmpDir)/foo.swift", contents: Data())
let result = try Process.popen(args: hostToolchain.swiftCompiler.pathString, "-c", "-Xfrontend", "-entry-point-function-name", "-Xfrontend", "foo", "\(tmpDir)/foo.swift", "-o", "\(tmpDir)/foo.o")
return try !result.utf8stderrOutput().contains("unknown argument: '-entry-point-function-name'")
}
}
14 changes: 11 additions & 3 deletions Tests/FunctionalTests/PluginTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import TSCBasic

class PluginTests: XCTestCase {

func testUseOfBuildToolPluginTargetByExecutableInSamePackage() {
func testUseOfBuildToolPluginTargetByExecutableInSamePackage() throws {
// 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.
try XCTSkipUnless(doesHostSwiftCompilerSupportRenamingMainSymbol(), "skipping because host compiler doesn't support '-entry-point-function-name'")

fixture(name: "Miscellaneous/Plugins") { path in
do {
Expand All @@ -31,7 +33,10 @@ class PluginTests: XCTestCase {
}
}

func testUseOfBuildToolPluginProductByExecutableAcrossPackages() {
func testUseOfBuildToolPluginProductByExecutableAcrossPackages() throws {
// 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.
try XCTSkipUnless(doesHostSwiftCompilerSupportRenamingMainSymbol(), "skipping because host compiler doesn't support '-entry-point-function-name'")

fixture(name: "Miscellaneous/Plugins") { path in
do {
let (stdout, _) = try executeSwiftBuild(path.appending(component: "MySourceGenClient"), configuration: .Debug, extraArgs: ["--product", "MyTool"], env: ["SWIFTPM_ENABLE_PLUGINS": "1"])
Expand All @@ -47,7 +52,10 @@ class PluginTests: XCTestCase {
}
}

func testUseOfPrebuildPluginTargetByExecutableAcrossPackages() {
func testUseOfPrebuildPluginTargetByExecutableAcrossPackages() throws {
// 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.
try XCTSkipUnless(doesHostSwiftCompilerSupportRenamingMainSymbol(), "skipping because host compiler doesn't support '-entry-point-function-name'")

fixture(name: "Miscellaneous/Plugins") { path in
do {
let (stdout, _) = try executeSwiftBuild(path.appending(component: "MySourceGenPlugin"), configuration: .Debug, extraArgs: ["--product", "MyOtherLocalTool"], env: ["SWIFTPM_ENABLE_PLUGINS": "1"])
Expand Down

0 comments on commit 96c5014

Please sign in to comment.