From 1af7bdaf2377b0121fb5c6eed20f0735590bb5fd Mon Sep 17 00:00:00 2001 From: gnalh Date: Mon, 30 Sep 2024 13:08:05 -0700 Subject: [PATCH] init --- Cargo.lock | 25 ++- cli/Cargo.toml | 3 + cli/src/lib.rs | 1 + cli/src/main.rs | 37 +++- cli/src/xcresult.rs | 434 +++++++++++++++++++++++++++++++++++++++ cli/tests/.gitattributes | 1 + cli/tests/.gitignore | 1 + cli/tests/data.tar.gz | 3 + cli/tests/xcresult.rs | 78 +++++++ 9 files changed, 574 insertions(+), 9 deletions(-) create mode 100644 cli/src/xcresult.rs create mode 100644 cli/tests/.gitattributes create mode 100644 cli/tests/.gitignore create mode 100644 cli/tests/data.tar.gz create mode 100644 cli/tests/xcresult.rs diff --git a/Cargo.lock b/Cargo.lock index 781527dd..cfb39e68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -476,6 +476,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "ctor" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edb49164822f3ee45b17acd4a208cfc1251410cf0cad9a833234c9890774dd9f" +dependencies = [ + "quote", + "syn 2.0.77", +] + [[package]] name = "debugid" version = "0.8.0" @@ -625,9 +635,9 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.33" +version = "1.0.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "324a1be68054ef05ad64b861cc9eaf1d623d2d8cb25b4bf2cb9cdd902b4bf253" +checksum = "a1b589b4dc103969ad3cf85c950899926ec64300a1a46d76c03a6072957036f0" dependencies = [ "crc32fast", "miniz_oxide", @@ -1902,7 +1912,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f81e1644e1b54f5a68959a29aa86cde704219254669da328ecfdf6a1f09d427" dependencies = [ "ansi_term", - "ctor", + "ctor 0.1.26", "difference", "output_vt100", ] @@ -2818,8 +2828,10 @@ dependencies = [ "chrono", "clap", "codeowners", + "ctor 0.2.8", "env_logger", "exitcode", + "flate2", "git2", "gix", "glob", @@ -2838,6 +2850,7 @@ dependencies = [ "tokio-retry", "uuid", "vergen", + "xml-builder", "zstd", ] @@ -3279,6 +3292,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "xml-builder" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef5f40cd674b9d9814545203f175ac29ffdcb6e006727f4d95797d7badd72e2" + [[package]] name = "zerocopy" version = "0.7.35" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 181b7e37..a08a865e 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -41,6 +41,9 @@ codeowners = { path = "../codeowners" } sentry = { version = "0.34.0", features = ["debug-images"] } openssl = { version = "0.10.66", features = ["vendored"] } uuid = { version = "1.10.0", features = ["v5"] } +xml-builder = "0.5.3" +flate2 = "1.0.34" +ctor = "0.2.8" [dev-dependencies] assert_cmd = "2.0.16" diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 56dec4cc..009dba57 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -6,3 +6,4 @@ pub mod runner; pub mod scanner; pub mod types; pub mod utils; +pub mod xcresult; diff --git a/cli/src/main.rs b/cli/src/main.rs index 190fd465..5fa785f0 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,6 +1,7 @@ use std::env; -use std::io::Write; +use std::io::{Seek, Write}; use std::time::{SystemTime, UNIX_EPOCH}; +use trunk_analytics_cli::xcresult::XCResultFile; use clap::{Args, Parser, Subcommand}; use tokio_retry::strategy::ExponentialBackoff; @@ -37,11 +38,13 @@ struct Cli { struct UploadArgs { #[arg( long, - required = true, + required = false, value_delimiter = ',', help = "Comma-separated list of glob paths to junit files." )] junit_paths: Vec, + #[arg(long, required = false, help = "Path of xcresult directory")] + xcresult_path: Option, #[arg(long, help = "Organization url slug.")] org_url_slug: String, #[arg( @@ -143,6 +146,7 @@ async fn run_upload( ) -> anyhow::Result { let UploadArgs { junit_paths, + xcresult_path, org_url_slug, token, repo_root, @@ -167,8 +171,10 @@ async fn run_upload( repo_head_commit_epoch, )?; - if junit_paths.is_empty() { - return Err(anyhow::anyhow!("No junit paths provided.")); + if junit_paths.is_empty() && xcresult_path.is_none() { + return Err(anyhow::anyhow!( + "Neither junit nor xcresult paths were provided." + )); } let api_address = from_non_empty_or_default( @@ -192,9 +198,28 @@ async fn run_upload( } let tags = parse_custom_tags(&tags)?; + let mut temp_paths = Vec::new(); + // NOTE: This temp directory must be in a higher scope, otherwise it will be dropped before we can use it. + let junit_temp_dir = tempfile::tempdir()?; + if xcresult_path.is_some() && cfg!(target_os = "macos") { + let xcresult = XCResultFile::new(xcresult_path.unwrap()); + let junit = xcresult?.junit(); + let junit_temp_path = junit_temp_dir.path().join("xcresult_junit.xml"); + let mut junit_temp = std::fs::File::create(&junit_temp_path)?; + junit_temp.write_all(&junit)?; + junit_temp.seek(std::io::SeekFrom::Start(0))?; + temp_paths.push(junit_temp_path.to_str().unwrap().to_string()); + } else if xcresult_path.is_some() && !cfg!(target_os = "macos") { + log::warn!("xcresult was specified but it is only supported on macOS. Ignoring xcresult."); + } - let (file_sets, file_counter) = - build_filesets(&repo, &junit_paths, team.clone(), &codeowners, exec_start)?; + let (file_sets, file_counter) = build_filesets( + &repo, + &[junit_paths.as_slice(), temp_paths.as_slice()].concat(), + team.clone(), + &codeowners, + exec_start, + )?; if !allow_missing_junit_files && (file_counter.get_count() == 0 || file_sets.is_empty()) { return Err(anyhow::anyhow!("No JUnit files found to upload.")); diff --git a/cli/src/xcresult.rs b/cli/src/xcresult.rs new file mode 100644 index 00000000..348dec8c --- /dev/null +++ b/cli/src/xcresult.rs @@ -0,0 +1,434 @@ +use serde_json::Value; +use std::str; +use std::{fs, process::Command}; +use xml_builder::{XMLBuilder, XMLElement, XMLVersion}; + +#[derive(Debug, Clone)] +pub struct XCResultFile { + pub path: String, + results_obj: serde_json::Value, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +enum Status { + Success, + Failure, + Error, + Skipped, +} + +const LEGACY_FLAG_MIN_VERSION: i32 = 70; + +fn xcrun(args: Vec<&str>) -> Result { + if !cfg!(target_os = "macos") { + return Err(anyhow::anyhow!("xcrun is only available on macOS")); + } + let mut cmd = Command::new("xcrun"); + let bin = cmd.args(args); + let output = match bin.output() { + Ok(val) => val, + Err(_) => { + return Err(anyhow::anyhow!( + "failed to run xcrun -- please make sure you have xcode installed" + )) + } + }; + let results_obj_raw = match String::from_utf8(output.stdout) { + Ok(val) => val, + Err(_) => return Err(anyhow::anyhow!("got non UTF-8 data from xcrun output")), + }; + Ok(results_obj_raw) +} + +fn xcrun_version() -> Result { + let version_raw = xcrun(vec!["--version"])?; + // regex to match version where the output looks like xcrun version 70. + let re = regex::Regex::new(r"xcrun version (\d+)").unwrap(); + Ok(match re.captures(&version_raw.to_string()) { + Some(val) => val.get(1).unwrap().as_str().parse::().unwrap_or(0), + None => return Err(anyhow::anyhow!("failed to parse xcrun version")), + }) +} + +fn xcresulttool( + path: &str, + options: Option>, +) -> Result { + let mut base_args = vec!["xcresulttool", "get", "--path", path, "--format", "json"]; + let version = xcrun_version()?; + if version >= LEGACY_FLAG_MIN_VERSION { + base_args.push("--legacy"); + } + if let Some(val) = options { + base_args.extend(val); + } + let output = xcrun(base_args)?; + match serde_json::from_str(&output) { + Ok(val) => Ok(val), + Err(_) => Err(anyhow::anyhow!("failed to parse json from xcrun output")), + } +} + +impl XCResultFile { + pub fn new(path: String) -> Result { + let binding = match fs::canonicalize(path.clone()) { + Ok(val) => val, + Err(_) => return Err(anyhow::anyhow!("failed to get absolute path")), + }; + let absolute_path = binding.to_str().unwrap_or(""); + let results_obj = match xcresulttool(absolute_path, None) { + Ok(val) => val, + Err(e) => return Err(e), + }; + Ok(XCResultFile { + path: absolute_path.to_string(), + results_obj, + }) + } + + fn find_tests(&self, id: &str) -> Result { + xcresulttool(&self.path, Some(vec!["--id", id])) + } + + fn generate_id(&self, raw_id: String) -> String { + return uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, raw_id.as_bytes()).to_string(); + } + + fn junit_testcase( + &self, + action: &serde_json::Value, + testcase: &serde_json::Value, + testcase_group: &serde_json::Value, + ) -> (Status, XMLElement) { + let mut testcase_xml = XMLElement::new("testcase"); + testcase_xml.add_attribute( + "name", + testcase + .get("name") + .and_then(|r| r.get("_value")) + .unwrap() + .as_str() + .unwrap(), + ); + testcase_xml.add_attribute( + "id", + self.generate_id( + testcase + .get("identifierURL") + .and_then(|r| r.get("_value")) + .unwrap() + .to_string(), + ) + .as_str(), + ); + + testcase_xml.add_attribute( + "classname", + testcase_group + .get("name") + .and_then(|r| r.get("_value")) + .unwrap() + .as_str() + .unwrap(), + ); + let duration = testcase + .get("duration") + .and_then(|r| r.get("_value")) + .unwrap() + .as_str() + .unwrap(); + testcase_xml.add_attribute("time", duration); + let status = match testcase + .get("testStatus") + .and_then(|r| r.get("_value")) + .unwrap() + .as_str() + .unwrap() + { + "Error" => Status::Error, + "Failure" => Status::Failure, + "Skipped" => Status::Skipped, + _ => Status::Success, + }; + if status == Status::Skipped { + let skipped_xml = XMLElement::new("skipped"); + match testcase_xml.add_child(skipped_xml) { + Ok(_) => {} + Err(e) => { + log::debug!("failed to add failure to testcase: {:?}", e); + } + } + } + if status == Status::Failure { + let mut failures = action + .get("actionResult") + .and_then(|r| r.get("issues")) + .and_then(|r| r.get("testFailureSummaries")) + .and_then(|r| r.get("_values")) + .unwrap() + .as_array() + .unwrap() + .iter(); + let failure = failures.find(|f| { + f.get("testCaseName") + .and_then(|r| r.get("_value")) + .unwrap() + .as_str() + .unwrap() + .replace('.', "/") + == testcase + .get("identifier") + .and_then(|r| r.get("_value")) + .unwrap() + .as_str() + .unwrap() + }); + let mut failure_xml = XMLElement::new("failure"); + failure_xml + .add_text(String::from( + failure + .unwrap() + .get("message") + .and_then(|r| r.get("_value")) + .unwrap() + .as_str() + .unwrap(), + )) + .unwrap(); + let raw_uri = failure + .unwrap() + .get("documentLocationInCreatingWorkspace") + .and_then(|r| r.get("url")) + .unwrap() + .get("_value") + .unwrap() + .as_str() + .unwrap() + .replace("file://", ""); + let uri = raw_uri.split('#').collect::>()[0]; + + testcase_xml.add_attribute("file", uri); + match testcase_xml.add_child(failure_xml) { + Ok(_) => {} + Err(e) => { + log::debug!("failed to add failure to testcase: {:?}", e); + } + } + } + (status, testcase_xml) + } + + fn junit_testsuite( + &self, + action: &serde_json::Value, + testsuite: &serde_json::Value, + ) -> XMLElement { + let mut testsuite_xml = XMLElement::new("testsuite"); + testsuite_xml.add_attribute( + "id", + self.generate_id( + testsuite + .get("identifierURL") + .and_then(|r| r.get("_value")) + .unwrap() + .to_string(), + ) + .as_str(), + ); + testsuite_xml.add_attribute( + "name", + testsuite + .get("name") + .and_then(|r| r.get("_value")) + .unwrap() + .as_str() + .unwrap(), + ); + let duration = testsuite + .get("duration") + .and_then(|r| r.get("_value")) + .unwrap() + .as_str() + .unwrap(); + testsuite_xml.add_attribute("time", duration); + let testcase_groups = testsuite + .get("subtests") + .and_then(|t| t.get("_values")) + .unwrap() + .as_array() + .unwrap(); + let mut failure_count = 0; + let mut total_count = 0; + let mut error_count = 0; + let mut skipped_count = 0; + for testcase_group in testcase_groups { + let testcases = testcase_group + .get("subtests") + .and_then(|t| t.get("_values")) + .unwrap() + .as_array() + .unwrap(); + for testcase in testcases { + total_count += 1; + let (status, testcase_xml) = self.junit_testcase(action, testcase, testcase_group); + match status { + Status::Skipped => { + skipped_count += 1; + } + Status::Failure => { + failure_count += 1; + } + Status::Error => { + error_count += 1; + } + _ => {} + } + match testsuite_xml.add_child(testcase_xml) { + Ok(_) => {} + Err(e) => { + log::debug!("failed to add testcase to testsuite: {:?}", e); + } + } + } + } + testsuite_xml.add_attribute("failures", failure_count.to_string().as_str()); + testsuite_xml.add_attribute("skipped", skipped_count.to_string().as_str()); + testsuite_xml.add_attribute("errors", error_count.to_string().as_str()); + testsuite_xml.add_attribute("tests", total_count.to_string().as_str()); + testsuite_xml + } + + fn junit_testsuites(&self, action: &serde_json::Value) -> XMLElement { + let mut testsuites_xml = XMLElement::new("testsuites"); + const TIME_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.f%z"; + let ended_time = chrono::DateTime::parse_from_str( + action + .get("endedTime") + .and_then(|r| r.get("_value")) + .unwrap() + .as_str() + .unwrap(), + TIME_FORMAT, + ) + .unwrap(); + let started_time = chrono::DateTime::parse_from_str( + action + .get("startedTime") + .and_then(|r| r.get("_value")) + .unwrap() + .as_str() + .unwrap(), + TIME_FORMAT, + ) + .unwrap(); + let duration = + (ended_time.timestamp_millis() - started_time.timestamp_millis()) as f32 / 1000.0; + testsuites_xml.add_attribute("time", duration.to_string().as_str()); + let tests = match action + .get("actionResult") + .and_then(|r| r.get("metrics")) + .and_then(|r| r.get("testsCount")) + .and_then(|r| r.get("_value")) + { + Some(val) => val.as_str().unwrap(), + None => return testsuites_xml, + }; + testsuites_xml.add_attribute("tests", tests); + testsuites_xml.add_attribute( + "failures", + action + .get("actionResult") + .and_then(|r| r.get("metrics")) + .and_then(|r| r.get("testsFailedCount")) + .and_then(|r| r.get("_value")) + .unwrap_or(&Value::from("0")) + .as_str() + .unwrap(), + ); + testsuites_xml.add_attribute( + "skipped", + action + .get("actionResult") + .and_then(|r| r.get("metrics")) + .and_then(|r| r.get("testsSkippedCount")) + .and_then(|r| r.get("_value")) + .unwrap_or(&Value::from("0")) + .as_str() + .unwrap(), + ); + let empty_val = Value::from(""); + let found_tests = self + .find_tests( + action + .get("actionResult") + .and_then(|r| r.get("testsRef")) + .and_then(|r| r.get("id")) + .and_then(|r| r.get("_value")) + .unwrap_or(&empty_val) + .as_str() + .unwrap(), + ) + .unwrap(); + let test_summaries = match found_tests.get("summaries").and_then(|r| r.get("_values")) { + Some(val) => val.as_array().unwrap(), + None => return testsuites_xml, + }; + for test_summary in test_summaries { + let testable_summaries = test_summary + .get("testableSummaries") + .and_then(|t| t.get("_values")) + .unwrap() + .as_array() + .unwrap(); + for testable_summary in testable_summaries { + let top_level_tests = testable_summary + .get("tests") + .and_then(|t| t.get("_values")) + .unwrap() + .as_array() + .unwrap(); + for top_level_test in top_level_tests { + let testsuites = top_level_test + .get("subtests") + .and_then(|t| t.get("_values")) + .unwrap() + .as_array() + .unwrap(); + for testsuite in testsuites { + let testsuite_xml = self.junit_testsuite(action, testsuite); + match testsuites_xml.add_child(testsuite_xml) { + Ok(_) => {} + Err(e) => { + log::debug!("failed to add testsuite to testsuites: {:?}", e); + } + } + } + } + } + } + testsuites_xml + } + + pub fn junit(&self) -> Vec { + let mut xml = XMLBuilder::new() + .version(XMLVersion::XML1_0) + .encoding("UTF-8".into()) + .build(); + + if let Some(actions) = self + .results_obj + .get("actions") + .and_then(|a| a.get("_values")) + { + for action in actions.as_array().unwrap() { + let testsuites_xml = self.junit_testsuites(action); + xml.set_root_element(testsuites_xml); + } + } + + let mut writer: Vec = Vec::new(); + xml.generate(&mut writer).unwrap(); + log::info!("junit xml: {}", str::from_utf8(&writer).unwrap()); + writer + } +} diff --git a/cli/tests/.gitattributes b/cli/tests/.gitattributes new file mode 100644 index 00000000..f6bd2897 --- /dev/null +++ b/cli/tests/.gitattributes @@ -0,0 +1 @@ +data.tar.gz filter=lfs diff=lfs merge=lfs -text diff --git a/cli/tests/.gitignore b/cli/tests/.gitignore new file mode 100644 index 00000000..1269488f --- /dev/null +++ b/cli/tests/.gitignore @@ -0,0 +1 @@ +data diff --git a/cli/tests/data.tar.gz b/cli/tests/data.tar.gz new file mode 100644 index 00000000..8779879a --- /dev/null +++ b/cli/tests/data.tar.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1373b78caad82efa77e68b61ce2244b530d070232abeae7526b20a9006c276dd +size 57798784 diff --git a/cli/tests/xcresult.rs b/cli/tests/xcresult.rs new file mode 100644 index 00000000..61431469 --- /dev/null +++ b/cli/tests/xcresult.rs @@ -0,0 +1,78 @@ +use ctor::ctor; +use flate2::read::GzDecoder; +use std::fs::File; +use tar::Archive; +use trunk_analytics_cli::xcresult::XCResultFile; + +#[cfg(test)] +#[ctor] +fn init() { + let path = "tests/data.tar.gz"; + let file = File::open(path).unwrap(); + let decoder = GzDecoder::new(file); + let mut archive = Archive::new(decoder); + let _ = archive.unpack("tests"); +} + +#[cfg(target_os = "macos")] +#[test] +fn test_xcresult_with_valid_path() { + let path_str = "tests/data/test1.xcresult"; + let xcresult = XCResultFile::new(path_str.to_string()); + assert!(xcresult.is_ok()); + + let junit = xcresult.unwrap().junit(); + let expected_path = "tests/data/test1.junit"; + // open the expected file + let expected = std::fs::read_to_string(expected_path).unwrap(); + assert_eq!(String::from_utf8(junit).unwrap(), expected); +} + +#[cfg(target_os = "macos")] +#[test] +fn test_xcresult_with_invalid_path() { + let path_str = "tests/data/test2.xcresult"; + let xcresult = XCResultFile::new(path_str.to_string()); + assert!(xcresult.is_err()); + assert_eq!( + xcresult.err().unwrap().to_string(), + "failed to get absolute path" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn test_xcresult_with_invalid_xcresult() { + let path_str = "tests/data/test3.xcresult"; + let xcresult = XCResultFile::new(path_str.to_string()); + assert!(xcresult.is_err()); + assert_eq!( + xcresult.err().unwrap().to_string(), + "failed to parse json from xcrun output" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn test_complex_xcresult_with_valid_path() { + let path_str = "tests/data/test4.xcresult"; + let xcresult = XCResultFile::new(path_str.to_string()); + assert!(xcresult.is_ok()); + + let junit = xcresult.unwrap().junit(); + let expected_path = "tests/data/test4.junit"; + // open the expected file + let expected = std::fs::read_to_string(expected_path).unwrap(); + assert_eq!(String::from_utf8(junit).unwrap(), expected); +} + +#[cfg(target_os = "linux")] +#[test] +fn test_xcresult_with_valid_path_invalid_os() { + let path_str = "tests/data/test1.xcresult"; + let xcresult = XCResultFile::new(path_str.to_string()); + assert_eq!( + xcresult.err().unwrap().to_string(), + "xcrun is only available on macOS" + ); +}