| // 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 |
| |
| // Parent class for end to end tests that generate an xcodeproj with the Tulsi binary and verify the |
| // generated xcodeproj by running the projects unit tests. |
| class TulsiEndToEndTest: BazelIntegrationTestCase { |
| fileprivate static let simulatorName = "tulsie2e-\(UUID().uuidString.prefix(8))" |
| fileprivate static let targetVersion = "16.2" |
| |
| let fileManager = FileManager.default |
| var runfilesWorkspaceURL: URL! = nil |
| |
| // Calling XCTFail() in a setUp() function will cause the test to crash and hide any logs. |
| // Instead, explicitly log that a set up error occured. |
| static func setUpFailure(_ msg: String) { |
| print("SETUP FAILURE: \(msg)") |
| } |
| |
| // Creates a new simulator, for use with testing generated projects, before any tests run. |
| override class func setUp() { |
| super.setUp() |
| |
| let targetDevice = "iPhone 14 Pro" |
| let deviceName = targetDevice.replacingOccurrences(of: " ", with: "-") |
| let deviceVersion = TulsiEndToEndTest.targetVersion.replacingOccurrences(of: ".", with: "-") |
| let typeId = "com.apple.CoreSimulator.SimDeviceType.\(deviceName)" |
| let runtimeId = "com.apple.CoreSimulator.SimRuntime.iOS-\(deviceVersion)" |
| let completionInfo = ProcessRunner.launchProcessSync( |
| "/usr/bin/xcrun", |
| arguments: [ |
| "simctl", |
| "create", |
| TulsiEndToEndTest.simulatorName, |
| typeId, |
| runtimeId |
| ]) |
| |
| if completionInfo.terminationStatus != 0 { |
| if let stderr = String(data: completionInfo.stderr, encoding: .utf8), !stderr.isEmpty { |
| TulsiEndToEndTest.setUpFailure( |
| "\(completionInfo.commandlineString) failed with error: \(stderr)") |
| } else { |
| TulsiEndToEndTest.setUpFailure( |
| "\(completionInfo.commandlineString) encountered an error. Exit code \(completionInfo.terminationStatus)." |
| ) |
| } |
| } |
| |
| // 'simctl' should output the UUID of the new simulator if it was created successfully. |
| if let stdout = String(data: completionInfo.stdout, encoding: .utf8), stdout.isEmpty { |
| TulsiEndToEndTest.setUpFailure("No UUID was ouputted for newly created simulator.") |
| } |
| } |
| |
| // Deletes the simulator created after all tests have run. |
| override class func tearDown() { |
| super.tearDown() |
| |
| let completionInfo = ProcessRunner.launchProcessSync( |
| "/usr/bin/xcrun", |
| arguments: [ |
| "simctl", |
| "delete", |
| TulsiEndToEndTest.simulatorName |
| ]) |
| |
| if let error = String(data: completionInfo.stderr, encoding: .utf8), !error.isEmpty { |
| print( |
| """ |
| \(completionInfo.commandlineString) failed with exit code: \(completionInfo.terminationStatus) |
| Error: \(error) |
| """ |
| ) |
| } |
| } |
| |
| // Unzips Tulsi and boots the simulator before each test case. |
| override func setUp() { |
| super.setUp() |
| super.continueAfterFailure = false |
| runfilesWorkspaceURL = fakeBazelWorkspace.runfilesWorkspaceURL |
| XCTAssertNotNil(runfilesWorkspaceURL, "runfilesWorkspaceURL must be not be nil after setup.") |
| |
| // Extracting only needs to be done once but we can't do this during the class 'setUp' function |
| // because we need access to instance variables. Instead, check here if Tulsi has already been |
| // extracted. |
| if !fileManager.fileExists(atPath: workspaceRootURL.appendingPathComponent("Tulsi.app").path) { |
| // Unzip the Tulsi.app bundle to the temp space. |
| let tulsiZipPath = "tulsi.zip" |
| let tulsiZipURL = runfilesWorkspaceURL.appendingPathComponent( |
| tulsiZipPath, isDirectory: false) |
| let completionInfo = ProcessRunner.launchProcessSync( |
| "/usr/bin/unzip", |
| arguments: [ |
| tulsiZipURL.path, |
| "-d", |
| workspaceRootURL.path |
| ]) |
| |
| if let error = String(data: completionInfo.stderr, encoding: .utf8), !error.isEmpty { |
| TulsiEndToEndTest.setUpFailure(error) |
| } |
| } |
| |
| // Boots the simulator in the background. |
| self.runSimctlCommand("boot", onSimulator: TulsiEndToEndTest.simulatorName) |
| } |
| |
| // Shuts down and erases the simulator after each test case. |
| override func tearDown() { |
| super.tearDown() |
| self.runSimctlCommand("shutdown", onSimulator: TulsiEndToEndTest.simulatorName) |
| self.runSimctlCommand("erase", onSimulator: TulsiEndToEndTest.simulatorName) |
| } |
| |
| // Takes a short path to data files and adds them to the fake Bazel workspace. |
| func copyDataToFakeWorkspace(_ path: String) -> Bool { |
| let sourceURL = runfilesWorkspaceURL.appendingPathComponent(path, isDirectory: false) |
| let destURL = workspaceRootURL.appendingPathComponent(path, isDirectory: false) |
| do { |
| if !fileManager.fileExists(atPath: sourceURL.path) { |
| XCTFail("Source file \(sourceURL.path) does not exist.") |
| } |
| if fileManager.fileExists(atPath: destURL.path) { |
| try fileManager.removeItem(at: destURL) |
| } |
| |
| // Symlinks cause issues with Tulsi and Storyboards so must deep copy any data files. |
| try fileManager.deepCopyItem(at: sourceURL, to: destURL) |
| return true |
| } catch let e as NSError { |
| print(e.localizedDescription) |
| return false |
| } |
| } |
| |
| // Runs the Tulsi binary with the given Tulsi project and config to generate an Xcode project. |
| func generateXcodeProject(tulsiProject path: String, config: String) -> URL { |
| let tulsiBinURL = workspaceRootURL.appendingPathComponent( |
| "Tulsi.app/Contents/MacOS/Tulsi", isDirectory: false) |
| XCTAssert(fileManager.fileExists(atPath: tulsiBinURL.path), "Tulsi binary is missing.") |
| |
| let projectURL = workspaceRootURL.appendingPathComponent(path, isDirectory: true) |
| XCTAssert(fileManager.fileExists(atPath: projectURL.path), "Tulsi project is missing.") |
| let configPath = projectURL.path + ":" + config |
| var args: [String] = [ |
| "--", |
| "--genconfig", |
| configPath, |
| "--outputfolder", |
| workspaceRootURL.path, |
| "--bazel", |
| bazelURL.path, |
| "--no-open-xcode" |
| ] |
| if !bazelBuildOptions.isEmpty { |
| args.append("--build-options") |
| args.append(bazelBuildOptions.joined(separator: " ")) |
| } |
| |
| // Generate Xcode project with Tulsi. |
| let completionInfo = ProcessRunner.launchProcessSync( |
| tulsiBinURL.path, |
| arguments: args) |
| |
| if let stdoutput = String(data: completionInfo.stdout, encoding: .utf8) { |
| print(stdoutput) |
| } |
| if let erroutput = String(data: completionInfo.stderr, encoding: .utf8) { |
| print(erroutput) |
| } |
| |
| let filename = TulsiGeneratorConfig.sanitizeFilename("\(config).xcodeproj") |
| let xcodeProjectURL = workspaceRootURL.appendingPathComponent(filename, isDirectory: true) |
| |
| // Remove Xcode project after each test method. |
| addTeardownBlock { |
| do { |
| if self.fileManager.fileExists(atPath: xcodeProjectURL.path) { |
| try self.fileManager.removeItem(at: xcodeProjectURL) |
| XCTAssertFalse(self.fileManager.fileExists(atPath: xcodeProjectURL.path)) |
| } |
| } catch { |
| XCTFail("Error while deleting generated Xcode project: \(error)") |
| } |
| } |
| |
| return xcodeProjectURL |
| } |
| |
| /// Returns the "json" style dictionary of a project. |
| /// |
| /// - Parameters: |
| /// - xcodeProjectURL: URL of project. |
| /// - Returns |
| /// - Dictionary of string to json style objects. |
| fileprivate func xcodeProjectDictionary(_ xcodeProjectURL: URL) -> [String: Any]? { |
| let completionInfo = ProcessRunner.launchProcessSync( |
| "/usr/bin/xcodebuild", |
| arguments: [ |
| "-list", |
| "-json", |
| "-project", |
| xcodeProjectURL.path |
| ]) |
| guard let stdoutput = String(data: completionInfo.stdout, encoding: .utf8), |
| !stdoutput.isEmpty |
| else { |
| if let error = String(data: completionInfo.stderr, encoding: .utf8), !error.isEmpty { |
| XCTFail(error) |
| } else { |
| XCTFail("Xcode project tests did not return success.") |
| } |
| return nil |
| } |
| guard |
| let jsonDeserialized = try? JSONSerialization.jsonObject( |
| with: completionInfo.stdout, options: []) |
| else { |
| XCTFail("Unable to decode from json: \(stdoutput)") |
| return nil |
| } |
| guard let jsonResponse = jsonDeserialized as? [String: Any] else { |
| XCTFail("Unable to decode from json as [String: Any]: \(stdoutput)") |
| return nil |
| } |
| guard let project = jsonResponse["project"] as? [String: Any] else { |
| XCTFail("Unable to extract project from \(jsonResponse)") |
| return nil |
| } |
| return project |
| } |
| |
| /// Returns targets of a project. |
| /// |
| /// - Parameters: |
| /// - xcodeProjectURL: URL of project. |
| /// - Returns |
| /// - Array of target names. |
| func targetsOfXcodeProject(_ xcodeProjectURL: URL) -> [String] { |
| guard let project = xcodeProjectDictionary(xcodeProjectURL) else { |
| XCTFail("Unable to extract project for \(xcodeProjectURL)") |
| return [] |
| } |
| guard let targets = project["targets"] as? [String] else { |
| XCTFail("Unable to extract targets from \(project)") |
| return [] |
| } |
| return targets |
| } |
| |
| /// Builds an Xcode Target |
| /// |
| /// - Parameters: |
| /// - xcodeProjectURL: URL of project. |
| /// - target: target name |
| func buildXcodeTarget(_ xcodeProjectURL: URL, target: String, configuration: String = "Release") -> String { |
| let completionInfo = ProcessRunner.launchProcessSync( |
| "/usr/bin/xcodebuild", |
| arguments: [ |
| "build", |
| "-project", |
| xcodeProjectURL.path, |
| "-target", |
| target, |
| // "destination" seems to be ignored when specifying a target, it might only apply |
| // when specifying a scheme. We will not actually be running artifacts from this |
| // build on a specifix device anyway so instead specify values to indicate we are |
| // building for an iOS simulator. |
| "-arch", |
| "x86_64", |
| "-sdk", |
| "iphonesimulator", |
| "-configuration", |
| configuration, |
| "SYMROOT=xcodeBuild" |
| ]) |
| |
| if let stdoutput = String(data: completionInfo.stdout, encoding: .utf8), |
| !stdoutput.isEmpty, |
| let result = stdoutput.split(separator: "\n").last |
| { |
| if (String(result) != "** BUILD SUCCEEDED **") { |
| print(stdoutput) |
| XCTFail( |
| "\(completionInfo.commandlineString) did not return build success. Exit code: \(completionInfo.terminationStatus)" |
| ) |
| } |
| |
| return String(stdoutput) |
| } else if let error = String(data: completionInfo.stderr, encoding: .utf8), !error.isEmpty { |
| XCTFail(error) |
| } else { |
| XCTFail("Xcode project build did not return success \(xcodeProjectURL):\(target).") |
| } |
| |
| return "" |
| } |
| |
| // Runs Xcode tests on the given Xcode project and scheme. This verifies that |
| // the test passes and that rsync behavior is used to copy files. |
| func testXcodeProject(_ xcodeProjectURL: URL, scheme: String) { |
| let destination |
| = "platform=iOS Simulator,name=\(TulsiEndToEndTest.simulatorName),OS=\(TulsiEndToEndTest.targetVersion)" |
| let completionInfo = ProcessRunner.launchProcessSync( |
| "/usr/bin/xcodebuild", |
| arguments: [ |
| "test", |
| "-project", |
| xcodeProjectURL.path, |
| "-scheme", |
| scheme, |
| // Use the device created specifically for this test for hermicity. |
| "-destination", |
| destination |
| ]) |
| |
| if let stdoutput = String(data: completionInfo.stdout, encoding: .utf8), |
| !stdoutput.isEmpty, |
| let result = stdoutput.split(separator: "\n").last |
| { |
| XCTAssert(stdoutput.contains("Rsyncing"), "Failed to find 'Rsyncing' in:\n\(stdoutput)") |
| if (String(result) != "** TEST SUCCEEDED **") { |
| print(stdoutput) |
| XCTFail( |
| "\(completionInfo.commandlineString) did not return test success. Exit code: \(completionInfo.terminationStatus)" |
| ) |
| } |
| } else if let error = String(data: completionInfo.stderr, encoding: .utf8), !error.isEmpty { |
| XCTFail(error) |
| } else { |
| XCTFail("Xcode project tests did not return success \(xcodeProjectURL):\(scheme).") |
| } |
| } |
| |
| // Runs 'simctl' in a subprocess with whatever command (i.e. boot, shutdown, delete, etc) and |
| // target simulator specified. |
| fileprivate func runSimctlCommand(_ command: String, onSimulator target: String) { |
| let completionInfo = ProcessRunner.launchProcessSync( |
| "/usr/bin/xcrun", |
| arguments: [ |
| "simctl", |
| command, |
| target |
| ]) |
| |
| if let error = String(data: completionInfo.stderr, encoding: .utf8), !error.isEmpty { |
| print( |
| """ |
| \(completionInfo.commandlineString) failed with exit code: \(completionInfo.terminationStatus) |
| Error: \(error) |
| """ |
| ) |
| } |
| } |
| } |
| |
| extension FileManager { |
| // Performs a deep copy of the item at sourceURL, resolving any symlinks along the way. |
| func deepCopyItem(at sourceURL: URL, to destURL: URL) throws { |
| do { |
| try self.createDirectory( |
| atPath: destURL.deletingLastPathComponent().path, withIntermediateDirectories: true) |
| let rootPath = sourceURL.path |
| if let rootAttributes = try? self.attributesOfItem(atPath: rootPath) { |
| if rootAttributes[FileAttributeKey.type] as? FileAttributeType == FileAttributeType |
| .typeSymbolicLink |
| { |
| let resolvedRootPath = try self.destinationOfSymbolicLink(atPath: rootPath) |
| try self.copyItem(atPath: resolvedRootPath, toPath: destURL.path) |
| } else { |
| try self.copyItem(at: sourceURL, to: destURL) |
| } |
| } |
| |
| let path = destURL.path |
| if let paths = self.subpaths(atPath: path) { |
| for subpath in paths { |
| let fullSubpath = path + "/" + subpath |
| if let attributes = try? self.attributesOfItem(atPath: fullSubpath) { |
| // If a file is a symbolic link, find the original file, remove the symlink, and copy |
| // over the original file. |
| if attributes[FileAttributeKey.type] as? FileAttributeType == FileAttributeType |
| .typeSymbolicLink |
| { |
| let resolvedPath = try self.destinationOfSymbolicLink(atPath: fullSubpath) |
| try self.removeItem(atPath: fullSubpath) |
| try self.copyItem(atPath: resolvedPath, toPath: fullSubpath) |
| } |
| } |
| } |
| } |
| } |
| } |
| } |