| // Copyright 2016 The Tulsi Authors. All rights reserved. |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| import XCTest |
| @testable import BazelIntegrationTestCase |
| @testable import TulsiGenerator |
| |
| |
| // Base class for end-to-end tests that generate xcodeproj bundles and validate them against golden |
| // versions. |
| class EndToEndIntegrationTestCase : BazelIntegrationTestCase { |
| enum Error: Swift.Error { |
| /// A subdirectory for the Xcode project could not be created. |
| case testSubdirectoryNotCreated |
| /// The Xcode project could not be generated. |
| case projectGenerationFailure(String) |
| /// Unable to execute user_build.py script. |
| case userBuildScriptInvocationFailure(String) |
| } |
| |
| let extraDebugFlags = ["--define=TULSI_TEST=dbg"] |
| let extraReleaseFlags = ["--define=TULSI_TEST=rel"] |
| let fakeBazelURL = URL(fileURLWithPath: "/fake/tulsi_test_bazel", isDirectory: false) |
| let testTulsiVersion = "9.99.999.9999" |
| |
| final func validateDiff(_ diffLines: [String], for resourceName: String, file: StaticString = #file, line: UInt = #line) { |
| guard !diffLines.isEmpty else { return } |
| let message = "\(resourceName) xcodeproj does not match its golden. Diff output:\n\(diffLines.joined(separator: "\n"))" |
| XCTFail(message, file: file, line: line) |
| } |
| |
| final func diffProjectAt(_ projectURL: URL, |
| againstGoldenProject resourceName: String, |
| file: StaticString = #file, |
| line: UInt = #line) -> [String] { |
| guard let hashing = ProcessInfo.processInfo.environment["SWIFT_DETERMINISTIC_HASHING"], |
| hashing == "1" else { |
| XCTFail("Must define environment variable \"SWIFT_DETERMINISTIC_HASHING=1\", or golden tests will fail.") |
| return [] |
| } |
| let goldenProjectURL = workspaceRootURL.appendingPathComponent(fakeBazelWorkspace |
| .resourcesPathBase, |
| isDirectory: true) |
| .appendingPathComponent("GoldenProjects/\(resourceName).xcodeproj", isDirectory: true) |
| guard FileManager.default.fileExists(atPath: goldenProjectURL.path) else { |
| assertionFailure("Missing required test resource file \(resourceName).xcodeproj") |
| XCTFail("Missing required test resource file \(resourceName).xcodeproj", |
| file: file, |
| line: line) |
| return [] |
| } |
| |
| var diffOutput = [String]() |
| let semaphore = DispatchSemaphore(value: 0) |
| let process = ProcessRunner.createProcess("/usr/bin/diff", |
| arguments: ["-r", |
| // For the sake of simplicity in |
| // maintaining the golden data, copied |
| // Tulsi artifacts are assumed to have |
| // been installed correctly. |
| "--exclude=.tulsi", |
| "--exclude=__init__.py", |
| projectURL.path, |
| goldenProjectURL.path]) { |
| completionInfo in |
| defer { |
| semaphore.signal() |
| } |
| if let stdout = NSString(data: completionInfo.stdout, encoding: String.Encoding.utf8.rawValue) { |
| diffOutput = stdout.components(separatedBy: "\n").filter({ !$0.isEmpty }) |
| } else { |
| XCTFail("No output received for diff command", file: file, line: line) |
| } |
| } |
| process.currentDirectoryPath = workspaceRootURL.path |
| process.launch() |
| |
| _ = semaphore.wait(timeout: DispatchTime.distantFuture) |
| return diffOutput |
| } |
| |
| final func copyOutput(source: URL, outputDir: String) throws { |
| if testUndeclaredOutputsDir != nil { |
| guard let testOutputURL = makeTestSubdirectory(outputDir, |
| rootDirectory: testUndeclaredOutputsDir, |
| cleanupOnTeardown: false) else { |
| throw Error.testSubdirectoryNotCreated |
| } |
| let testOutputProjURL = testOutputURL.appendingPathComponent(source.lastPathComponent) |
| if FileManager.default.fileExists(atPath: testOutputProjURL.path) { |
| try FileManager.default.removeItem(at: testOutputProjURL) |
| } |
| try FileManager.default.copyItem(at: source, to: testOutputProjURL) |
| } |
| } |
| |
| final func validateBuildCommandForProject(_ projectURL: URL, |
| swift: Bool = false, |
| options: TulsiOptionSet = TulsiOptionSet(), |
| targets: [String]) throws { |
| let actualDebug = try userBuildCommandForProject(projectURL, release: false, targets: targets) |
| let actualRelease = try userBuildCommandForProject(projectURL, release: true, targets: targets) |
| let (debug, release) = expectedBuildCommands(swift: swift, options: options, targets: targets) |
| |
| XCTAssertEqual(actualDebug, debug) |
| XCTAssertEqual(actualRelease, release) |
| } |
| |
| final func expectedBuildCommands(swift: Bool, |
| options: TulsiOptionSet, |
| targets: [String]) -> (String, String) { |
| let provider = BazelSettingsProvider(universalFlags: bazelUniversalFlags) |
| let features = BazelBuildSettingsFeatures.enabledFeatures(options: options) |
| let dbg = provider.tulsiFlags(hasSwift: swift, options: options, features: features).debug |
| let rel = provider.tulsiFlags(hasSwift: swift, options: options, features: features).release |
| |
| let config: PlatformConfiguration |
| if let identifier = options[.ProjectGenerationPlatformConfiguration].commonValue, |
| let parsedConfig = PlatformConfiguration(identifier: identifier) { |
| config = parsedConfig |
| } else { |
| config = PlatformConfiguration.defaultConfiguration |
| } |
| |
| func buildCommand(extraBuildFlags: [String], tulsiFlags: BazelFlags) -> String { |
| var args = [fakeBazelURL.path] |
| args.append(contentsOf: bazelStartupOptions) |
| args.append(contentsOf: tulsiFlags.startup) |
| args.append("build") |
| args.append(contentsOf: extraBuildFlags) |
| args.append(contentsOf: bazelBuildOptions) |
| args.append(contentsOf: config.bazelFlags) |
| args.append(contentsOf: tulsiFlags.build) |
| args.append("--tool_tag=tulsi:user_build") |
| args.append(contentsOf: targets) |
| |
| return args.map { $0.escapingForShell }.joined(separator: " ") |
| } |
| |
| let debugCommand = buildCommand(extraBuildFlags: extraDebugFlags, tulsiFlags: dbg) |
| let releaseCommand = buildCommand(extraBuildFlags: extraReleaseFlags, tulsiFlags: rel) |
| |
| return (debugCommand, releaseCommand) |
| } |
| |
| final func userBuildCommandForProject(_ projectURL: URL, |
| release: Bool = false, |
| targets: [String], |
| file: StaticString = #file, |
| line: UInt = #line) throws -> String { |
| let expectedScriptURL = projectURL.appendingPathComponent(".tulsi/Scripts/user_build.py", |
| isDirectory: false) |
| let fileManager = FileManager.default |
| |
| guard fileManager.fileExists(atPath: expectedScriptURL.path) else { |
| throw Error.userBuildScriptInvocationFailure( |
| "user_build.py script not found: expected at path \(expectedScriptURL.path)") |
| } |
| |
| var output = "<none>" |
| let semaphore = DispatchSemaphore(value: 0) |
| var args = [ |
| "--norun", |
| ] |
| if release { |
| args.append("--release") |
| } |
| args.append(contentsOf: targets) |
| |
| let process = ProcessRunner.createProcess( |
| expectedScriptURL.path, |
| arguments: args, |
| messageLogger: localizedMessageLogger |
| ) { completionInfo in |
| defer { |
| semaphore.signal() |
| } |
| let exitcode = completionInfo.terminationStatus |
| guard exitcode == 0 else { |
| let stderr = |
| String(data: completionInfo.stderr, encoding: .utf8) ?? "<no stderr>" |
| XCTFail("user_build.py returned \(exitcode). stderr: \(stderr)", file: file, line: line) |
| return |
| } |
| if let stdout = String(data: completionInfo.stdout, encoding: .utf8)?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines), |
| !stdout.isEmpty { |
| output = stdout |
| } else { |
| let stderr = |
| String(data: completionInfo.stderr, encoding: .utf8) ?? "<no stderr>" |
| XCTFail("user_build.py had no stdout. stderr: \(stderr)", file: file, line: line) |
| } |
| } |
| process.currentDirectoryPath = workspaceRootURL.path |
| process.launch() |
| |
| _ = semaphore.wait(timeout: DispatchTime.distantFuture) |
| return output |
| } |
| |
| final func generateProjectNamed(_ projectName: String, |
| buildTargets: [RuleInfo], |
| pathFilters: [String], |
| additionalFilePaths: [String] = [], |
| outputDir: String, |
| options: TulsiOptionSet = TulsiOptionSet()) throws -> URL { |
| if !bazelStartupOptions.isEmpty { |
| let startupFlags = bazelStartupOptions.joined(separator: " ") |
| options[.BazelBuildStartupOptionsDebug].projectValue = startupFlags |
| options[.BazelBuildStartupOptionsRelease].projectValue = startupFlags |
| } |
| |
| let debugBuildOptions = extraDebugFlags + bazelBuildOptions |
| let releaseBuildOptions = extraReleaseFlags + bazelBuildOptions |
| |
| options[.BazelBuildOptionsDebug].projectValue = debugBuildOptions.joined(separator: " ") |
| options[.BazelBuildOptionsRelease].projectValue = releaseBuildOptions.joined(separator: " ") |
| options[.RunAspectsFromWorkspace].projectValue = "YES" |
| |
| let bazelURLParam = TulsiParameter(value: fakeBazelURL, source: .explicitlyProvided) |
| let config = TulsiGeneratorConfig(projectName: projectName, |
| buildTargets: buildTargets, |
| pathFilters: Set<String>(pathFilters), |
| additionalFilePaths: additionalFilePaths, |
| options: options, |
| bazelURL: bazelURLParam) |
| |
| guard let outputFolderURL = makeXcodeProjPath(outputDir) else { |
| throw Error.testSubdirectoryNotCreated |
| } |
| |
| let projectGenerator = TulsiXcodeProjectGenerator(workspaceRootURL: workspaceRootURL, |
| config: config, |
| extractorBazelURL: bazelURL, |
| tulsiVersion: testTulsiVersion) |
| // Bazel built-in preprocessor defines are suppressed in order to prevent any |
| // environment-dependent variables from mismatching the golden data. |
| projectGenerator.xcodeProjectGenerator.suppressCompilerDefines = true |
| // Don't update shell command utilities. |
| projectGenerator.xcodeProjectGenerator.suppressUpdatingShellCommands = true |
| // Don't install module cache pruner tool. |
| projectGenerator.xcodeProjectGenerator.suppressModuleCachePrunerInstallation = true |
| // The username is forced to a known value. |
| projectGenerator.xcodeProjectGenerator.usernameFetcher = { "_TEST_USER_" } |
| // The workspace symlink is forced to a known value. |
| projectGenerator.xcodeProjectGenerator.redactWorkspaceSymlink = true |
| let errorInfo: String |
| do { |
| let generatedProjURL = try projectGenerator.generateXcodeProjectInFolder(outputFolderURL) |
| try copyOutput(source: generatedProjURL, outputDir: outputDir) |
| return generatedProjURL |
| } catch TulsiXcodeProjectGenerator.GeneratorError.unsupportedTargetType(let targetType) { |
| errorInfo = "Unsupported target type: \(targetType)" |
| } catch TulsiXcodeProjectGenerator.GeneratorError.serializationFailed(let details) { |
| errorInfo = "General failure: \(details)" |
| } catch let error { |
| errorInfo = "Unexpected failure: \(error)" |
| } |
| throw Error.projectGenerationFailure(errorInfo) |
| } |
| } |