Skip to content

[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

Closed
wants to merge 23 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
079859b
[CI] Added .travis.yml to potentially use Travis as a first-pass CI t…
czechboy0 Feb 29, 2016
1ce878e
Travis bump
czechboy0 Feb 29, 2016
30be12a
[CI] Inject the latest Swift version so that swiftenv knows what to i…
czechboy0 Feb 29, 2016
843622b
[CI] Use the just-built swift test binary
czechboy0 Feb 29, 2016
6cf1fd7
[CI] Updated .travis.yml according to @kylef's comments.
czechboy0 Feb 29, 2016
d837e3e
Refactor with Buildable protocol
aciidgh Feb 23, 2016
29df147
Add -g for products in debug conf
aciidgh Feb 27, 2016
77ba85b
Add failing tests matching dependency with pre-release version
norio-nomura Feb 22, 2016
ad4a367
Stop matching pre-release suffixed next version
norio-nomura Feb 22, 2016
7a123b5
Add `PackageDescriptiontest.PackageTests()` in `LinuxMain.swift`
norio-nomura Feb 26, 2016
d5b312f
- first stab at automatically generating Linux test manifests
czechboy0 Feb 27, 2016
174083b
- added import for fclose
czechboy0 Feb 27, 2016
f4421fc
- realizing that we'll need to add those files to sources, the LinuxM…
czechboy0 Feb 27, 2016
cd8f808
- doesn't generate a manifest if no test class was found in a test mo…
czechboy0 Feb 27, 2016
4111842
- now LinuxMain has a list of test classes which are ALL scoped by th…
czechboy0 Feb 28, 2016
7828109
[Parsing] improved test metadata parser
czechboy0 Feb 28, 2016
60853f1
Renamed Linux... to XCTest...
czechboy0 Mar 1, 2016
b5ba4a6
Throw error if `debug.yaml` not found
aciidgh Feb 27, 2016
7194ed3
Change if to guard
aciidgh Feb 27, 2016
939b8a8
Add \n at EOF
aciidgh Feb 27, 2016
c3948d6
Update SourceLayouts.md
aciidgh Feb 27, 2016
454b531
DRY isLibrary check
mxcl Feb 29, 2016
30c1f44
Merge branch 'master' into hd/autogenerate_test_manifest
czechboy0 Mar 1, 2016
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .travis.yml
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
4 changes: 2 additions & 2 deletions Documentation/SourceLayouts.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,6 @@ Where `foo` is an executable and `bar.a` a static library.

## Other Rules

* Directories named `Tests` are ignored
* Sub directories of a directory named `Sources`, `Source`, `srcs` or `src` become modules
* Sub directories of directory named `Tests` become test-modules and are executed by `swift test`. `Tests` or any subdirectory can be excluded via Manifest file.
* Sub directories of a directory named `Sources`, `Source`, `srcs` or `src` become modules.
* It is acceptable to have no `Sources` directory, in which case the root directory is treated as a single module (place your sources there) or sub directories of the root are considered modules. Use this layout convention for simple projects.
206 changes: 206 additions & 0 deletions Sources/Build/TestMetadata.swift
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
}
}


114 changes: 114 additions & 0 deletions Sources/Build/TestMetadataParser.swift
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"])
Copy link
Contributor

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?

private class MySecretTestCase: XCTestCase, XCTestCaseProvider {
    func testMySecretThing() { /* ... */ }
}

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.

Copy link
Member Author

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 corresponding LinuxTestManifest.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 least internal, but since this is a parser, I'd prefer it to not have any side effects (but could live with it).

Copy link
Contributor

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."

Copy link
Member Author

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.

Copy link
Member Author

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.

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 }
Copy link
Contributor

Choose a reason for hiding this comment

The 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".

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, I had no idea.

  • Is that just an implementation artifact for the time being or is this the general direction of XCTest for both platforms? Manually providing which tests should be called is going against automatic discovery in general. You are correct, I didn't assume someone would write tests exclusively for Linux and yes, we're forcing those people to remove their own manifests and rename to test... to get testing working again for them.
  • As mentioned above, this will get hit by the symbol duplication for XCTestCaseProvider. Thus users will be forced to remove their inline Linux manifests in order for this to work. I think that's the cleanest solution to get everyone on the same page.

Copy link
Contributor

Choose a reason for hiding this comment

The 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 test, and I think they're used to this idea.

Copy link
Contributor

Choose a reason for hiding this comment

The 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 test... methods when generating the manifest.

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
}
}

Loading