| // 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 Foundation |
| import XCTest |
| @testable import TulsiGenerator |
| |
| /// Base class for test cases utilizing an external Bazel instance. |
| class BazelIntegrationTestCase: XCTestCase { |
| |
| var bazelURL: URL! = nil |
| var bazelStartupOptions = [String]() |
| var bazelBuildOptions = [String]() |
| var runfilesURL: URL! = nil |
| var fakeBazelWorkspace: BazelFakeWorkspace! = nil |
| var bazelUniversalFlags = BazelFlags() |
| var workspaceRootURL: URL! = nil |
| var testUndeclaredOutputsDir: URL? = nil |
| var workspaceInfoFetcher: BazelWorkspacePathInfoFetcher! = nil |
| var localizedMessageLogger: DirectLocalizedMessageLogger! = nil |
| |
| private var pathsToCleanOnTeardown = Set<URL>() |
| |
| override func setUp() { |
| super.setUp() |
| |
| guard let test_srcdir = ProcessInfo.processInfo.environment["TEST_SRCDIR"] else { |
| XCTFail("This test must be run as a bazel test and/or must define $TEST_SRCDIR.") |
| return |
| } |
| runfilesURL = URL(fileURLWithPath: test_srcdir) |
| |
| let tempdir = ProcessInfo.processInfo.environment["TEST_TMPDIR"] ?? NSTemporaryDirectory() |
| let tempdirURL = URL(fileURLWithPath: tempdir, |
| isDirectory: true) |
| localizedMessageLogger = DirectLocalizedMessageLogger() |
| localizedMessageLogger.startLogging() |
| fakeBazelWorkspace = BazelFakeWorkspace(runfilesURL: runfilesURL, |
| tempDirURL: tempdirURL, |
| messageLogger: localizedMessageLogger).setup() |
| pathsToCleanOnTeardown.formUnion(fakeBazelWorkspace.pathsToCleanOnTeardown) |
| workspaceRootURL = fakeBazelWorkspace.workspaceRootURL |
| bazelURL = fakeBazelWorkspace.bazelURL |
| |
| // Add any build options specific to the fakeBazelWorkspace. |
| bazelBuildOptions.append(contentsOf: fakeBazelWorkspace.extraBuildFlags) |
| |
| if let testOutputPath = ProcessInfo.processInfo.environment["TEST_UNDECLARED_OUTPUTS_DIR"] { |
| testUndeclaredOutputsDir = URL(fileURLWithPath: testOutputPath, isDirectory: true) |
| } |
| |
| // Source is being copied outside of the normal workspace, so status commands won't work. |
| bazelBuildOptions.append("--workspace_status_command=/usr/bin/true") |
| |
| // Integration tests is missing mocks for C++ toolchain resolution |
| // TODO(b/225026735): Remove after C++ toolchains are correctly mocked |
| bazelBuildOptions.append("--noincompatible_enable_cc_toolchain_resolution") |
| |
| // Prevent any custom --bazelrc startup option to be specified. It should always be /dev/null. |
| for startupOption in bazelStartupOptions { |
| if (startupOption.hasPrefix("--bazelrc") && startupOption != "--bazelrc=/dev/null") { |
| fatalError("testBazelStartupOptions includes custom bazelrc, which is not allowed '\(startupOption)'") |
| } |
| } |
| bazelStartupOptions.append("--bazelrc=/dev/null") |
| |
| // Prevent any custom --*_minimum_os build option to be specified for tests, as this will |
| // effectively remove the reproduceability of the generated projects. |
| for bazelBuildOption in bazelBuildOptions { |
| if (bazelBuildOption.hasPrefix("--ios_minimum_os") || |
| bazelBuildOption.hasPrefix("--macos_minimum_os") || |
| bazelBuildOption.hasPrefix("--tvos_minimum_os") || |
| bazelBuildOption.hasPrefix("--watchos_minimum_os")) { |
| fatalError("testBazelBuildOptions includes minimum deployment " + |
| "version '\(bazelBuildOption)'. Setting this value is not allowed.") |
| } |
| } |
| |
| // Set the default deployment versions for all platforms to prevent different Xcode from |
| // producing different generated projects that only differ on *_DEPLOYMENT_VERSION values. |
| bazelBuildOptions.append("--ios_minimum_os=11.0") |
| bazelBuildOptions.append("--macos_minimum_os=10.13") |
| bazelBuildOptions.append("--tvos_minimum_os=11.0") |
| bazelBuildOptions.append("--watchos_minimum_os=4.0") |
| |
| // Explicitly set Xcode version to use. Must use the same version or the golden files |
| // won't match. |
| bazelBuildOptions.append("--xcode_version=14.2.0") |
| |
| // Disable the Swift worker as it adds extra dependencies. |
| bazelBuildOptions.append("--define=RULES_SWIFT_BUILD_DUMMY_WORKER=1") |
| bazelBuildOptions.append("--strategy=SwiftCompile=local") |
| |
| // We rely on dynamic execution in the tests, so we can't disable it for |
| // the clean builds. |
| // TODO(b/203094728): Remove this when it is removed from the ox bazelrc. |
| bazelBuildOptions.append("--noexperimental_dynamic_skip_first_build") |
| |
| // Force to build on Mac. |
| bazelBuildOptions.append("--extra_execution_platforms=//buildenv/platforms/apple:macos_fom_intel") |
| bazelBuildOptions.append("--noexperimental_stream_log_file_uploads") |
| |
| guard let workspaceRootURL = workspaceRootURL else { |
| fatalError("Failed to find workspaceRootURL.") |
| } |
| workspaceInfoFetcher = BazelWorkspacePathInfoFetcher(bazelURL: bazelURL, |
| workspaceRootURL: workspaceRootURL, |
| bazelUniversalFlags: bazelUniversalFlags, |
| localizedMessageLogger: localizedMessageLogger) |
| } |
| |
| override func tearDown() { |
| super.tearDown() |
| cleanCreatedFiles() |
| workspaceInfoFetcher = nil |
| if localizedMessageLogger != nil { |
| localizedMessageLogger.stopLogging() |
| } |
| localizedMessageLogger = nil |
| } |
| |
| /// Copies the .BUILD file bundled under the given name into the test workspace, as well as any |
| /// .bzl file with the same root name. |
| @discardableResult |
| func installBUILDFile(_ fileResourceName: String, |
| intoSubdirectory subdirectory: String? = nil, |
| fromResourceDirectory resourceDirectory: String? = nil, |
| file: StaticString = #file, |
| line: UInt = #line) -> URL? { |
| guard var resourceDirectoryURL = getWorkspaceDirectory(fakeBazelWorkspace.resourcesPathBase) |
| else { return nil } |
| if let resourceDirectory = resourceDirectory { |
| resourceDirectoryURL.appendPathComponent(resourceDirectory, isDirectory: true) |
| } |
| let buildFileURL = resourceDirectoryURL.appendingPathComponent("\(fileResourceName).BUILD", |
| isDirectory: false) |
| |
| guard let directoryURL = getWorkspaceDirectory(subdirectory) else { return nil } |
| |
| @discardableResult |
| func copyFile(_ sourceFileURL: URL, |
| toFileNamed targetName: String) -> URL? { |
| let destinationURL = directoryURL.appendingPathComponent(targetName, isDirectory: false) |
| do { |
| let fileManager = FileManager.default |
| if fileManager.fileExists(atPath: destinationURL.path) { |
| try fileManager.removeItem(at: destinationURL) |
| } |
| try fileManager.copyItem(at: sourceFileURL, to: destinationURL) |
| pathsToCleanOnTeardown.insert(destinationURL) |
| } catch let e as NSError { |
| XCTFail("Failed to install '\(sourceFileURL)' to '\(destinationURL)' for test. Error: \(e.localizedDescription)", |
| file: file, |
| line: line) |
| return nil |
| } |
| return destinationURL |
| } |
| |
| guard let destinationURL = copyFile(buildFileURL, toFileNamed: "BUILD") else { |
| return nil |
| } |
| |
| let skylarkFileURL = resourceDirectoryURL.appendingPathComponent("\(fileResourceName).bzl") |
| if FileManager.default.fileExists(atPath: skylarkFileURL.path) { |
| copyFile(skylarkFileURL, toFileNamed: skylarkFileURL.lastPathComponent) |
| } |
| |
| return destinationURL |
| } |
| |
| /// Creates a file in the test workspace with the given contents. |
| func makeFileNamed(_ name: String, |
| withData data: Data, |
| inSubdirectory subdirectory: String? = nil, |
| file: StaticString = #file, |
| line: UInt = #line) -> URL? { |
| guard let directoryURL = getWorkspaceDirectory(subdirectory, |
| file: file, |
| line: line) else { |
| return nil |
| } |
| |
| let fileURL = directoryURL.appendingPathComponent(name, isDirectory: false) |
| XCTAssertTrue((try? data.write(to: fileURL, options: [.atomic])) != nil, |
| "Failed to write to file at '\(fileURL.path)'", |
| file: file, |
| line: line) |
| pathsToCleanOnTeardown.insert(fileURL) |
| return fileURL |
| } |
| |
| /// Creates a file in the test workspace with the given contents. |
| @discardableResult |
| func makeFileNamed(_ name: String, |
| withContent content: String = "", |
| inSubdirectory subdirectory: String? = nil, |
| file: StaticString = #file, |
| line: UInt = #line) -> URL? { |
| guard let data = (content as NSString).data(using: String.Encoding.utf8.rawValue) else { |
| XCTFail("Failed to convert file contents '\(content)' to UTF8-encoded NSData", |
| file: file, |
| line: line) |
| return nil |
| } |
| return makeFileNamed(name, withData: data, inSubdirectory: subdirectory, file: file, line: line) |
| } |
| |
| /// Creates a plist file in the test workspace with the given contents. |
| @discardableResult |
| func makePlistFileNamed(_ name: String, |
| withContent content: [String: Any], |
| inSubdirectory subdirectory: String? = nil, |
| file: StaticString = #file, |
| line: UInt = #line) -> URL? { |
| do { |
| let data = try PropertyListSerialization.data(fromPropertyList: content, |
| format: .xml, |
| options: 0) |
| return makeFileNamed(name, withData: data, inSubdirectory: subdirectory, file: file, line: line) |
| } catch let e { |
| XCTFail("Failed to serialize content: \(e)", file: file, line: line) |
| return nil |
| } |
| } |
| |
| /// Creates a mock xcdatamodel bundle in the given subdirectory. |
| @discardableResult |
| func makeTestXCDataModel(_ name: String, |
| inSubdirectory subdirectory: String, |
| file: StaticString = #file, |
| line: UInt = #line) -> URL? { |
| guard let _ = getWorkspaceDirectory(subdirectory, file: file, line: line) else { |
| return nil |
| } |
| |
| let dataModelPath = "\(subdirectory)/\(name).xcdatamodel" |
| guard let datamodelURL = getWorkspaceDirectory(dataModelPath, file: file, line: line) else { |
| return nil |
| } |
| guard let _ = makeFileNamed("elements", |
| withContent: "", |
| inSubdirectory: dataModelPath, |
| file: file, |
| line: line), |
| let _ = makeFileNamed("layout", |
| withContent: "", |
| inSubdirectory: dataModelPath, |
| file: file, |
| line: line) else { |
| return nil |
| } |
| |
| return datamodelURL |
| } |
| |
| /// Creates a workspace-relative directory that will be cleaned up by the test on exit. |
| func makeTestSubdirectory(_ subdirectory: String, |
| rootDirectory: URL? = nil, |
| cleanupOnTeardown: Bool = true, |
| file: StaticString = #file, |
| line: UInt = #line) -> URL? { |
| return getWorkspaceDirectory(subdirectory, |
| rootDirectory: rootDirectory, |
| cleanupOnTeardown: cleanupOnTeardown, |
| file: file, |
| line: line) |
| } |
| |
| func makeXcodeProjPath(_ subdirectory: String, |
| file: StaticString = #file, |
| line: UInt = #line) -> URL? { |
| return getWorkspaceDirectory(subdirectory, file: file, line: line) |
| } |
| |
| // MARK: - Private methods |
| |
| fileprivate func getWorkspaceDirectory(_ subdirectory: String? = nil, |
| rootDirectory: URL? = nil, |
| cleanupOnTeardown: Bool = true, |
| file: StaticString = #file, |
| line: UInt = #line) -> URL? { |
| guard let tempDirectory = rootDirectory ?? workspaceRootURL else { |
| XCTFail("Cannot create test workspace directory, workspaceRootURL is nil", |
| file: file, |
| line: line) |
| return nil |
| } |
| |
| if let subdirectory = subdirectory, !subdirectory.isEmpty { |
| let directoryURL = tempDirectory.appendingPathComponent(subdirectory, isDirectory: true) |
| if cleanupOnTeardown { |
| pathsToCleanOnTeardown.insert(directoryURL) |
| } |
| do { |
| try FileManager.default.createDirectory(at: directoryURL, |
| withIntermediateDirectories: true, |
| attributes: nil) |
| return directoryURL |
| } catch let e as NSError { |
| XCTFail("Failed to create directory '\(directoryURL.path)'. Error: \(e.localizedDescription)", |
| file: file, |
| line: line) |
| } |
| return nil |
| } |
| |
| return tempDirectory |
| } |
| |
| fileprivate func cleanCreatedFiles() { |
| let fileManager = FileManager.default |
| // Sort such that deeper paths are removed before their parents. |
| let sortedPaths = pathsToCleanOnTeardown.sorted(by: { $1.path < $0.path }) |
| for url in sortedPaths { |
| do { |
| try fileManager.removeItem(at: url) |
| } catch let e as NSError { |
| XCTFail(String(format: "Failed to remove test's temp directory at '%@'. Error: %@", |
| url.path, |
| e)) |
| } |
| } |
| } |
| |
| |
| /// Override for LocalizedMessageLogger that prints immediately rather than bouncing to the main |
| /// thread. |
| final class DirectLocalizedMessageLogger: LocalizedMessageLogger { |
| var observer: NSObjectProtocol? = nil |
| |
| init() { |
| super.init(bundle: Bundle(for: TulsiXcodeProjectGenerator.self)) |
| } |
| |
| deinit { |
| stopLogging() |
| } |
| |
| func startLogging() { |
| observer = NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: TulsiMessageNotification), |
| object: nil, |
| queue: nil) { |
| [weak self] (notification: Notification) in |
| guard let item = LogMessage(notification: notification) else { |
| guard !(notification.userInfo?["displayErrors"] as? Bool ?? false) else { |
| return |
| } |
| |
| XCTFail("Invalid message notification received (failed to convert to LogMessage)") |
| return |
| } |
| self?.handleMessage(item) |
| } |
| } |
| |
| func stopLogging() { |
| if let observer = self.observer { |
| NotificationCenter.default.removeObserver(observer) |
| } |
| } |
| |
| fileprivate func handleMessage(_ item: LogMessage) { |
| switch item.level { |
| case .Error: |
| if let details = item.details { |
| print("> Critical error logged: \(item.message)\nDetails:\n\(details)") |
| } else { |
| print("> Critical error logged: \(item.message)") |
| } |
| case .Warning: |
| print("> W: \(item.message)") |
| case .Info: |
| print("> I: \(item.message)") |
| case .Syslog: |
| print("> S: \(item.message)") |
| case .Debug: |
| print("> D: \(item.message)") |
| } |
| } |
| } |
| } |