-
Notifications
You must be signed in to change notification settings - Fork 1.4k
[WIP] Automatic Linux test manifest generation #156
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
079859b
1ce878e
30be12a
843622b
6cf1fd7
d837e3e
29df147
77ba85b
ad4a367
7a123b5
d5b312f
174083b
f4421fc
cd8f808
4111842
7828109
60853f1
b5ba4a6
7194ed3
939b8a8
c3948d6
454b531
30c1f44
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
os: | ||
- linux | ||
- osx | ||
language: generic | ||
sudo: required | ||
dist: trusty | ||
osx_image: xcode7.3 | ||
env: | ||
- SWIFT_VERSION=DEVELOPMENT-SNAPSHOT-2016-02-25-a | ||
install: | ||
- eval "$(curl -sL https://gist.githubusercontent.com/kylef/5c0475ff02b7c7671d2a/raw/02090c7ede5a637b76e6df1710e83cd0bbe7dcdf/swiftenv-install.sh)" | ||
script: | ||
- Utilities/bootstrap | ||
- .build/bin/swift-test | ||
notifications: | ||
email: | ||
on_success: never | ||
on_failure: change |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,206 @@ | ||
/* | ||
This source file is part of the Swift.org open source project | ||
|
||
Copyright 2015 - 2016 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 | ||
See http://swift.org/CONTRIBUTORS.txt for Swift project authors | ||
*/ | ||
|
||
import PackageType | ||
import Utility | ||
import POSIX | ||
import func libc.fclose | ||
|
||
struct TestMetadata { | ||
|
||
/// Map from product name -> XCTestMain absolute path | ||
var productMainPaths: [String: String] | ||
|
||
/// Map from module name -> XCTestManifest absolute path | ||
var moduleManifestPaths: [String: String] | ||
} | ||
|
||
struct ProductTestMetadata { | ||
let xctestMainPath: String | ||
let product: Product | ||
let metadata: [ModuleTestMetadata] | ||
} | ||
|
||
|
||
/// Generates a XCTestManifest.swift file for each module and XCTestMain.swift file for | ||
/// each product. Note that no files are generated for modules with no XCTestCase | ||
/// subclasses and no files are generated for packages with no XCTest-able modules. | ||
/// Returns TestMetadata, which informs of which files need to be added for | ||
/// compilation into which module/product. | ||
func generateTestManifestFilesForProducts(products: [Product], prefix: String) throws -> TestMetadata { | ||
|
||
//only work with test products | ||
let testProducts = products.filter { $0.isTest } | ||
|
||
//create our .build subfolder | ||
let testManifestFolder = Path.join(prefix, "xctestmanifests") | ||
try mkdir(testManifestFolder) | ||
|
||
//TODO: what do we do when user deletes an XCTestCase file? | ||
//we should somehow know to delete its manifest. | ||
//here we could keep track of touched manifest files and delete the | ||
//untouched ones at the end. | ||
|
||
//produce manifest file for each test module | ||
let productsMetadata = try testProducts.flatMap { testProduct in | ||
return try generateTestManifestFiles(testProduct, testManifestFolder: testManifestFolder) | ||
} | ||
|
||
//collect the paths of generated files | ||
var productPaths = [String: String]() | ||
var modulePaths = [String: String]() | ||
|
||
for productMetadata in productsMetadata { | ||
productPaths[productMetadata.product.name] = productMetadata.xctestMainPath | ||
for moduleMetadata in productMetadata.metadata { | ||
modulePaths[moduleMetadata.module.name] = moduleMetadata.testManifestPath | ||
} | ||
} | ||
|
||
return TestMetadata(productMainPaths: productPaths, moduleManifestPaths: modulePaths) | ||
} | ||
|
||
/// Generates one XCTestManifest.swift per test module and one XCTestMain.swift | ||
/// for the whole product. All returned paths need to be added for compilation. | ||
func generateTestManifestFiles(product: Product, testManifestFolder: String) throws -> ProductTestMetadata? { | ||
|
||
// Now parse and generate files for each test module. | ||
// First parse each module's tests to get the list of classes | ||
// which we'll use to generate the XCTestMain.swift file later. | ||
let metadata = try product | ||
.modules | ||
.flatMap{ $0 as? TestModule } | ||
.flatMap { try generateXCTestManifests($0, testManifestFolder: testManifestFolder) } | ||
|
||
// Don't continue if no modules with testable classes were found | ||
guard metadata.count > 0 else { return nil } | ||
|
||
//TODO: Decide what to do when users already have | ||
//the manual XCTestCaseProvider extension. | ||
//Ignore that file? Error out and ask them to remove it once and for all? | ||
//I'd prefer the second, because it'll get everyone on the same page quickly. | ||
|
||
// With the test information, generate the XCTestMain file | ||
let mainPath = try generateXCTestMain(product, metadata: metadata, testManifestFolder: testManifestFolder) | ||
|
||
return ProductTestMetadata(xctestMainPath: mainPath, product: product, metadata: metadata) | ||
} | ||
|
||
/// Updates the contents of and returns the path of XCTestMain.swift file. | ||
func generateXCTestMain(product: Product, metadata: [ModuleTestMetadata], testManifestFolder: String) throws -> String { | ||
|
||
let main = Path.join(testManifestFolder, "\(product.name)-XCTestMain.swift") | ||
|
||
// Generate the XCTestMain.swift's contents. | ||
try writeXCTestMain(metadata, path: main) | ||
|
||
return main | ||
} | ||
|
||
/// Returns a list of class names that are subclasses of XCTestCase | ||
func generateXCTestManifests(module: TestModule, testManifestFolder: String) throws -> ModuleTestMetadata? { | ||
|
||
let root = module.sources.root | ||
let testManifestPath = Path.join(testManifestFolder, "\(module.name)-XCTestManifest.swift") | ||
|
||
// Replace the String based parser with AST parser once it's ready | ||
let parser: TestMetadataParser = StringTestMetadataParser() | ||
|
||
let classes = try module | ||
.sources | ||
.relativePaths | ||
.map { Path.join(root, $0) } | ||
.flatMap { try parser.parseTestClasses($0) } | ||
.flatMap { $0 } | ||
|
||
guard classes.count > 0 else { return nil } | ||
|
||
let metadata = ModuleTestMetadata(module: module, testManifestPath: testManifestPath, classes: classes) | ||
|
||
//now generate the XCTestManifest.swift file | ||
try writeXCTestManifest(metadata, path: testManifestPath) | ||
|
||
return metadata | ||
} | ||
|
||
func writeXCTestManifest(metadata: ModuleTestMetadata, path: String) throws { | ||
|
||
let file = try fopen(path, mode: .Write) | ||
defer { | ||
fclose(file) | ||
} | ||
|
||
//imports | ||
try fputs("import XCTest\n", file) | ||
try fputs("\n", file) | ||
|
||
try fputs("#if os(Linux)\n", file) | ||
|
||
//for each class | ||
try metadata.classes.sort { $0.name < $1.name }.forEach { classMetadata in | ||
try fputs("extension \(classMetadata.name): XCTestCaseProvider {\n", file) | ||
try fputs(" var allTests : [(String, () throws -> Void)] {\n", file) | ||
try fputs(" return [\n", file) | ||
|
||
try classMetadata.testNames.sort().forEach { | ||
try fputs(" (\"\($0)\", \($0)),\n", file) | ||
} | ||
|
||
try fputs(" ]\n", file) | ||
try fputs(" }\n", file) | ||
try fputs("}\n", file) | ||
} | ||
|
||
try fputs("#endif\n\n", file) | ||
} | ||
|
||
func writeXCTestMain(metadata: [ModuleTestMetadata], path: String) throws { | ||
|
||
//don't write anything if no classes are available | ||
guard metadata.count > 0 else { return } | ||
|
||
let file = try fopen(path, mode: .Write) | ||
defer { | ||
fclose(file) | ||
} | ||
|
||
//imports | ||
try fputs("import XCTest\n", file) | ||
try metadata.flatMap { $0.module.importableName() }.sort().forEach { | ||
try fputs("@testable import \($0)\n", file) | ||
} | ||
try fputs("\n", file) | ||
|
||
try fputs("XCTMain([\n", file) | ||
|
||
//for each class | ||
for moduleMetadata in metadata { | ||
try moduleMetadata | ||
.classes | ||
.sort { $0.name < $1.name } | ||
.forEach { classMetadata in | ||
try fputs(" \(moduleMetadata.module.importableName()).\(classMetadata.name)(),\n", file) | ||
} | ||
} | ||
|
||
try fputs("])\n\n", file) | ||
} | ||
|
||
extension Module { | ||
|
||
func importableName() -> String { | ||
//module name e.g. "Build.test" needs to be imported as "Buildtest" | ||
//so remove all occurences of '.' | ||
let name = self.name.splitWithCharactersInString(".").joinWithSeparator("") | ||
return name | ||
} | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,114 @@ | ||
/* | ||
This source file is part of the Swift.org open source project | ||
|
||
Copyright 2015 - 2016 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 | ||
See http://swift.org/CONTRIBUTORS.txt for Swift project authors | ||
*/ | ||
|
||
import Utility | ||
import PackageType | ||
|
||
struct TestClassMetadata { | ||
let name: String | ||
let testNames: [String] | ||
} | ||
|
||
struct ModuleTestMetadata { | ||
let module: TestModule | ||
let testManifestPath: String | ||
let classes: [TestClassMetadata] | ||
} | ||
|
||
protocol TestMetadataParser { | ||
func parseTestClasses(testFilePath: String) throws -> [TestClassMetadata]? | ||
} | ||
|
||
//up for grabs | ||
//struct ASTTestMetadataParser: TestMetadataParser { | ||
// | ||
// | ||
//} | ||
|
||
struct StringTestMetadataParser: TestMetadataParser { | ||
|
||
/// Based on the absolute path passed in parses test class metadata | ||
/// Returns array because there might be more test classes in one file | ||
func parseTestClasses(testFilePath: String) throws -> [TestClassMetadata]? { | ||
|
||
guard let lines = try? File(path: testFilePath).enumerate() else { return nil } | ||
|
||
var classes = [TestClassMetadata]() | ||
func finishClass(className: String?, _ testNames: [String]) { | ||
if let className = className where testNames.count > 0 { | ||
let testClass = TestClassMetadata(name: className, testNames: testNames) | ||
classes.append(testClass) | ||
} | ||
} | ||
|
||
//parse lines one by one, no need to load the whole into memory | ||
var className: String? = nil | ||
var testNames = [String]() | ||
while true { | ||
|
||
guard let line = lines.next() else { | ||
//EOF, no more classes | ||
finishClass(className, testNames) | ||
testNames = [] | ||
return classes.nilIfEmpty() | ||
} | ||
|
||
//look for a class name | ||
if let newClassName = parseClassName(line) { | ||
//found class name, finish old class and start new | ||
finishClass(className, testNames) | ||
className = newClassName | ||
testNames = [] | ||
} | ||
|
||
//look for a test case | ||
if let testName = parseTestName(line) { | ||
//found another test name | ||
testNames.append(testName) | ||
} | ||
} | ||
} | ||
|
||
func parseClassName(line: String) -> String? { | ||
var comps = line.splitWithCharactersInString(":\r\t\n ").filter { !$0.isEmpty } | ||
guard comps.count >= 2 else { return nil } | ||
//see if this line is a candidate | ||
guard Set(comps).contains("class") else { return nil } | ||
while true { | ||
let allowedFirst = Set(["private", "public", "internal", "final"]) | ||
if allowedFirst.contains(comps[0]) { | ||
//drop first | ||
comps = Array(comps.dropFirst()) | ||
} else { | ||
break | ||
} | ||
} | ||
guard comps[0] == "class" else { return nil } | ||
guard comps[2] == "XCTestCase" else { return nil } | ||
return comps[1] | ||
} | ||
|
||
func parseTestName(line: String) -> String? { | ||
let comps = line.splitWithCharactersInString(" \t\n\r{").filter { !$0.isEmpty } | ||
guard comps.count >= 2 else { return nil } | ||
guard comps[0] == "func" else { return nil } | ||
guard comps[1].hasPrefix("test") else { return nil } | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since swift-corelibs-xctest allows users to list their own test methods, the technical restriction that test methods begin with "test" has been lifted. Developers out there using SwiftPM but only writing tests for Linux could very well be writing tests that don't start with "test". There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Interesting, I had no idea.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Still, I think this pull request is worth it. We can document that users need to begin their test methods with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree that this code-gen solution should behave the same way as Xcode XCTest in that it looks for |
||
guard comps[1].hasSuffix("()") else { return nil } | ||
//remove trailing parentheses | ||
return String(comps[1].characters.dropLast(2)) | ||
} | ||
} | ||
|
||
extension Array { | ||
public func nilIfEmpty() -> Array? { | ||
return self.isEmpty ? nil : self | ||
} | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If a test case is declared
private
, we won't be able to reference it from within the test manifest file. How about adding a test that verifies the following doesn't break your parser?I admit that it would be silly for any user to do this, but technically I believe Apple XCTest does run private
XCTestCase
classes written in Swift, so it's conceivable someone out there has written something like this before.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(I think you mean
private class MySecretTestCase:...
)As things are now, we will still generate a manifest, but the compiler will fail on not being able to reference
MySecretTestCase
from its module's correspondingLinuxTestManifest.swift
. I wonder what we should do here, I think erroring out is fine. Do private test classes even work?TBH, hopefully this would become easier if SEP-0025 passed, but for the time being, I'm accepting all ideas.
Obviously, we could detect
private
here right away and throw a very specific error that would tell the use to make their test class at leastinternal
, but since this is a parser, I'd prefer it to not have any side effects (but could live with it).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Whoops! Updated my comment to
private class
. Thanks for pointing that out.)My vote is for a very specific error, although I agree with your concerns about how to implement it. I think end users would be confused if
swift test
failed with a build error. It'd be nicer if we failed with an error such as: "class Foo is marked private. Private test case classes are not supported. Please mark Foo as internal or public."There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code generation actually runs during
swift build
, so the user would know right away even when not running tests.If we rely on the parser detecting this case, we can easily throw an error during the generation with this specific text and ask the user to fix it. We can even easily point them to the right file, even line number.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm just wondering whether you think it's an appropriate thing to do - to not let the compiler fail, instead fail earlier in the process with the specific error text. I think it is but I'm looking for a second set of eyes here.