idb_companion/Utility/IDBXCTestReporter/IDBXCTestReporter.swift (364 lines of code) (raw):

/* * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ import Foundation import GRPC import IDBGRPCSwift import FBSimulatorControl import XCTestBootstrap extension IDBXCTestReporter { struct Configuration { let resultBundlePath: String let coverageConfiguration: FBCodeCoverageConfiguration? let logDirectoryPath: String? let binariesPath: [String] let reportAttachments: Bool init(legacy: FBXCTestReporterConfiguration) { self.resultBundlePath = legacy.resultBundlePath ?? "" self.coverageConfiguration = legacy.coverageConfiguration self.logDirectoryPath = legacy.logDirectoryPath self.binariesPath = legacy.binariesPaths ?? [] self.reportAttachments = legacy.reportAttachments } } struct CurrentTestInfo { var bundleName = "" var testClass = "" var testMethod = "" var activityRecords: [FBActivityRecord] = [] var failureInfo: Idb_XctestRunResponse.TestRunInfo.TestRunFailureInfo? } } @objc final class IDBXCTestReporter: NSObject, FBXCTestReporter, FBDataConsumer { let reportingTerminated = FBMutableFuture<NSNumber>() var configuration: Configuration! @Atomic private var responseStream: GRPCAsyncResponseStreamWriter<Idb_XctestRunResponse>? private let queue: DispatchQueue private let logger: FBControlCoreLogger private let processUnderTestExitedMutable = FBMutableFuture<NSNull>() @Atomic private var currentInfo = CurrentTestInfo() init(responseStream: GRPCAsyncResponseStreamWriter<Idb_XctestRunResponse>, queue: DispatchQueue, logger: FBControlCoreLogger) { self.responseStream = responseStream self.queue = queue self.logger = logger } // MARK: - FBDataConsumer implementation @objc func consumeData(_ data: Data) { let logOutput = String(data: data, encoding: .utf8) ?? "" let response = createResponse(logOutput: logOutput) write(response: response) } @objc func consumeEndOfFile() { // Implementation not required } // MARK: - FBXCTestReporter implementation @objc func processWaitingForDebugger(withProcessIdentifier pid: pid_t) { logger.info().log("Tests waiting for debugger. To debug run: lldb -p \(pid)") let response = Idb_XctestRunResponse.with { $0.status = .running $0.debugger = .with { $0.pid = UInt64(pid) } } write(response: response) } @objc func didBeginExecutingTestPlan() { // Implementation not required } @objc func didFinishExecutingTestPlan() { let response = Idb_XctestRunResponse.with { $0.status = .terminatedNormally } write(response: response) } @objc func processUnderTestDidExit() { processUnderTestExitedMutable.resolve(withResult: NSNull()) } @objc func testSuite(_ testSuite: String, didStartAt startTime: String) { currentInfo.bundleName = testSuite } @objc func testCaseDidFinish(forTestClass testClass: String, method: String, with status: FBTestReportStatus, duration: TimeInterval, logs: [String]?) { let info = createRunInfo(testClass: testClass, method: method, status: status, duration: duration, logs: logs ?? []) write(testRunInfo: info) } @objc func testCaseDidFail(forTestClass testClass: String, method: String, withMessage message: String, file: String?, line: UInt) { let currentInfo = self.currentInfo if testClass == currentInfo.testClass && method != currentInfo.testMethod { logger.log("Got failure info for \(testClass)/\(method) but the current known executing test is \(currentInfo.testClass)\(currentInfo.testMethod). Ignoring it") return } self.currentInfo.failureInfo = createFailureInfo(message: message, file: file, line: line) } @objc func testCaseDidStart(forTestClass testClass: String, method: String) { _currentInfo.sync { $0.testClass = testClass $0.testMethod = method } } @objc func testPlanDidFail(withMessage message: String) { let response = responseFor(crashMessage: message) write(response: response) } @objc func testCase(_ testClass: String, method: String, didFinishActivity activity: FBActivityRecord) { currentInfo.activityRecords.append(activity) } @objc func finished(with summary: FBTestManagerResultSummary) { // didFinishExecutingTestPlan should be used to signify completion instead } @objc func testHadOutput(_ output: String) { let response = createResponseExtractingFailureInfo(from: output) write(response: response) } @objc func handleExternalEvent(_ event: String) { let response = createResponseExtractingFailureInfo(from: event) write(response: response) } @objc func printReport() throws { // Warning! This method is bridged to swift incorrectly and loses bool return type. Adapt and use with extra care } @objc func didCrashDuringTest(_ error: Error) { let response = responseFor(crashMessage: error.localizedDescription) write(response: response) } // MARK: - Privates private func createRunInfo(testClass: String, method: String, status: FBTestReportStatus, duration: TimeInterval, logs: [String]) -> Idb_XctestRunResponse.TestRunInfo { defer { resetCurrentTestState() } return _currentInfo.sync { currentInfo in var stackedActivities: [FBActivityRecord] = [] currentInfo.activityRecords.sort(by: { $0.start < $1.start }) while let activity = currentInfo.activityRecords.first { currentInfo.activityRecords.remove(at: 0) populateSubactivities(root: activity, remaining: &currentInfo.activityRecords) stackedActivities.append(activity) } return Idb_XctestRunResponse.TestRunInfo.with { $0.bundleName = currentInfo.bundleName $0.className = testClass $0.methodName = method $0.status = status == .failed ? .failed : .passed $0.duration = duration if let failureInfo = currentInfo.failureInfo { $0.failureInfo = failureInfo } $0.logs = logs $0.activityLogs = stackedActivities.map(translate(activity:)) } } } private func translate(activity: FBActivityRecord) -> Idb_XctestRunResponse.TestRunInfo.TestActivity { let subactivities = activity.subactivities as! [FBActivityRecord] return Idb_XctestRunResponse.TestRunInfo.TestActivity.with { $0.title = activity.title $0.duration = activity.duration $0.uuid = activity.uuid.uuidString $0.activityType = activity.activityType $0.start = activity.start.timeIntervalSince1970 $0.finish = activity.finish.timeIntervalSince1970 $0.name = activity.name if configuration.reportAttachments { $0.attachments = activity.attachments.map { attachment in .with { $0.payload = attachment.payload ?? Data() $0.name = attachment.name $0.timestamp = attachment.timestamp.timeIntervalSince1970 $0.uniformTypeIdentifier = attachment.uniformTypeIdentifier } } } $0.subActivities = subactivities.map(translate(activity:)) } } private func resetCurrentTestState() { _currentInfo.sync { $0.activityRecords.removeAll() $0.failureInfo = nil $0.testClass = "" $0.testMethod = "" } } private func populateSubactivities(root: FBActivityRecord, remaining: inout [FBActivityRecord]) { while let firstRemaining = remaining.first, root.start <= firstRemaining.start && firstRemaining.finish <= root.finish { remaining.remove(at: 0) populateSubactivities(root: firstRemaining, remaining: &remaining) root.subactivities.add(firstRemaining) } } private func write(testRunInfo: Idb_XctestRunResponse.TestRunInfo) { let response = Idb_XctestRunResponse.with { $0.status = .running $0.results = [testRunInfo] } write(response: response) } private func write(response: Idb_XctestRunResponse) { Task { do { switch response.status { case .terminatedNormally, .terminatedAbnormally: try await insertFinalDataThenWriteResponse(response: response) default: try await writeResponseFinal(response: response) } } catch { logger.log("Failed to write xctest run response \(error.localizedDescription)") } } } // TODO: Parallelism of execution was lost after rewriting this method to swift. Make this work in parallel again private func insertFinalDataThenWriteResponse(response: Idb_XctestRunResponse) async throws { var response = response if !configuration.resultBundlePath.isEmpty { do { let resultBundle = try await gzipFolder(at: configuration.resultBundlePath) response.resultBundle = .with { $0.data = resultBundle } } catch { logger.info().log("Failed to create result bundle \(error.localizedDescription)") } } if let coverageConfig = configuration.coverageConfiguration, !coverageConfig.coverageDirectory.isEmpty { do { let coverageData = try await getCoverageResponseData(config: coverageConfig) response.codeCoverageData = .with { $0.data = coverageData } } catch { logger.info().log("Failed to get coverage data: \(error.localizedDescription)") } } if let logDirectoryPath = configuration.logDirectoryPath { let data = try await gzipFolder(at: logDirectoryPath) response.logDirectory = .with { $0.data = data } } try await writeResponseFinal(response: response) } private func writeResponseFinal(response: Idb_XctestRunResponse) async throws { let shouldCloseStream = isLastMessage(responseStatus: response.status) let stream: GRPCAsyncResponseStreamWriter<Idb_XctestRunResponse>? = _responseStream.sync { storedStream in guard let responseStream = storedStream else { return nil } if shouldCloseStream { storedStream = nil } return responseStream } guard let responseStream = stream else { logger.error().log("writeResponse called, but the last response has already been written!") return } try await responseStream.send(response) if shouldCloseStream { logger.log("Test Reporting has finished with status \(response.status)") reportingTerminated.resolve(withResult: .init(value: response.status.rawValue)) } } private func isLastMessage(responseStatus: Idb_XctestRunResponse.Status) -> Bool { return responseStatus == .terminatedNormally || responseStatus == .terminatedAbnormally } private func gzipFolder(at path: String) async throws -> Data { let task = FBArchiveOperations.createGzippedTarData(forPath: path, queue: queue, logger: logger) return try await FutureBox(task).value as Data } private func getCoverageResponseData(config: FBCodeCoverageConfiguration) async throws -> Data { try await FutureBox(processUnderTestExitedMutable).await() switch config.format { case .exported: let data = try await getCoverageDataExported(config: config) let archived = try await FutureBox(FBArchiveOperations.createGzipData(from: data, logger: logger)).value let archivedData = archived.stdOut ?? NSData() return archivedData as Data case .raw: return try await gzipFolder(at: config.coverageDirectory) default: throw FBControlCoreError.describe("Unsupported code coverage format") } } private func getCoverageDataExported(config: FBCodeCoverageConfiguration) async throws -> Data { let coverageDirectory = URL(fileURLWithPath: config.coverageDirectory) let profdataPath = coverageDirectory.appendingPathComponent("coverage.profdata") try await mergeRawCoverage(coverageDirectory: coverageDirectory, profdataPath: profdataPath) return try await exportCoverage(profdataPath: profdataPath, binariesPath: configuration.binariesPath) } private func mergeRawCoverage(coverageDirectory: URL, profdataPath: URL) async throws { let profraws = try FileManager.default .contentsOfDirectory(at: coverageDirectory, includingPropertiesForKeys: nil, options: []) .filter { $0.pathExtension == "profraw" } let mergeArgs: [String] = ["llvm-profdata", "merge", "-o", profdataPath.path] + profraws.map(\.path) let mergeProcessFuture = FBProcessBuilder<NSNull, NSData, NSString> .withLaunchPath("/usr/bin/xcrun", arguments: mergeArgs) .withStdOutInMemoryAsData() .withStdErrInMemoryAsString() .runUntilCompletion(withAcceptableExitCodes: nil) let mergeProcess = try await FutureBox(mergeProcessFuture).value let exitCode = try await FutureBox(mergeProcess.exitCode).value if exitCode != 0 { throw FBControlCoreError.describe("xcrun failed to export code coverage data \(exitCode.intValue) \(mergeProcess.stdErr ?? "")") } } private func exportCoverage(profdataPath: URL, binariesPath: [String]) async throws -> Data { let exportArgs: [String] = ["llvm-cov", "export", "-instr-profile", profdataPath.path] + binariesPath.reduce(into: []) { $0 += ["-object", $1] } let exportProcessFuture = FBProcessBuilder<NSNull, NSData, NSString> .withLaunchPath("/usr/bin/xcrun", arguments: exportArgs) .withStdOutInMemoryAsData() .withStdErrInMemoryAsString() .runUntilCompletion(withAcceptableExitCodes: nil) let exportProcess = try await FutureBox(exportProcessFuture).value let exitCode = try await FutureBox(exportProcess.exitCode).value if exitCode != 0 { throw FBControlCoreError.describe("xcrun failed to export code coverage data \(exitCode.intValue) \(exportProcess.stdErr ?? "")") } let stdOut = exportProcess.stdOut ?? NSData() return stdOut as Data } private func createFailureInfo(message: String, file: String?, line: UInt) -> Idb_XctestRunResponse.TestRunInfo.TestRunFailureInfo { return Idb_XctestRunResponse.TestRunInfo.TestRunFailureInfo.with { $0.failureMessage = message $0.file = file ?? "" $0.line = UInt64(line) } } private func responseFor(crashMessage: String) -> Idb_XctestRunResponse { defer { resetCurrentTestState() } let currentInfo = self.currentInfo let info = Idb_XctestRunResponse.TestRunInfo.with { $0.bundleName = currentInfo.bundleName $0.className = currentInfo.testClass $0.methodName = currentInfo.testMethod $0.failureInfo = currentInfo.failureInfo ?? .init() $0.failureInfo.failureMessage = crashMessage $0.status = .crashed } return Idb_XctestRunResponse.with { $0.status = .terminatedAbnormally $0.results = [info] } } private func createResponseExtractingFailureInfo(from logOutput: String) -> Idb_XctestRunResponse { extractFailureInfo(from: logOutput) return createResponse(logOutput: logOutput) } private func extractFailureInfo(from logOutput: String) { do { let regexp = try NSRegularExpression(pattern: "Assertion failed: (.*), function (.*), file (.*), line (\\d+).", options: .caseInsensitive) let log = logOutput as NSString if let result = regexp.firstMatch(in: logOutput, options: [], range: .init(location: 0, length: log.length)) { currentInfo.failureInfo = failureInfoWith(message: log.substring(with: result.range(at: 1)), file: log.substring(with: result.range(at: 3)), line: UInt(log.substring(with: result.range(at: 4))) ?? 0) } } catch { assertionFailure(error.localizedDescription) logger.error().log("Incorrect regexp \(error.localizedDescription)") } } private func createResponse(logOutput: String) -> Idb_XctestRunResponse { return Idb_XctestRunResponse.with { $0.status = .running $0.logOutput = [logOutput] } } private func failureInfoWith(message: String, file: String, line: UInt) -> Idb_XctestRunResponse.TestRunInfo.TestRunFailureInfo { return .with { $0.failureMessage = message $0.file = file $0.line = UInt64(line) } } }