diff --git a/bundle/src/bundler.rs b/bundle/src/bundler.rs index 836abd05..dc9cb96e 100644 --- a/bundle/src/bundler.rs +++ b/bundle/src/bundler.rs @@ -1,5 +1,4 @@ use std::{ - collections::HashMap, fs::File, io::{Seek, Write}, path::PathBuf, diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 60e958d3..d38c571f 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -28,6 +28,7 @@ exitcode = "1.1.1" gix = { version = "0.74.0", default-features = false, features = [ ], optional = true } http = "1.1.0" +prost-wkt-types = { version = "0.5.1", features = ["vendored-protox"] } tokio = { version = "*", default-features = false, features = [ "rt-multi-thread", "macros", diff --git a/cli/src/context.rs b/cli/src/context.rs index 55c8adde..7238be3e 100644 --- a/cli/src/context.rs +++ b/cli/src/context.rs @@ -33,10 +33,10 @@ use context::{ }; use github_actions::extract_github_external_id; use lazy_static::lazy_static; -use prost::Message; +use proto::test_context::test_run::test_case_run::TestRunnerInformation; use proto::test_context::test_run::{ - BazelAttemptNumber, BazelBuildInformation, TestBuildResult, TestReport, TestResult, - UploaderMetadata, + BazelAttemptNumber, BazelBuildInformation, BazelRunInformation, TestBuildResult, TestReport, + TestResult, UploaderMetadata, }; use regex::Regex; use tempfile::TempDir; @@ -407,6 +407,26 @@ pub fn generate_internal_file_from_bep( quarantined_test_ids, variant.as_deref().unwrap_or(""), ); + xml_test_case_runs.iter_mut().for_each(|test_case_run| { + test_case_run.test_runner_information = Some( + TestRunnerInformation::BazelRunInformation(BazelRunInformation { + label: label.clone(), + attempt_number: xml_file.attempt, + started_at: xml_file.start_time.map(|time| { + prost_wkt_types::Timestamp { + seconds: time.timestamp(), + nanos: time.timestamp_subsec_nanos() as i32, + } + }), + finished_at: xml_file.end_time.map(|time| { + prost_wkt_types::Timestamp { + seconds: time.timestamp(), + nanos: time.timestamp_subsec_nanos() as i32, + } + }), + }), + ); + }); test_case_runs.extend(xml_test_case_runs); } } diff --git a/context-js/tests/parse_and_validate.test.ts b/context-js/tests/parse_and_validate.test.ts index 2e5d810f..2e9ad1c5 100644 --- a/context-js/tests/parse_and_validate.test.ts +++ b/context-js/tests/parse_and_validate.test.ts @@ -442,13 +442,12 @@ describe("context-js", () => { expect(testSuite).toBeDefined(); expect(testSuite?.test_cases).toHaveLength(1); - expect(testSuite?.test_cases.at(0).status.status).toBe( - BindingsTestCaseStatusStatus.Success, - ); - expect( - testSuite?.test_cases.at(0)?.js_extra().attempt_number, - ).toBeUndefined(); - expect(testSuite?.test_cases.at(0)?.js_extra().line).toBe("9"); + const testCase = testSuite?.test_cases.at(0); + + expect(testCase?.status.status).toBe(BindingsTestCaseStatusStatus.Success); + expect(testCase?.js_extra().attempt_number).toBeUndefined(); + expect(testCase?.js_extra().line).toBe("9"); + expect(testCase?.bazel_run_information).toBeUndefined(); }); it("parses test_internal_bep_v2.bin", () => { @@ -474,12 +473,57 @@ describe("context-js", () => { expect(testSuite).toBeDefined(); expect(testSuite?.test_cases).toHaveLength(1); - expect(testSuite?.test_cases.at(0).status.status).toBe( - BindingsTestCaseStatusStatus.Success, + + const testCase = testSuite?.test_cases.at(0); + + expect(testCase?.status.status).toBe(BindingsTestCaseStatusStatus.Success); + expect(testCase?.js_extra().attempt_number).toBeUndefined(); + expect(testCase?.js_extra().line).toBe("9"); + expect(testCase?.bazel_run_information).toBeUndefined(); + }); + + it("parses test_internal_bep_v3.bin", () => { + expect.hasAssertions(); + + const file_path = path.resolve(__dirname, "./test_internal_bep_v3.bin"); + const file = fs.readFileSync(file_path); + const bindingsReports = bin_parse(file); + + expect(bindingsReports).toHaveLength(1); + + const result = bindingsReports.at(0); + + expect(result?.bazel_build_information?.label).toBe( + "//trunk/hello_world/cc:hello_test", ); + // Newer format of BEP file has attempt number in bazel build information + expect(result?.bazel_build_information?.max_attempt_number).toBe(0); + expect(result?.tests).toBe(1); + expect(result?.test_suites).toHaveLength(1); + + const testSuite = result?.test_suites.at(0); + + expect(testSuite).toBeDefined(); + expect(testSuite?.test_cases).toHaveLength(1); + + const testCase = testSuite?.test_cases.at(0); + + expect(testCase?.status.status).toBe(BindingsTestCaseStatusStatus.Success); + expect(testCase?.js_extra().attempt_number).toBeUndefined(); + expect(testCase?.js_extra().line).toBe("9"); + expect(testCase?.bazel_run_information).toBeDefined(); + expect(testCase?.bazel_run_information?.label).toBe( + "//trunk/hello_world/cc:hello_test", + ); + expect( + new Date( + Number(testCase?.bazel_run_information?.started_at) * 1000, + ).toISOString(), + ).toBe("2026-01-22T06:07:16.000Z"); expect( - testSuite?.test_cases.at(0)?.js_extra().attempt_number, - ).toBeUndefined(); - expect(testSuite?.test_cases.at(0)?.js_extra().line).toBe("9"); + new Date( + Number(testCase?.bazel_run_information?.finished_at) * 1000, + ).toISOString(), + ).toBe("2026-01-22T06:07:16.000Z"); }); }); diff --git a/context-js/tests/test_internal_bep_v3.bin b/context-js/tests/test_internal_bep_v3.bin new file mode 100644 index 00000000..47bc0612 Binary files /dev/null and b/context-js/tests/test_internal_bep_v3.bin differ diff --git a/context/src/bazel_bep/common.rs b/context/src/bazel_bep/common.rs index f52126aa..0c9c3135 100644 --- a/context/src/bazel_bep/common.rs +++ b/context/src/bazel_bep/common.rs @@ -66,6 +66,9 @@ pub enum BepTestStatus { pub struct BepXMLFile { pub file: String, pub attempt: i32, + pub label: String, + pub start_time: Option>, + pub end_time: Option>, } #[derive(Debug, Clone, Default)] @@ -128,6 +131,19 @@ impl BepParseResult { acc.bep_test_events.push(build_event); } (Some(Payload::TestResult(test_result)), Some(Id::TestResult(id))) => { + let start_time = + test_result.test_attempt_start.clone().and_then(|ts| { + DateTime::from_timestamp(ts.seconds, ts.nanos as u32) + }); + let end_time = start_time.and_then(|start| { + test_result.test_attempt_duration.clone().map(|duration| { + let duration_secs = duration.seconds; + let duration_nanos = duration.nanos as i64; + start + + chrono::Duration::seconds(duration_secs) + + chrono::Duration::nanoseconds(duration_nanos) + }) + }); let xml_files = test_result .test_action_output .iter() @@ -142,6 +158,9 @@ impl BepParseResult { .to_string(), // bazel attempt number is 1-indexed, our representation is 0-indexed attempt: id.attempt - 1, + label: id.label.clone(), + start_time, + end_time, }) } else { None diff --git a/context/src/bazel_bep/parser.rs b/context/src/bazel_bep/parser.rs index d43865fb..6520c264 100644 --- a/context/src/bazel_bep/parser.rs +++ b/context/src/bazel_bep/parser.rs @@ -118,11 +118,45 @@ mod tests { &BepTestStatus::Passed ); - // Test that attempt numbers are captured correctly - let attempt_numbers: Vec = test_result.xml_files.iter().map(|f| f.attempt).collect(); - assert_eq!(attempt_numbers.len(), 2, "Should have 2 attempt numbers"); - assert!(attempt_numbers.contains(&0), "Should have attempt 0"); - assert!(attempt_numbers.contains(&1), "Should have attempt 1"); + // Test that xml files include correct testResult information + let xml_files = &test_result.xml_files; + assert_eq!(xml_files.len(), 2); + assert_eq!(xml_files[0].label, "//trunk/hello_world/cc:hello_test"); + assert_eq!(xml_files[0].attempt, 0); + assert_eq!( + xml_files[0].start_time, + Some( + DateTime::parse_from_rfc3339("2024-12-10T06:17:23.963Z") + .unwrap() + .into() + ) + ); + assert_eq!( + xml_files[0].end_time, + Some( + DateTime::parse_from_rfc3339("2024-12-10T06:17:24.006Z") + .unwrap() + .into() + ) + ); + assert_eq!(xml_files[1].label, "//trunk/hello_world/cc:hello_test"); + assert_eq!(xml_files[1].attempt, 1); + assert_eq!( + xml_files[1].start_time, + Some( + DateTime::parse_from_rfc3339("2024-12-10T06:17:24.011Z") + .unwrap() + .into() + ) + ); + assert_eq!( + xml_files[1].end_time, + Some( + DateTime::parse_from_rfc3339("2024-12-10T06:17:24.055Z") + .unwrap() + .into() + ) + ); assert_eq!( parse_result.errors.len(), @@ -259,19 +293,63 @@ mod tests { .iter() .find(|r| r.label == "//trunk/hello_world/cc:hello_test") .expect("Should find hello_test result"); - let attempt_numbers: Vec = hello_test_result - .xml_files - .iter() - .map(|f| f.attempt) - .collect(); + + let xml_files = &hello_test_result.xml_files; + assert_eq!(xml_files.len(), 3); + assert_eq!(xml_files[0].label, "//trunk/hello_world/cc:hello_test"); + assert_eq!(xml_files[0].attempt, 0); assert_eq!( - attempt_numbers.len(), - 3, - "Should have 3 attempt numbers for flaky test" + xml_files[0].start_time, + Some( + DateTime::parse_from_rfc3339("2024-12-17T04:10:55.311Z") + .unwrap() + .into() + ) + ); + assert_eq!( + xml_files[0].end_time, + Some( + DateTime::parse_from_rfc3339("2024-12-17T04:10:55.357Z") + .unwrap() + .into() + ) + ); + assert_eq!(xml_files[1].label, "//trunk/hello_world/cc:hello_test"); + assert_eq!(xml_files[1].attempt, 1); + assert_eq!( + xml_files[1].start_time, + Some( + DateTime::parse_from_rfc3339("2024-12-17T04:10:55.377Z") + .unwrap() + .into() + ) + ); + assert_eq!( + xml_files[1].end_time, + Some( + DateTime::parse_from_rfc3339("2024-12-17T04:10:55.421Z") + .unwrap() + .into() + ) + ); + assert_eq!(xml_files[2].label, "//trunk/hello_world/cc:hello_test"); + assert_eq!(xml_files[2].attempt, 2); + assert_eq!( + xml_files[2].start_time, + Some( + DateTime::parse_from_rfc3339("2024-12-17T04:10:55.425Z") + .unwrap() + .into() + ) + ); + assert_eq!( + xml_files[2].end_time, + Some( + DateTime::parse_from_rfc3339("2024-12-17T04:10:55.466Z") + .unwrap() + .into() + ) ); - assert!(attempt_numbers.contains(&0), "Should have attempt 0"); - assert!(attempt_numbers.contains(&1), "Should have attempt 1"); - assert!(attempt_numbers.contains(&2), "Should have attempt 2"); } #[test] diff --git a/context/src/junit/bindings.rs b/context/src/junit/bindings.rs deleted file mode 100644 index 6b8c30f7..00000000 --- a/context/src/junit/bindings.rs +++ /dev/null @@ -1,1584 +0,0 @@ -use std::{collections::HashMap, time::Duration}; - -use chrono::{DateTime, TimeDelta}; -use proto::test_context::test_run::{TestBuildResult, TestCaseRun, TestCaseRunStatus, TestResult}; -#[cfg(feature = "pyo3")] -use pyo3::prelude::*; -#[cfg(feature = "pyo3")] -use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pyclass_enum, gen_stub_pymethods}; -use quick_junit::{ - NonSuccessKind, Property, Report, TestCase, TestCaseStatus, TestRerun, TestSuite, -}; -#[cfg(feature = "wasm")] -use wasm_bindgen::prelude::*; - -use super::{ - parser::JunitParseFlatIssue, - validator::{ - JunitReportValidationFlatIssue, JunitTestSuiteValidation, JunitValidationLevel, - JunitValidationType, - }, -}; -use crate::junit::validator::JunitReportValidation; -use crate::junit::{parser::extra_attrs, validator::TestRunnerReportValidation}; - -#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] -#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] -#[derive(Clone, Debug)] -pub struct BindingsParseResult { - pub report: Option, - pub issues: Vec, -} - -#[cfg_attr(feature = "pyo3", gen_stub_pyclass_enum, pyclass(eq, eq_int))] -#[cfg_attr(feature = "wasm", wasm_bindgen)] -#[derive(Clone, Debug, Copy, PartialEq, Eq)] -pub enum BindingsTestBuildResult { - Unspecified, - Success, - Failure, - Skipped, - Flaky, -} - -// Ideally this would be an enum, but enums are not directly supportted by wasm conversions, so we have to manually map out the options. See: https://github.com/rustwasm/wasm-bindgen/issues/2407 -#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] -#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] -#[derive(Clone, Debug)] -pub struct BazelBuildInformation { - pub label: String, - pub result: BindingsTestBuildResult, - pub max_attempt_number: Option, -} - -#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] -#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] -#[derive(Clone, Debug)] -pub struct BindingsReport { - pub name: String, - pub uuid: Option, - pub timestamp: Option, - pub timestamp_micros: Option, - pub time: Option, - pub tests: usize, - pub failures: usize, - pub errors: usize, - pub test_suites: Vec, - pub variant: Option, - pub bazel_build_information: Option, -} - -pub fn map_i32_to_bindings_test_build_result( - result: i32, -) -> anyhow::Result { - if result == TestBuildResult::Unspecified as i32 { - Ok(BindingsTestBuildResult::Unspecified) - } else if result == TestBuildResult::Success as i32 { - Ok(BindingsTestBuildResult::Success) - } else if result == TestBuildResult::Failure as i32 { - Ok(BindingsTestBuildResult::Failure) - } else if result == TestBuildResult::Skipped as i32 { - Ok(BindingsTestBuildResult::Skipped) - } else if result == TestBuildResult::Flaky as i32 { - Ok(BindingsTestBuildResult::Flaky) - } else { - Err(anyhow::anyhow!("Unknown TestBuildResult: {}", result)) - } -} - -impl From for BindingsTestCaseStatusStatus { - fn from(value: TestCaseRunStatus) -> Self { - match value { - TestCaseRunStatus::Success => BindingsTestCaseStatusStatus::Success, - TestCaseRunStatus::Failure => BindingsTestCaseStatusStatus::NonSuccess, - TestCaseRunStatus::Skipped => BindingsTestCaseStatusStatus::Skipped, - TestCaseRunStatus::Unspecified => BindingsTestCaseStatusStatus::Unspecified, - } - } -} - -impl From for BindingsReport { - fn from( - TestResult { - test_case_runs, - uploader_metadata, - test_build_information, - }: TestResult, - ) -> Self { - let test_cases: Vec = test_case_runs - .into_iter() - .map(BindingsTestCase::from) - .collect(); - let parent_name_map: HashMap> = - test_cases.iter().fold(HashMap::new(), |mut acc, testcase| { - if let Some(parent_name) = testcase.extra.get("parent_name") { - acc.entry(parent_name.clone()) - .or_default() - .push(testcase.to_owned()); - } - acc - }); - let test_suites: Vec = parent_name_map - .into_iter() - .map(|(name, testcases)| { - let tests = testcases.len(); - let disabled = testcases - .iter() - .filter(|tc| tc.status.status == BindingsTestCaseStatusStatus::Skipped) - .count(); - let failures = testcases - .iter() - .filter(|tc| tc.status.status == BindingsTestCaseStatusStatus::NonSuccess) - .count(); - let timestamp = testcases.iter().map(|tc| tc.timestamp.unwrap_or(0)).max(); - let timestamp_micros = testcases - .iter() - .map(|tc| tc.timestamp_micros.unwrap_or(0)) - .max(); - let time = testcases.iter().map(|tc| tc.time.unwrap_or(0.0)).sum(); - BindingsTestSuite { - name, - tests, - disabled, - errors: 0, - failures, - timestamp, - timestamp_micros, - time: Some(time), - test_cases: testcases, - properties: vec![], - system_out: None, - system_err: None, - extra: HashMap::new(), - } - }) - .collect(); - let (report_time, report_failures, report_tests) = - test_suites.iter().fold((0.0, 0, 0), |acc, ts| { - ( - acc.0 + ts.time.unwrap_or(0.0), - acc.1 + ts.failures, - acc.2 + ts.tests, - ) - }); - let (name, timestamp, timestamp_micros, variant) = match uploader_metadata { - Some(t) => { - let upload_time = t.upload_time.clone().unwrap_or_default(); - ( - t.origin, - Some(upload_time.seconds), - Some( - chrono::Duration::nanoseconds(upload_time.nanos as i64) - .num_microseconds() - .unwrap_or_default(), - ), - Some(t.variant), - ) - } - None => ("Unknown".to_string(), None, None, None), - }; - let bazel_build_information = match test_build_information { - Some(proto::test_context::test_run::test_result::TestBuildInformation::BazelBuildInformation( - bazel_build_information, - )) => Some(BazelBuildInformation { - label: bazel_build_information.label, - result: map_i32_to_bindings_test_build_result(bazel_build_information.result) - .unwrap_or(BindingsTestBuildResult::Unspecified), - max_attempt_number: bazel_build_information.max_attempt_number.map(|number| number.number), - }), - _ => None, - }; - BindingsReport { - name, - test_suites, - time: Some(report_time), - uuid: None, - timestamp, - timestamp_micros, - errors: 0, - failures: report_failures, - tests: report_tests, - variant, - bazel_build_information, - } - } -} - -fn non_empty_option(s: Option<&str>) -> Option { - s.filter(|s| !s.is_empty()).map(|s| s.to_string()) -} - -impl From for BindingsTestCase { - fn from( - TestCaseRun { - name, - parent_name, - classname, - started_at, - finished_at, - status, - status_output_message, - id, - file, - line, - attempt_number, - is_quarantined, - codeowners, - attempt_index, - line_number, - test_output, - }: TestCaseRun, - ) -> Self { - let started_at = started_at.unwrap_or_default(); - let timestamp = chrono::DateTime::from(started_at.clone()); - let timestamp_micros = chrono::DateTime::from(started_at).timestamp_micros(); - let time = (chrono::DateTime::from(finished_at.unwrap_or_default()) - timestamp) - .to_std() - .unwrap_or_default(); - let classname = if classname.is_empty() { - None - } else { - Some(classname) - }; - let typed_status = - TestCaseRunStatus::try_from(status).unwrap_or(TestCaseRunStatus::Unspecified); - - let mut extra = HashMap::from([ - ("id".to_string(), id.to_string()), - ("file".to_string(), file), - ("parent_name".to_string(), parent_name), - ("is_quarantined".to_string(), is_quarantined.to_string()), - ]); - - if let Some(line_number) = &line_number { - extra.insert("line".to_string(), line_number.number.to_string()); - } else if line != 0 { - // Handle deprecated field - extra.insert("line".to_string(), line.to_string()); - } - - if let Some(attempt_index) = &attempt_index { - extra.insert( - "attempt_number".to_string(), - attempt_index.number.to_string(), - ); - } else if attempt_number != 0 { - // Handle deprecated field - extra.insert("attempt_number".to_string(), attempt_number.to_string()); - } - - Self { - name, - classname, - codeowners: Some(codeowners.iter().map(|c| c.name.to_owned()).collect()), - assertions: None, - timestamp: Some(timestamp.timestamp()), - timestamp_micros: Some(timestamp_micros), - time: Some(time.as_secs_f64()), - status: BindingsTestCaseStatus { - status: typed_status.into(), - success: { - if typed_status == TestCaseRunStatus::Success { - Some(BindingsTestCaseStatusSuccess { flaky_runs: vec![] }) - } else { - None - } - }, - non_success: { - if typed_status == TestCaseRunStatus::Failure { - Some(BindingsTestCaseStatusNonSuccess { - kind: BindingsNonSuccessKind::Failure, - message: non_empty_option( - test_output - .as_ref() - .map(|fi| fi.message.as_str()) - .or(Some(status_output_message.as_str())), - ), - ty: None, - description: non_empty_option( - test_output.as_ref().map(|fi| fi.text.as_str()), - ), - reruns: vec![], - }) - } else { - None - } - }, - skipped: { - if typed_status == TestCaseRunStatus::Skipped { - Some(BindingsTestCaseStatusSkipped { - message: non_empty_option( - test_output - .as_ref() - .map(|fi| fi.message.as_str()) - .or(Some(status_output_message.as_str())), - ), - ty: None, - description: non_empty_option( - test_output.as_ref().map(|fi| fi.text.as_str()), - ), - }) - } else { - None - } - }, - }, - system_err: non_empty_option(test_output.as_ref().map(|fi| fi.system_err.as_str())), - system_out: non_empty_option(test_output.as_ref().map(|fi| fi.system_out.as_str())), - extra, - properties: vec![], - } - } -} - -impl From for BindingsReport { - fn from( - Report { - name, - uuid, - timestamp, - time, - tests, - failures, - errors, - test_suites, - }: Report, - ) -> Self { - Self { - name: name.into_string(), - uuid: uuid.map(|u| u.to_string()), - timestamp: timestamp.map(|t| t.timestamp()), - timestamp_micros: timestamp.map(|t| t.timestamp_micros()), - time: time.map(|t| t.as_secs_f64()), - tests, - failures, - errors, - test_suites: test_suites - .into_iter() - .map(BindingsTestSuite::from) - .collect(), - variant: None, - bazel_build_information: None, - } - } -} - -impl From for Report { - fn from(val: BindingsReport) -> Self { - let BindingsReport { - name, - uuid, - timestamp: _, - timestamp_micros, - time, - tests, - failures, - errors, - test_suites, - variant: _, - bazel_build_information: _, - } = val; - // NOTE: Cannot make a UUID without a `&'static str` - let _ = uuid; - Report { - name: name.into(), - uuid: None, - timestamp: timestamp_micros - .and_then(|micro_secs| { - let micros_delta = TimeDelta::microseconds(micro_secs); - DateTime::from_timestamp( - micros_delta.num_seconds(), - micros_delta.subsec_nanos() as u32, - ) - }) - .map(|dt| dt.fixed_offset()), - time: time.map(Duration::from_secs_f64), - tests, - failures, - errors, - test_suites: test_suites - .into_iter() - .map(BindingsTestSuite::into) - .collect(), - } - } -} - -#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] -#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] -#[derive(Clone, Debug)] -pub struct BindingsTestSuite { - pub name: String, - pub tests: usize, - pub disabled: usize, - pub errors: usize, - pub failures: usize, - pub timestamp: Option, - pub timestamp_micros: Option, - pub time: Option, - pub test_cases: Vec, - pub properties: Vec, - pub system_out: Option, - pub system_err: Option, - extra: HashMap, -} - -#[cfg(feature = "pyo3")] -#[gen_stub_pymethods] -#[pymethods] -impl BindingsTestSuite { - fn py_extra(&self) -> HashMap { - self.extra.clone() - } -} - -impl BindingsTestSuite { - pub fn extra(&self) -> HashMap { - self.extra.clone() - } -} - -#[cfg(feature = "wasm")] -#[wasm_bindgen] -impl BindingsTestSuite { - pub fn js_extra(&self) -> Result { - let entries = self - .extra - .iter() - .fold(js_sys::Array::new(), |acc, (key, value)| { - let entry = js_sys::Array::new(); - entry.push(&js_sys::JsString::from(key.as_str())); - entry.push(&js_sys::JsString::from(value.as_str())); - acc.push(&entry); - acc - }); - js_sys::Object::from_entries(&entries) - } -} - -impl From for BindingsTestSuite { - fn from( - TestSuite { - name, - tests, - disabled, - errors, - failures, - timestamp, - time, - test_cases, - properties, - system_out, - system_err, - extra, - // NOTE: The above should be all fields, but here may be more added in the future due to - // `#[non_exhaustive]` - .. - }: TestSuite, - ) -> Self { - let file = extra.get(extra_attrs::FILE); - let filepath = extra.get(extra_attrs::FILEPATH); - let test_cases = test_cases - .into_iter() - .map(|mut tc| { - if let Some(file) = file { - tc.extra.insert(extra_attrs::FILE.into(), file.clone()); - } - if let Some(filepath) = filepath { - tc.extra - .insert(extra_attrs::FILEPATH.into(), filepath.clone()); - } - BindingsTestCase::from(tc) - }) - .collect(); - Self { - name: name.into_string(), - tests, - disabled, - errors, - failures, - timestamp: timestamp.map(|t| t.timestamp()), - timestamp_micros: timestamp.map(|t| t.timestamp_micros()), - time: time.map(|t| t.as_secs_f64()), - test_cases, - properties: properties.into_iter().map(BindingsProperty::from).collect(), - system_out: system_out.map(|s| s.to_string()), - system_err: system_err.map(|s| s.to_string()), - extra: HashMap::from_iter( - extra - .into_iter() - .map(|(k, v)| (k.to_string(), v.to_string())), - ), - } - } -} - -impl From for TestSuite { - fn from(val: BindingsTestSuite) -> Self { - let BindingsTestSuite { - name, - tests, - disabled, - errors, - failures, - timestamp: _, - timestamp_micros, - time, - test_cases, - properties, - system_out, - system_err, - extra, - } = val; - let mut test_suite = TestSuite::new(name); - test_suite.tests = tests; - test_suite.disabled = disabled; - test_suite.errors = errors; - test_suite.failures = failures; - test_suite.timestamp = timestamp_micros - .and_then(|micro_secs| { - let micros_delta = TimeDelta::microseconds(micro_secs); - DateTime::from_timestamp( - micros_delta.num_seconds(), - micros_delta.subsec_nanos() as u32, - ) - }) - .map(|dt| dt.fixed_offset()); - test_suite.time = time.map(Duration::from_secs_f64); - let file = test_suite.extra.get(extra_attrs::FILE); - let filepath = test_suite.extra.get(extra_attrs::FILEPATH); - test_suite.test_cases = test_cases - .into_iter() - .map(|mut tc| { - if let Some(file) = file { - tc.extra.insert(extra_attrs::FILE.into(), file.to_string()); - } - if let Some(filepath) = filepath { - tc.extra - .insert(extra_attrs::FILEPATH.into(), filepath.to_string()); - } - BindingsTestCase::try_into(tc) - }) - .filter_map(|t| { - // Removes any invalid test cases that could not be parsed correctly - t.ok() - }) - .collect(); - test_suite.properties = properties.into_iter().map(BindingsProperty::into).collect(); - test_suite.system_out = system_out.map(|s| s.into()); - test_suite.system_err = system_err.map(|s| s.into()); - test_suite.extra = extra - .into_iter() - .map(|(k, v)| (k.into(), v.into())) - .collect(); - test_suite - } -} - -#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] -#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] -#[derive(Clone, Debug)] -pub struct BindingsProperty { - pub name: String, - pub value: String, -} - -impl From for BindingsProperty { - fn from(Property { name, value }: Property) -> Self { - Self { - name: name.to_string(), - value: value.to_string(), - } - } -} - -impl From for Property { - fn from(val: BindingsProperty) -> Self { - let BindingsProperty { name, value } = val; - Property { - name: name.into(), - value: value.into(), - } - } -} - -#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] -#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] -#[derive(Clone, Debug)] -pub struct BindingsTestCase { - pub name: String, - pub classname: Option, - pub assertions: Option, - pub timestamp: Option, - pub timestamp_micros: Option, - pub time: Option, - pub status: BindingsTestCaseStatus, - pub system_out: Option, - pub system_err: Option, - pub codeowners: Option>, - extra: HashMap, - pub properties: Vec, -} - -#[cfg(feature = "pyo3")] -#[gen_stub_pymethods] -#[pymethods] -impl BindingsTestCase { - fn py_extra(&self) -> HashMap { - self.extra.clone() - } -} - -#[cfg(feature = "wasm")] -#[wasm_bindgen] -impl BindingsTestCase { - pub fn js_extra(&self) -> Result { - let entries = self - .extra - .iter() - .fold(js_sys::Array::new(), |acc, (key, value)| { - let entry = js_sys::Array::new(); - entry.push(&js_sys::JsString::from(key.as_str())); - entry.push(&js_sys::JsString::from(value.as_str())); - acc.push(&entry); - acc - }); - js_sys::Object::from_entries(&entries) - } -} - -impl BindingsTestCase { - pub fn extra(&self) -> HashMap { - self.extra.clone() - } - - pub fn is_quarantined(&self) -> bool { - self.extra - .get("is_quarantined") - .map_or(false, |v| v == "true") - } -} - -impl From for BindingsTestCase { - fn from( - TestCase { - name, - classname, - assertions, - timestamp, - time, - status, - system_out, - system_err, - extra, - properties, - // NOTE: The above should be all fields, but here may be more added in the future due to - // `#[non_exhaustive]` - .. - }: TestCase, - ) -> Self { - Self { - name: name.into_string(), - classname: classname.map(|c| c.to_string()), - assertions, - timestamp: timestamp.map(|t| t.timestamp()), - timestamp_micros: timestamp.map(|t| t.timestamp_micros()), - time: time.map(|t| t.as_secs_f64()), - status: BindingsTestCaseStatus::from(status), - system_out: system_out.map(|s| s.to_string()), - system_err: system_err.map(|s| s.to_string()), - extra: HashMap::from_iter( - extra - .into_iter() - .map(|(k, v)| (k.to_string(), v.to_string())), - ), - properties: properties.into_iter().map(BindingsProperty::from).collect(), - codeowners: None, - } - } -} - -impl TryInto for BindingsTestCase { - type Error = (); - - fn try_into(self) -> Result { - let Self { - name, - classname, - assertions, - codeowners: _, - timestamp: _, - timestamp_micros, - time, - status, - system_out, - system_err, - extra, - properties, - } = self; - let mut test_case = TestCase::new(name, status.try_into()?); - test_case.classname = classname.map(|c| c.into()); - test_case.assertions = assertions; - test_case.timestamp = timestamp_micros - .and_then(|micro_secs| { - let micros_delta = TimeDelta::microseconds(micro_secs); - DateTime::from_timestamp( - micros_delta.num_seconds(), - micros_delta.subsec_nanos() as u32, - ) - }) - .map(|dt| dt.fixed_offset()); - test_case.time = time.map(Duration::from_secs_f64); - test_case.system_out = system_out.map(|s| s.into()); - test_case.system_err = system_err.map(|s| s.into()); - test_case.extra = extra - .into_iter() - .map(|(k, v)| (k.into(), v.into())) - .collect(); - test_case.properties = properties.into_iter().map(BindingsProperty::into).collect(); - Ok(test_case) - } -} - -#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] -#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] -#[derive(Clone, Debug)] -pub struct BindingsTestCaseStatus { - pub status: BindingsTestCaseStatusStatus, - pub success: Option, - pub non_success: Option, - pub skipped: Option, -} - -impl From for BindingsTestCaseStatus { - fn from(value: TestCaseStatus) -> Self { - match value { - TestCaseStatus::Success { flaky_runs } => Self { - status: BindingsTestCaseStatusStatus::Success, - success: Some(BindingsTestCaseStatusSuccess { - flaky_runs: flaky_runs - .into_iter() - .map(BindingsTestRerun::from) - .collect(), - }), - non_success: None, - skipped: None, - }, - TestCaseStatus::NonSuccess { - kind, - message, - ty, - description, - reruns, - } => Self { - status: BindingsTestCaseStatusStatus::NonSuccess, - success: None, - non_success: Some(BindingsTestCaseStatusNonSuccess { - kind: BindingsNonSuccessKind::from(kind), - message: message.map(|m| m.into_string()), - ty: ty.map(|t| t.into_string()), - description: description.map(|d| d.into_string()), - reruns: reruns.into_iter().map(BindingsTestRerun::from).collect(), - }), - skipped: None, - }, - TestCaseStatus::Skipped { - message, - ty, - description, - } => Self { - status: BindingsTestCaseStatusStatus::Skipped, - success: None, - non_success: None, - skipped: Some(BindingsTestCaseStatusSkipped { - message: message.map(|m| m.into_string()), - ty: ty.map(|t| t.into_string()), - description: description.map(|d| d.into_string()), - }), - }, - } - } -} - -impl TryInto for BindingsTestCaseStatus { - type Error = (); - - fn try_into(self) -> Result { - let Self { - status, - success, - non_success, - skipped, - } = self; - match (status, success, non_success, skipped) { - (BindingsTestCaseStatusStatus::Success, Some(success_fields), None, None) => { - Ok(success_fields.into()) - } - (BindingsTestCaseStatusStatus::NonSuccess, None, Some(non_success_fields), None) => { - Ok(non_success_fields.into()) - } - (BindingsTestCaseStatusStatus::Skipped, None, None, Some(skipped_fields)) => { - Ok(skipped_fields.into()) - } - _ => Err(()), - } - } -} - -#[cfg_attr(feature = "pyo3", gen_stub_pyclass_enum, pyclass(eq, eq_int))] -#[cfg_attr(feature = "wasm", wasm_bindgen)] -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub enum BindingsTestCaseStatusStatus { - Success, - NonSuccess, - Skipped, - Unspecified, -} - -#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] -#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] -#[derive(Clone, Debug)] -pub struct BindingsTestCaseStatusSuccess { - pub flaky_runs: Vec, -} - -impl From for TestCaseStatus { - fn from(val: BindingsTestCaseStatusSuccess) -> Self { - let BindingsTestCaseStatusSuccess { flaky_runs } = val; - TestCaseStatus::Success { - flaky_runs: flaky_runs - .into_iter() - .map(BindingsTestRerun::into) - .collect(), - } - } -} - -#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] -#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] -#[derive(Clone, Debug)] -pub struct BindingsTestCaseStatusNonSuccess { - pub kind: BindingsNonSuccessKind, - pub message: Option, - pub ty: Option, - pub description: Option, - pub reruns: Vec, -} - -impl From for TestCaseStatus { - fn from(val: BindingsTestCaseStatusNonSuccess) -> Self { - let BindingsTestCaseStatusNonSuccess { - kind, - message, - ty, - description, - reruns, - } = val; - TestCaseStatus::NonSuccess { - kind: kind.into(), - message: message.map(|m| m.into()), - ty: ty.map(|t| t.into()), - description: description.map(|d| d.into()), - reruns: reruns.into_iter().map(BindingsTestRerun::into).collect(), - } - } -} - -#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] -#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] -#[derive(Clone, Debug)] -pub struct BindingsTestCaseStatusSkipped { - pub message: Option, - pub ty: Option, - pub description: Option, -} - -impl From for TestCaseStatus { - fn from(val: BindingsTestCaseStatusSkipped) -> Self { - let BindingsTestCaseStatusSkipped { - message, - ty, - description, - } = val; - TestCaseStatus::Skipped { - message: message.map(|m| m.into()), - ty: ty.map(|t| t.into()), - description: description.map(|d| d.into()), - } - } -} - -#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] -#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] -#[derive(Clone, Debug)] -pub struct BindingsTestRerun { - pub kind: BindingsNonSuccessKind, - pub timestamp: Option, - pub timestamp_micros: Option, - pub time: Option, - pub message: Option, - pub ty: Option, - pub stack_trace: Option, - pub system_out: Option, - pub system_err: Option, - pub description: Option, -} - -impl From for BindingsTestRerun { - fn from( - TestRerun { - kind, - timestamp, - time, - message, - ty, - stack_trace, - system_out, - system_err, - description, - }: TestRerun, - ) -> Self { - Self { - kind: BindingsNonSuccessKind::from(kind), - timestamp: timestamp.map(|t| t.timestamp()), - timestamp_micros: timestamp.map(|t| t.timestamp_micros()), - time: time.map(|t| t.as_secs_f64()), - message: message.map(|m| m.to_string()), - ty: ty.map(|t| t.to_string()), - stack_trace: stack_trace.map(|st| st.to_string()), - system_out: system_out.map(|s| s.to_string()), - system_err: system_err.map(|s| s.to_string()), - description: description.map(|d| d.to_string()), - } - } -} - -impl From for TestRerun { - fn from(val: BindingsTestRerun) -> Self { - let BindingsTestRerun { - kind, - timestamp: _, - timestamp_micros, - time, - message, - ty, - stack_trace, - system_out, - system_err, - description, - } = val; - TestRerun { - kind: kind.into(), - timestamp: timestamp_micros - .and_then(|micro_secs| { - let micros_delta = TimeDelta::microseconds(micro_secs); - DateTime::from_timestamp( - micros_delta.num_seconds(), - micros_delta.subsec_nanos() as u32, - ) - }) - .map(|dt| dt.fixed_offset()), - time: time.map(Duration::from_secs_f64), - message: message.map(|m| m.into()), - ty: ty.map(|t| t.into()), - stack_trace: stack_trace.map(|st| st.into()), - system_out: system_out.map(|s| s.into()), - system_err: system_err.map(|s| s.into()), - description: description.map(|d| d.into()), - } - } -} - -#[cfg_attr(feature = "pyo3", gen_stub_pyclass_enum, pyclass(eq, eq_int))] -#[cfg_attr(feature = "wasm", wasm_bindgen)] -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub enum BindingsNonSuccessKind { - Failure, - Error, -} - -impl From for BindingsNonSuccessKind { - fn from(value: NonSuccessKind) -> Self { - match value { - NonSuccessKind::Failure => BindingsNonSuccessKind::Failure, - NonSuccessKind::Error => BindingsNonSuccessKind::Error, - } - } -} - -impl From for NonSuccessKind { - fn from(val: BindingsNonSuccessKind) -> Self { - match val { - BindingsNonSuccessKind::Failure => NonSuccessKind::Failure, - BindingsNonSuccessKind::Error => NonSuccessKind::Error, - } - } -} - -#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] -#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] -#[derive(Clone, Debug)] -pub struct BindingsJunitReportValidation { - all_issues: Vec, - level: JunitValidationLevel, - test_runner_report: TestRunnerReportValidation, - test_suites: Vec, - valid_test_suites: Vec, -} - -impl From for BindingsJunitReportValidation { - fn from( - JunitReportValidation { - all_issues, - level, - test_suites, - valid_test_suites, - test_runner_report, - }: JunitReportValidation, - ) -> Self { - Self { - all_issues: all_issues - .into_iter() - .map(|i| JunitReportValidationFlatIssue { - level: JunitValidationLevel::from(&i), - error_type: JunitValidationType::from(&i), - error_message: i.to_string(), - }) - .collect(), - level, - test_suites, - valid_test_suites: valid_test_suites - .into_iter() - .map(BindingsTestSuite::from) - .collect(), - test_runner_report, - } - } -} - -impl From for JunitReportValidation { - fn from( - BindingsJunitReportValidation { - all_issues: _, - level, - test_runner_report, - test_suites, - valid_test_suites, - }: BindingsJunitReportValidation, - ) -> Self { - let mut validation = Self { - all_issues: Vec::new(), - level, - test_runner_report, - test_suites, - valid_test_suites, - }; - validation.derive_all_issues(); - validation - } -} - -#[cfg_attr(feature = "pyo3", gen_stub_pymethods, pymethods)] -#[cfg_attr(feature = "wasm", wasm_bindgen)] -impl BindingsJunitReportValidation { - pub fn all_issues_owned(&self) -> Vec { - self.all_issues.clone() - } - - pub fn max_level(&self) -> JunitValidationLevel { - self.test_suites - .iter() - .map(|test_suite| test_suite.max_level()) - .max() - .map_or(self.level, |l| l.max(self.level)) - } - - pub fn num_invalid_issues(&self) -> usize { - self.all_issues - .iter() - .filter(|issue| issue.level == JunitValidationLevel::Invalid) - .count() - } - - pub fn num_suboptimal_issues(&self) -> usize { - self.all_issues - .iter() - .filter(|issue| issue.level == JunitValidationLevel::SubOptimal) - .count() - } -} - -#[cfg(test)] -mod tests { - use std::io::BufReader; - - use proto::test_context::test_run::{ - AttemptNumber, CodeOwner, LineNumber, TestCaseRun, TestCaseRunStatus, TestResult, - }; - - use crate::junit::bindings::{BindingsReport, BindingsTestCaseStatusStatus}; - use crate::junit::parser::JunitParser; - use crate::junit::validator::{JunitValidationLevel, JunitValidationType}; - - #[cfg(feature = "bindings")] - #[test] - fn parse_quick_junit_to_bindings() { - use std::io::BufReader; - - use crate::junit::parser::JunitParser; - const INPUT_XML: &str = r#" - - - - - - - - - -"#; - let mut junit_parser = JunitParser::new(); - junit_parser - .parse(BufReader::new(INPUT_XML.as_bytes())) - .unwrap(); - let reports = junit_parser.into_reports(); - assert_eq!(reports.len(), 1); - let bindings_report = BindingsReport::from(reports[0].clone()); - assert_eq!(bindings_report.name, "my-test-run"); - assert_eq!(bindings_report.tests, 2); - assert_eq!(bindings_report.failures, 1); - assert_eq!(bindings_report.errors, 0); - assert_eq!(bindings_report.test_suites.len(), 1); - let test_suite = &bindings_report.test_suites[0]; - assert_eq!(test_suite.name, "my-test-suite"); - assert_eq!(test_suite.tests, 2); - assert_eq!(test_suite.disabled, 0); - assert_eq!(test_suite.errors, 0); - assert_eq!(test_suite.failures, 1); - assert_eq!(test_suite.test_cases.len(), 2); - let test_case1 = &test_suite.test_cases[0]; - assert_eq!(test_case1.name, "success-case"); - assert_eq!(test_case1.classname, None); - assert_eq!(test_case1.assertions, None); - assert_eq!(test_case1.timestamp, None); - assert_eq!(test_case1.timestamp_micros, None); - assert_eq!(test_case1.time, None); - assert_eq!(test_case1.system_out, None); - assert_eq!(test_case1.system_err, None); - assert_eq!(test_case1.extra.len(), 1); - assert_eq!(test_case1.extra["file"], "path/to/my/test.js"); - assert_eq!(test_case1.properties.len(), 0); - let test_case2 = &test_suite.test_cases[1]; - assert_eq!(test_case2.name, "failure-case"); - assert_eq!(test_case2.classname, None); - assert_eq!(test_case2.assertions, None); - assert_eq!(test_case2.timestamp, None); - assert_eq!(test_case2.timestamp_micros, None); - assert_eq!(test_case2.time, None); - assert_eq!(test_case2.system_out, None); - assert_eq!(test_case2.system_err, None); - assert_eq!(test_case2.extra.len(), 1); - assert_eq!(test_case2.extra["file"], "path/to/my/test.js"); - assert_eq!(test_case2.properties.len(), 0); - } - - #[cfg(feature = "bindings")] - #[test] - fn parse_test_report_to_bindings() { - use prost_wkt_types::Timestamp; - use proto::test_context::test_run::TestOutput; - - use crate::junit::validator::validate; - let test_started_at = Timestamp { - seconds: 1000, - nanos: 0, - }; - let test_finished_at = Timestamp { - seconds: 2000, - nanos: 0, - }; - let codeowner1 = CodeOwner { - name: "@user".into(), - }; - let test1 = TestCaseRun { - id: "test_id1".into(), - name: "test_name".into(), - classname: "test_classname".into(), - file: "test_file".into(), - parent_name: "test_parent_name1".into(), - // trunk-ignore(clippy/deprecated) - line: 0, - line_number: Some(LineNumber { number: 1 }), - status: TestCaseRunStatus::Success.into(), - // trunk-ignore(clippy/deprecated) - attempt_number: 0, - attempt_index: Some(AttemptNumber { number: 1 }), - started_at: Some(test_started_at.clone()), - finished_at: Some(test_finished_at.clone()), - status_output_message: "test_status_output_message".into(), - codeowners: vec![codeowner1], - test_output: Some(TestOutput { - message: "test_failure_message".into(), - text: "".into(), - system_out: "".into(), - system_err: "".into(), - }), - ..Default::default() - }; - - let test2 = TestCaseRun { - id: "test_id2".into(), - name: "test_name".into(), - classname: "test_classname".into(), - file: "test_file".into(), - parent_name: "test_parent_name2".into(), - line: 1, - status: TestCaseRunStatus::Failure.into(), - attempt_number: 1, - started_at: Some(test_started_at.clone()), - finished_at: Some(test_finished_at), - status_output_message: "test_status_output_message".into(), - test_output: Some(TestOutput { - message: "".into(), - text: "test_status_output_message".into(), - system_out: "".into(), - system_err: "".into(), - }), - ..Default::default() - }; - - let mut test_result = TestResult::default(); - test_result.test_case_runs.push(test1.clone()); - test_result.test_case_runs.push(test1.clone()); - test_result.test_case_runs.push(test2.clone()); - - let converted_bindings: BindingsReport = test_result.into(); - assert_eq!(converted_bindings.test_suites.len(), 2); - let mut test_suite1 = &converted_bindings.test_suites[0]; - let mut test_suite2 = &converted_bindings.test_suites[1]; - if test_suite1.name == "test_parent_name1" { - assert_eq!(test_suite1.tests, 2); - assert_eq!(test_suite2.tests, 1); - } else { - assert_eq!(test_suite1.tests, 1); - assert_eq!(test_suite2.tests, 2); - // swap them for convenience - (test_suite1, test_suite2) = (test_suite2, test_suite1); - } - let test_case1 = &test_suite1.test_cases[0]; - assert_eq!(test_case1.name, test1.name); - assert_eq!(test_case1.classname, Some(test1.classname)); - assert_eq!(test_case1.assertions, None); - assert_eq!( - test_case1.timestamp, - Some(test1.started_at.clone().unwrap().seconds) - ); - assert_eq!( - test_case1.timestamp_micros, - Some( - test1.started_at.clone().unwrap().seconds * 1000000 - + test1.started_at.unwrap().nanos as i64 / 1000 - ) - ); - assert_eq!(test_case1.time, Some(1000.0)); - assert_eq!(test_case1.system_out, None); - assert_eq!(test_case1.system_err, None); - assert!(test_case1.status.success.is_some()); - assert_eq!(test_case1.extra["id"], test1.id); - assert_eq!(test_case1.extra["file"], test1.file); - assert_eq!( - test_case1.extra["line"], - test1.line_number.unwrap().number.to_string() - ); - assert_eq!( - test_case1.extra["attempt_number"], - test1.attempt_index.unwrap().number.to_string() - ); - assert_eq!(test_case1.properties.len(), 0); - assert_eq!(test_case1.codeowners.clone().unwrap().len(), 1); - assert_eq!(test_case1.codeowners.clone().unwrap()[0], "@user"); - - assert_eq!(test_suite2.test_cases.len(), 1); - let test_case2 = &test_suite2.test_cases[0]; - assert_eq!(test_case2.name, test2.name); - assert_eq!(test_case2.classname, Some(test2.classname)); - assert_eq!(test_case2.assertions, None); - assert_eq!( - test_case2.timestamp, - Some(test2.started_at.clone().unwrap().seconds) - ); - assert_eq!( - test_case2.timestamp_micros, - Some( - test2.started_at.clone().unwrap().seconds * 1000000 - + test2.started_at.unwrap().nanos as i64 / 1000 - ) - ); - assert_eq!(test_case2.time, Some(1000.0)); - assert_eq!(test_case2.system_out, None); - assert_eq!(test_case2.system_err, None); - assert_eq!( - test_case2.status.non_success.as_ref().unwrap().description, - Some(test2.test_output.clone().unwrap().text) - ); - assert_eq!( - test_case2.status.non_success.as_ref().unwrap().message, - None - ); - assert_eq!(test_case2.extra["id"], test2.id); - assert_eq!(test_case2.extra["file"], test2.file); - assert_eq!(test_case2.extra["line"], test2.line.to_string()); - assert_eq!( - test_case2.extra["attempt_number"], - test2.attempt_number.to_string() - ); - assert_eq!(test_case2.properties.len(), 0); - assert_eq!(test_case2.codeowners.clone().unwrap().len(), 0); - - // verify that the test report is valid - let results = validate( - &converted_bindings, - &None, - chrono::Utc::now().fixed_offset(), - ); - assert_eq!(results.all_issues_owned().len(), 1); - results - .all_issues_owned() - .sort_by(|a, b| a.error_message.cmp(&b.error_message)); - results - .all_issues_owned() - .iter() - .enumerate() - .for_each(|issue| { - assert_eq!(issue.1.level, JunitValidationLevel::SubOptimal); - if issue.0 == 0 { - assert_eq!(issue.1.error_type, JunitValidationType::Report); - assert_eq!( - issue.1.error_message, - "report has old (> 24 hour(s)) timestamps" - ); - } else { - assert_eq!(issue.1.error_type, JunitValidationType::TestCase); - assert_eq!(issue.1.error_message, "test case id is not a valid uuidv5"); - } - }); - assert_eq!(results.test_suites.len(), 2); - assert_eq!(results.valid_test_suites.len(), 2); - assert_eq!( - results.valid_test_suites[0].test_cases.len(), - converted_bindings.test_suites[0].tests - ); - assert_eq!( - results.valid_test_suites[1].test_cases.len(), - converted_bindings.test_suites[1].tests - ); - } - #[cfg(feature = "bindings")] - #[test] - fn test_junit_conversion_paths() { - use crate::repo::RepoUrlParts; - - let mut junit_parser = JunitParser::new(); - let file_contents = r#" - - - - - - but was: ]]> - - - - - - "#; - let parsed_results = junit_parser.parse(BufReader::new(file_contents.as_bytes())); - assert!(parsed_results.is_ok()); - - // Get test case runs from parser - let test_case_runs = junit_parser.into_test_case_runs( - None, - &String::from(""), - &RepoUrlParts { - host: "".into(), - owner: "".into(), - name: "".into(), - }, - &[], - "", - ); - assert_eq!(test_case_runs.len(), 2); - - // Convert test case runs to bindings - let bindings_from_runs: Vec = - test_case_runs.into_iter().map(|run| run.into()).collect(); - - // Get reports and convert directly to bindings - let mut junit_parser = JunitParser::new(); - junit_parser - .parse(BufReader::new(file_contents.as_bytes())) - .unwrap(); - let reports = junit_parser.into_reports(); - assert_eq!(reports.len(), 1); - - let bindings_from_reports: Vec = reports[0] - .test_suites - .iter() - .flat_map(|suite| suite.test_cases.iter().map(|case| case.clone().into())) - .collect(); - - // Compare the two conversion paths - assert_eq!(bindings_from_runs.len(), bindings_from_reports.len()); - - for (run_binding, report_binding) in - bindings_from_runs.iter().zip(bindings_from_reports.iter()) - { - assert_eq!(run_binding.classname, report_binding.classname); - assert_eq!(run_binding.status.status, report_binding.status.status); - assert_eq!(run_binding.timestamp, report_binding.timestamp); - assert_eq!( - run_binding.timestamp_micros, - report_binding.timestamp_micros - ); - assert_eq!(run_binding.time, report_binding.time); - assert_eq!(run_binding.system_out, report_binding.system_out); - assert_eq!(run_binding.system_err, report_binding.system_err); - if run_binding.status.status == BindingsTestCaseStatusStatus::NonSuccess { - assert_eq!( - run_binding.status.non_success.as_ref().unwrap().description, - Some("Expected: but was: ".into()) - ); - assert_eq!( - run_binding.status.non_success.as_ref().unwrap().message, - Some("Test failed".into()) - ); - } - // check that the properties match - for property in run_binding.properties.iter() { - if let Some(report_property) = report_binding - .properties - .iter() - .find(|p| p.name == property.name) - { - assert_eq!(property.value, report_property.value); - } else { - panic!("Property {} not found in report binding", property.name); - } - } - assert_eq!( - run_binding.extra().get("file"), - report_binding.extra().get("file") - ); - } - } - - #[cfg(feature = "bindings")] - #[test] - fn test_validate_preserves_codeowners_in_valid_test_suites() { - use prost_wkt_types::Timestamp; - - use crate::junit::validator::validate; - let test_started_at = Timestamp { - seconds: chrono::Utc::now().timestamp(), - nanos: 0, - }; - let test_finished_at = Timestamp { - seconds: test_started_at.seconds + 1, - nanos: 0, - }; - let codeowner1 = CodeOwner { - name: "@user1".into(), - }; - let codeowner2 = CodeOwner { - name: "@user2".into(), - }; - let test1 = TestCaseRun { - id: "test_id1".into(), - name: "test_name1".into(), - classname: "test_classname".into(), - file: "test_file1.java".into(), - parent_name: "test_parent_name1".into(), - line: 1, - status: TestCaseRunStatus::Success.into(), - attempt_number: 1, - started_at: Some(test_started_at.clone()), - finished_at: Some(test_finished_at.clone()), - status_output_message: "".into(), - codeowners: vec![codeowner1.clone()], - test_output: None, - ..Default::default() - }; - - let test2 = TestCaseRun { - id: "test_id2".into(), - name: "test_name2".into(), - classname: "test_classname".into(), - file: "test_file2.java".into(), - parent_name: "test_parent_name1".into(), - line: 2, - status: TestCaseRunStatus::Success.into(), - attempt_number: 1, - started_at: Some(test_started_at.clone()), - finished_at: Some(test_finished_at.clone()), - status_output_message: "".into(), - codeowners: vec![codeowner2.clone()], - test_output: None, - ..Default::default() - }; - - let mut test_result = TestResult::default(); - test_result.test_case_runs.push(test1.clone()); - test_result.test_case_runs.push(test2.clone()); - - let converted_bindings: BindingsReport = test_result.into(); - - // Verify codeowners are present in the original report - assert_eq!(converted_bindings.test_suites.len(), 1); - let original_test_suite = &converted_bindings.test_suites[0]; - assert_eq!(original_test_suite.test_cases.len(), 2); - assert_eq!( - original_test_suite.test_cases[0].codeowners, - Some(vec!["@user1".to_string()]) - ); - assert_eq!( - original_test_suite.test_cases[1].codeowners, - Some(vec!["@user2".to_string()]) - ); - - // Validate the report - let validation_result = validate( - &converted_bindings, - &None, - chrono::Utc::now().fixed_offset(), - ); - - // Verify that valid_test_suites preserves codeowners - assert_eq!(validation_result.valid_test_suites.len(), 1); - let valid_test_suite = &validation_result.valid_test_suites[0]; - assert_eq!(valid_test_suite.test_cases.len(), 2); - - // Find test cases by name to match them up - let valid_test_case1 = valid_test_suite - .test_cases - .iter() - .find(|tc| tc.name == "test_name1") - .expect("test_name1 should be in valid_test_suites"); - let valid_test_case2 = valid_test_suite - .test_cases - .iter() - .find(|tc| tc.name == "test_name2") - .expect("test_name2 should be in valid_test_suites"); - - // Verify codeowners are preserved - assert_eq!( - valid_test_case1.codeowners, - Some(vec!["@user1".to_string()]), - "codeowners for test_name1 should be preserved" - ); - assert_eq!( - valid_test_case2.codeowners, - Some(vec!["@user2".to_string()]), - "codeowners for test_name2 should be preserved" - ); - } -} diff --git a/context/src/junit/bindings/mod.rs b/context/src/junit/bindings/mod.rs new file mode 100644 index 00000000..274d8bdd --- /dev/null +++ b/context/src/junit/bindings/mod.rs @@ -0,0 +1,13 @@ +#[cfg(feature = "bindings")] +pub mod report; +#[cfg(feature = "bindings")] +pub mod suite; +#[cfg(feature = "bindings")] +pub mod test_case; +#[cfg(feature = "bindings")] +pub mod validation; + +pub use report::*; +pub use suite::*; +pub use test_case::*; +pub use validation::*; diff --git a/context/src/junit/bindings/report.rs b/context/src/junit/bindings/report.rs new file mode 100644 index 00000000..405504c4 --- /dev/null +++ b/context/src/junit/bindings/report.rs @@ -0,0 +1,751 @@ +use std::{collections::HashMap, time::Duration}; + +use chrono::{DateTime, TimeDelta}; +use proto::test_context::test_run::{TestBuildResult, TestResult}; +#[cfg(feature = "pyo3")] +use pyo3::prelude::*; +#[cfg(feature = "pyo3")] +use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pyclass_enum}; +use quick_junit::Report; +#[cfg(feature = "wasm")] +use wasm_bindgen::prelude::*; + +use crate::junit::{ + bindings::{ + suite::BindingsTestSuite, + test_case::{BindingsTestCase, BindingsTestCaseStatusStatus}, + }, + parser::JunitParseFlatIssue, +}; + +#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] +#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] +#[derive(Clone, Debug)] +pub struct BindingsParseResult { + pub report: Option, + pub issues: Vec, +} + +#[cfg_attr(feature = "pyo3", gen_stub_pyclass_enum, pyclass(eq, eq_int))] +#[cfg_attr(feature = "wasm", wasm_bindgen)] +#[derive(Clone, Debug, Copy, PartialEq, Eq)] +pub enum BindingsTestBuildResult { + Unspecified, + Success, + Failure, + Skipped, + Flaky, +} + +// Ideally this would be an enum, but enums are not directly supportted by wasm conversions, so we have to manually map out the options. See: https://github.com/rustwasm/wasm-bindgen/issues/2407 +#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] +#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] +#[derive(Clone, Debug)] +pub struct BazelBuildInformation { + pub label: String, + pub result: BindingsTestBuildResult, + pub max_attempt_number: Option, +} + +#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] +#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] +#[derive(Clone, Debug)] +pub struct BindingsReport { + pub name: String, + pub uuid: Option, + pub timestamp: Option, + pub timestamp_micros: Option, + pub time: Option, + pub tests: usize, + pub failures: usize, + pub errors: usize, + pub test_suites: Vec, + pub variant: Option, + pub bazel_build_information: Option, +} + +pub fn map_i32_to_bindings_test_build_result( + result: i32, +) -> anyhow::Result { + if result == TestBuildResult::Unspecified as i32 { + Ok(BindingsTestBuildResult::Unspecified) + } else if result == TestBuildResult::Success as i32 { + Ok(BindingsTestBuildResult::Success) + } else if result == TestBuildResult::Failure as i32 { + Ok(BindingsTestBuildResult::Failure) + } else if result == TestBuildResult::Skipped as i32 { + Ok(BindingsTestBuildResult::Skipped) + } else if result == TestBuildResult::Flaky as i32 { + Ok(BindingsTestBuildResult::Flaky) + } else { + Err(anyhow::anyhow!("Unknown TestBuildResult: {result}")) + } +} + +impl From for BindingsReport { + fn from( + TestResult { + test_case_runs, + // trunk-ignore(clippy/deprecated) + uploader_metadata, + test_build_information, + }: TestResult, + ) -> Self { + let test_cases: Vec = test_case_runs + .into_iter() + .map(BindingsTestCase::from) + .collect(); + let parent_name_map: HashMap> = + test_cases.iter().fold(HashMap::new(), |mut acc, testcase| { + if let Some(parent_name) = testcase.extra.get("parent_name") { + acc.entry(parent_name.clone()) + .or_default() + .push(testcase.to_owned()); + } + acc + }); + let test_suites: Vec = parent_name_map + .into_iter() + .map(|(name, testcases)| { + let tests = testcases.len(); + let disabled = testcases + .iter() + .filter(|tc| tc.status.status == BindingsTestCaseStatusStatus::Skipped) + .count(); + let failures = testcases + .iter() + .filter(|tc| tc.status.status == BindingsTestCaseStatusStatus::NonSuccess) + .count(); + let timestamp = testcases.iter().map(|tc| tc.timestamp.unwrap_or(0)).max(); + let timestamp_micros = testcases + .iter() + .map(|tc| tc.timestamp_micros.unwrap_or(0)) + .max(); + let time = testcases.iter().map(|tc| tc.time.unwrap_or(0.0)).sum(); + BindingsTestSuite { + name, + tests, + disabled, + errors: 0, + failures, + timestamp, + timestamp_micros, + time: Some(time), + test_cases: testcases, + properties: vec![], + system_out: None, + system_err: None, + extra: HashMap::new(), + } + }) + .collect(); + let (report_time, report_failures, report_tests) = + test_suites.iter().fold((0.0, 0, 0), |acc, ts| { + ( + acc.0 + ts.time.unwrap_or(0.0), + acc.1 + ts.failures, + acc.2 + ts.tests, + ) + }); + let (name, timestamp, timestamp_micros, variant) = match uploader_metadata { + Some(t) => { + let upload_time = t.upload_time.clone().unwrap_or_default(); + ( + t.origin, + Some(upload_time.seconds), + Some( + chrono::Duration::nanoseconds(upload_time.nanos as i64) + .num_microseconds() + .unwrap_or_default(), + ), + Some(t.variant), + ) + } + None => ("Unknown".to_string(), None, None, None), + }; + let bazel_build_information = match test_build_information { + Some(proto::test_context::test_run::test_result::TestBuildInformation::BazelBuildInformation( + bazel_build_information, + )) => Some(BazelBuildInformation { + label: bazel_build_information.label, + result: map_i32_to_bindings_test_build_result(bazel_build_information.result) + .unwrap_or(BindingsTestBuildResult::Unspecified), + max_attempt_number: bazel_build_information.max_attempt_number.map(|number| number.number), + }), + _ => None, + }; + BindingsReport { + name, + test_suites, + time: Some(report_time), + uuid: None, + timestamp, + timestamp_micros, + errors: 0, + failures: report_failures, + tests: report_tests, + variant, + bazel_build_information, + } + } +} + +impl From for BindingsReport { + fn from( + Report { + name, + uuid, + timestamp, + time, + tests, + failures, + errors, + test_suites, + }: Report, + ) -> Self { + Self { + name: name.into_string(), + uuid: uuid.map(|u| u.to_string()), + timestamp: timestamp.map(|t| t.timestamp()), + timestamp_micros: timestamp.map(|t| t.timestamp_micros()), + time: time.map(|t| t.as_secs_f64()), + tests, + failures, + errors, + test_suites: test_suites + .into_iter() + .map(BindingsTestSuite::from) + .collect(), + variant: None, + bazel_build_information: None, + } + } +} + +impl From for Report { + fn from(val: BindingsReport) -> Self { + let BindingsReport { + name, + uuid, + timestamp: _, + timestamp_micros, + time, + tests, + failures, + errors, + test_suites, + variant: _, + bazel_build_information: _, + } = val; + // NOTE: Cannot make a UUID without a `&'static str` + let _ = uuid; + Report { + name: name.into(), + uuid: None, + timestamp: timestamp_micros + .and_then(|micro_secs| { + let micros_delta = TimeDelta::microseconds(micro_secs); + DateTime::from_timestamp( + micros_delta.num_seconds(), + micros_delta.subsec_nanos() as u32, + ) + }) + .map(|dt| dt.fixed_offset()), + time: time.map(Duration::from_secs_f64), + tests, + failures, + errors, + test_suites: test_suites + .into_iter() + .map(BindingsTestSuite::into) + .collect(), + } + } +} + +#[cfg(test)] +mod tests { + use std::io::BufReader; + + use proto::test_context::test_run::{ + AttemptNumber, CodeOwner, LineNumber, TestCaseRun, TestCaseRunStatus, TestResult, + }; + + use crate::junit::bindings::{BindingsReport, BindingsTestCaseStatusStatus}; + use crate::junit::parser::JunitParser; + use crate::junit::validator::{JunitValidationLevel, JunitValidationType}; + + #[cfg(feature = "bindings")] + #[test] + fn parse_quick_junit_to_bindings() { + use std::io::BufReader; + + use crate::junit::parser::JunitParser; + const INPUT_XML: &str = r#" + + + + + + + + + +"#; + let mut junit_parser = JunitParser::new(); + junit_parser + .parse(BufReader::new(INPUT_XML.as_bytes())) + .unwrap(); + let reports = junit_parser.into_reports(); + assert_eq!(reports.len(), 1); + let bindings_report = BindingsReport::from(reports[0].clone()); + assert_eq!(bindings_report.name, "my-test-run"); + assert_eq!(bindings_report.tests, 2); + assert_eq!(bindings_report.failures, 1); + assert_eq!(bindings_report.errors, 0); + assert_eq!(bindings_report.test_suites.len(), 1); + let test_suite = &bindings_report.test_suites[0]; + assert_eq!(test_suite.name, "my-test-suite"); + assert_eq!(test_suite.tests, 2); + assert_eq!(test_suite.disabled, 0); + assert_eq!(test_suite.errors, 0); + assert_eq!(test_suite.failures, 1); + assert_eq!(test_suite.test_cases.len(), 2); + let test_case1 = &test_suite.test_cases[0]; + assert_eq!(test_case1.name, "success-case"); + assert_eq!(test_case1.classname, None); + assert_eq!(test_case1.assertions, None); + assert_eq!(test_case1.timestamp, None); + assert_eq!(test_case1.timestamp_micros, None); + assert_eq!(test_case1.time, None); + assert_eq!(test_case1.system_out, None); + assert_eq!(test_case1.system_err, None); + assert_eq!(test_case1.extra.len(), 1); + assert_eq!(test_case1.extra["file"], "path/to/my/test.js"); + assert_eq!(test_case1.properties.len(), 0); + let test_case2 = &test_suite.test_cases[1]; + assert_eq!(test_case2.name, "failure-case"); + assert_eq!(test_case2.classname, None); + assert_eq!(test_case2.assertions, None); + assert_eq!(test_case2.timestamp, None); + assert_eq!(test_case2.timestamp_micros, None); + assert_eq!(test_case2.time, None); + assert_eq!(test_case2.system_out, None); + assert_eq!(test_case2.system_err, None); + assert_eq!(test_case2.extra.len(), 1); + assert_eq!(test_case2.extra["file"], "path/to/my/test.js"); + assert_eq!(test_case2.properties.len(), 0); + } + + #[cfg(feature = "bindings")] + #[test] + fn parse_test_report_to_bindings() { + use prost_wkt_types::Timestamp; + use proto::test_context::test_run::TestOutput; + + use crate::junit::validator::validate; + let test_started_at = Timestamp { + seconds: 1000, + nanos: 0, + }; + let test_finished_at = Timestamp { + seconds: 2000, + nanos: 0, + }; + let codeowner1 = CodeOwner { + name: "@user".into(), + }; + let test1 = TestCaseRun { + id: "test_id1".into(), + name: "test_name".into(), + classname: "test_classname".into(), + file: "test_file".into(), + parent_name: "test_parent_name1".into(), + // trunk-ignore(clippy/deprecated) + line: 0, + line_number: Some(LineNumber { number: 1 }), + status: TestCaseRunStatus::Success.into(), + // trunk-ignore(clippy/deprecated) + attempt_number: 0, + attempt_index: Some(AttemptNumber { number: 1 }), + started_at: Some(test_started_at.clone()), + finished_at: Some(test_finished_at.clone()), + // trunk-ignore(clippy/deprecated) + status_output_message: "test_status_output_message".into(), + codeowners: vec![codeowner1], + test_output: Some(TestOutput { + message: "test_failure_message".into(), + text: "".into(), + system_out: "".into(), + system_err: "".into(), + }), + ..Default::default() + }; + + let test2 = TestCaseRun { + id: "test_id2".into(), + name: "test_name".into(), + classname: "test_classname".into(), + file: "test_file".into(), + parent_name: "test_parent_name2".into(), + // trunk-ignore(clippy/deprecated) + line: 1, + status: TestCaseRunStatus::Failure.into(), + // trunk-ignore(clippy/deprecated) + attempt_number: 1, + started_at: Some(test_started_at.clone()), + finished_at: Some(test_finished_at), + // trunk-ignore(clippy/deprecated) + status_output_message: "test_status_output_message".into(), + test_output: Some(TestOutput { + message: "".into(), + text: "test_status_output_message".into(), + system_out: "".into(), + system_err: "".into(), + }), + ..Default::default() + }; + + let mut test_result = TestResult::default(); + test_result.test_case_runs.push(test1.clone()); + test_result.test_case_runs.push(test1.clone()); + test_result.test_case_runs.push(test2.clone()); + + let converted_bindings: BindingsReport = test_result.into(); + assert_eq!(converted_bindings.test_suites.len(), 2); + let mut test_suite1 = &converted_bindings.test_suites[0]; + let mut test_suite2 = &converted_bindings.test_suites[1]; + if test_suite1.name == "test_parent_name1" { + assert_eq!(test_suite1.tests, 2); + assert_eq!(test_suite2.tests, 1); + } else { + assert_eq!(test_suite1.tests, 1); + assert_eq!(test_suite2.tests, 2); + // swap them for convenience + (test_suite1, test_suite2) = (test_suite2, test_suite1); + } + let test_case1 = &test_suite1.test_cases[0]; + assert_eq!(test_case1.name, test1.name); + assert_eq!(test_case1.classname, Some(test1.classname)); + assert_eq!(test_case1.assertions, None); + assert_eq!( + test_case1.timestamp, + Some(test1.started_at.clone().unwrap().seconds) + ); + assert_eq!( + test_case1.timestamp_micros, + Some( + test1.started_at.clone().unwrap().seconds * 1000000 + + test1.started_at.unwrap().nanos as i64 / 1000 + ) + ); + assert_eq!(test_case1.time, Some(1000.0)); + assert_eq!(test_case1.system_out, None); + assert_eq!(test_case1.system_err, None); + assert!(test_case1.status.success.is_some()); + assert_eq!(test_case1.extra["id"], test1.id); + assert_eq!(test_case1.extra["file"], test1.file); + assert_eq!( + test_case1.extra["line"], + test1.line_number.unwrap().number.to_string() + ); + assert_eq!( + test_case1.extra["attempt_number"], + test1.attempt_index.unwrap().number.to_string() + ); + assert_eq!(test_case1.properties.len(), 0); + assert_eq!(test_case1.codeowners.clone().unwrap().len(), 1); + assert_eq!(test_case1.codeowners.clone().unwrap()[0], "@user"); + + assert_eq!(test_suite2.test_cases.len(), 1); + let test_case2 = &test_suite2.test_cases[0]; + assert_eq!(test_case2.name, test2.name); + assert_eq!(test_case2.classname, Some(test2.classname)); + assert_eq!(test_case2.assertions, None); + assert_eq!( + test_case2.timestamp, + Some(test2.started_at.clone().unwrap().seconds) + ); + assert_eq!( + test_case2.timestamp_micros, + Some( + test2.started_at.clone().unwrap().seconds * 1000000 + + test2.started_at.unwrap().nanos as i64 / 1000 + ) + ); + assert_eq!(test_case2.time, Some(1000.0)); + assert_eq!(test_case2.system_out, None); + assert_eq!(test_case2.system_err, None); + assert_eq!( + test_case2.status.non_success.as_ref().unwrap().description, + Some(test2.test_output.clone().unwrap().text) + ); + assert_eq!( + test_case2.status.non_success.as_ref().unwrap().message, + None + ); + assert_eq!(test_case2.extra["id"], test2.id); + assert_eq!(test_case2.extra["file"], test2.file); + // trunk-ignore(clippy/deprecated) + assert_eq!(test_case2.extra["line"], test2.line.to_string()); + assert_eq!( + test_case2.extra["attempt_number"], + // trunk-ignore(clippy/deprecated) + test2.attempt_number.to_string() + ); + assert_eq!(test_case2.properties.len(), 0); + assert_eq!(test_case2.codeowners.clone().unwrap().len(), 0); + + // verify that the test report is valid + let results = validate( + &converted_bindings, + &None, + chrono::Utc::now().fixed_offset(), + ); + assert_eq!(results.all_issues_owned().len(), 1); + results + .all_issues_owned() + .sort_by(|a, b| a.error_message.cmp(&b.error_message)); + results + .all_issues_owned() + .iter() + .enumerate() + .for_each(|issue| { + assert_eq!(issue.1.level, JunitValidationLevel::SubOptimal); + if issue.0 == 0 { + assert_eq!(issue.1.error_type, JunitValidationType::Report); + assert_eq!( + issue.1.error_message, + "report has old (> 24 hour(s)) timestamps" + ); + } else { + assert_eq!(issue.1.error_type, JunitValidationType::TestCase); + assert_eq!(issue.1.error_message, "test case id is not a valid uuidv5"); + } + }); + assert_eq!(results.test_suites.len(), 2); + assert_eq!(results.valid_test_suites.len(), 2); + assert_eq!( + results.valid_test_suites[0].test_cases.len(), + converted_bindings.test_suites[0].tests + ); + assert_eq!( + results.valid_test_suites[1].test_cases.len(), + converted_bindings.test_suites[1].tests + ); + } + #[cfg(feature = "bindings")] + #[test] + fn test_junit_conversion_paths() { + use crate::repo::RepoUrlParts; + + let mut junit_parser = JunitParser::new(); + let file_contents = r#" + + + + + + but was: ]]> + + + + + + "#; + let parsed_results = junit_parser.parse(BufReader::new(file_contents.as_bytes())); + assert!(parsed_results.is_ok()); + + // Get test case runs from parser + let test_case_runs = junit_parser.into_test_case_runs( + None, + &String::from(""), + &RepoUrlParts { + host: "".into(), + owner: "".into(), + name: "".into(), + }, + &[], + "", + ); + assert_eq!(test_case_runs.len(), 2); + + // Convert test case runs to bindings + let bindings_from_runs: Vec = + test_case_runs.into_iter().map(|run| run.into()).collect(); + + // Get reports and convert directly to bindings + let mut junit_parser = JunitParser::new(); + junit_parser + .parse(BufReader::new(file_contents.as_bytes())) + .unwrap(); + let reports = junit_parser.into_reports(); + assert_eq!(reports.len(), 1); + + let bindings_from_reports: Vec = reports[0] + .test_suites + .iter() + .flat_map(|suite| suite.test_cases.iter().map(|case| case.clone().into())) + .collect(); + + // Compare the two conversion paths + assert_eq!(bindings_from_runs.len(), bindings_from_reports.len()); + + for (run_binding, report_binding) in + bindings_from_runs.iter().zip(bindings_from_reports.iter()) + { + assert_eq!(run_binding.classname, report_binding.classname); + assert_eq!(run_binding.status.status, report_binding.status.status); + assert_eq!(run_binding.timestamp, report_binding.timestamp); + assert_eq!( + run_binding.timestamp_micros, + report_binding.timestamp_micros + ); + assert_eq!(run_binding.time, report_binding.time); + assert_eq!(run_binding.system_out, report_binding.system_out); + assert_eq!(run_binding.system_err, report_binding.system_err); + if run_binding.status.status == BindingsTestCaseStatusStatus::NonSuccess { + assert_eq!( + run_binding.status.non_success.as_ref().unwrap().description, + Some("Expected: but was: ".into()) + ); + assert_eq!( + run_binding.status.non_success.as_ref().unwrap().message, + Some("Test failed".into()) + ); + } + // check that the properties match + for property in run_binding.properties.iter() { + if let Some(report_property) = report_binding + .properties + .iter() + .find(|p| p.name == property.name) + { + assert_eq!(property.value, report_property.value); + } else { + panic!("Property {} not found in report binding", property.name); + } + } + assert_eq!( + run_binding.extra().get("file"), + report_binding.extra().get("file") + ); + } + } + + #[cfg(feature = "bindings")] + #[test] + fn test_validate_preserves_codeowners_in_valid_test_suites() { + use prost_wkt_types::Timestamp; + + use crate::junit::validator::validate; + let test_started_at = Timestamp { + seconds: chrono::Utc::now().timestamp(), + nanos: 0, + }; + let test_finished_at = Timestamp { + seconds: test_started_at.seconds + 1, + nanos: 0, + }; + let codeowner1 = CodeOwner { + name: "@user1".into(), + }; + let codeowner2 = CodeOwner { + name: "@user2".into(), + }; + let test1 = TestCaseRun { + id: "test_id1".into(), + name: "test_name1".into(), + classname: "test_classname".into(), + file: "test_file1.java".into(), + parent_name: "test_parent_name1".into(), + // trunk-ignore(clippy/deprecated) + line: 1, + status: TestCaseRunStatus::Success.into(), + // trunk-ignore(clippy/deprecated) + attempt_number: 1, + started_at: Some(test_started_at.clone()), + finished_at: Some(test_finished_at.clone()), + // trunk-ignore(clippy/deprecated) + status_output_message: "".into(), + codeowners: vec![codeowner1.clone()], + test_output: None, + ..Default::default() + }; + + let test2 = TestCaseRun { + id: "test_id2".into(), + name: "test_name2".into(), + classname: "test_classname".into(), + file: "test_file2.java".into(), + parent_name: "test_parent_name1".into(), + // trunk-ignore(clippy/deprecated) + line: 2, + status: TestCaseRunStatus::Success.into(), + // trunk-ignore(clippy/deprecated) + attempt_number: 1, + started_at: Some(test_started_at.clone()), + finished_at: Some(test_finished_at.clone()), + // trunk-ignore(clippy/deprecated) + status_output_message: "".into(), + codeowners: vec![codeowner2.clone()], + test_output: None, + ..Default::default() + }; + + let mut test_result = TestResult::default(); + test_result.test_case_runs.push(test1.clone()); + test_result.test_case_runs.push(test2.clone()); + + let converted_bindings: BindingsReport = test_result.into(); + + // Verify codeowners are present in the original report + assert_eq!(converted_bindings.test_suites.len(), 1); + let original_test_suite = &converted_bindings.test_suites[0]; + assert_eq!(original_test_suite.test_cases.len(), 2); + assert_eq!( + original_test_suite.test_cases[0].codeowners, + Some(vec!["@user1".to_string()]) + ); + assert_eq!( + original_test_suite.test_cases[1].codeowners, + Some(vec!["@user2".to_string()]) + ); + + // Validate the report + let validation_result = validate( + &converted_bindings, + &None, + chrono::Utc::now().fixed_offset(), + ); + + // Verify that valid_test_suites preserves codeowners + assert_eq!(validation_result.valid_test_suites.len(), 1); + let valid_test_suite = &validation_result.valid_test_suites[0]; + assert_eq!(valid_test_suite.test_cases.len(), 2); + + // Find test cases by name to match them up + let valid_test_case1 = valid_test_suite + .test_cases + .iter() + .find(|tc| tc.name == "test_name1") + .expect("test_name1 should be in valid_test_suites"); + let valid_test_case2 = valid_test_suite + .test_cases + .iter() + .find(|tc| tc.name == "test_name2") + .expect("test_name2 should be in valid_test_suites"); + + // Verify codeowners are preserved + assert_eq!( + valid_test_case1.codeowners, + Some(vec!["@user1".to_string()]), + "codeowners for test_name1 should be preserved" + ); + assert_eq!( + valid_test_case2.codeowners, + Some(vec!["@user2".to_string()]), + "codeowners for test_name2 should be preserved" + ); + } +} diff --git a/context/src/junit/bindings/suite.rs b/context/src/junit/bindings/suite.rs new file mode 100644 index 00000000..9549c793 --- /dev/null +++ b/context/src/junit/bindings/suite.rs @@ -0,0 +1,184 @@ +use std::{collections::HashMap, time::Duration}; + +use chrono::{DateTime, TimeDelta}; +#[cfg(feature = "pyo3")] +use pyo3::prelude::*; +#[cfg(feature = "pyo3")] +use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods}; +use quick_junit::TestSuite; +#[cfg(feature = "wasm")] +use wasm_bindgen::prelude::*; + +use crate::junit::bindings::test_case::{BindingsProperty, BindingsTestCase}; +use crate::junit::parser::extra_attrs; + +#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] +#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] +#[derive(Clone, Debug)] +pub struct BindingsTestSuite { + pub name: String, + pub tests: usize, + pub disabled: usize, + pub errors: usize, + pub failures: usize, + pub timestamp: Option, + pub timestamp_micros: Option, + pub time: Option, + pub test_cases: Vec, + pub properties: Vec, + pub system_out: Option, + pub system_err: Option, + pub(crate) extra: HashMap, +} + +#[cfg(feature = "pyo3")] +#[gen_stub_pymethods] +#[pymethods] +impl BindingsTestSuite { + fn py_extra(&self) -> HashMap { + self.extra.clone() + } +} + +impl BindingsTestSuite { + pub fn extra(&self) -> HashMap { + self.extra.clone() + } +} + +#[cfg(feature = "wasm")] +#[wasm_bindgen] +impl BindingsTestSuite { + pub fn js_extra(&self) -> Result { + let entries = self + .extra + .iter() + .fold(js_sys::Array::new(), |acc, (key, value)| { + let entry = js_sys::Array::new(); + entry.push(&js_sys::JsString::from(key.as_str())); + entry.push(&js_sys::JsString::from(value.as_str())); + acc.push(&entry); + acc + }); + js_sys::Object::from_entries(&entries) + } +} + +impl From for BindingsTestSuite { + fn from( + TestSuite { + name, + tests, + disabled, + errors, + failures, + timestamp, + time, + test_cases, + properties, + system_out, + system_err, + extra, + // NOTE: The above should be all fields, but here may be more added in the future due to + // `#[non_exhaustive]` + .. + }: TestSuite, + ) -> Self { + let file = extra.get(extra_attrs::FILE); + let filepath = extra.get(extra_attrs::FILEPATH); + let test_cases = test_cases + .into_iter() + .map(|mut tc| { + if let Some(file) = file { + tc.extra.insert(extra_attrs::FILE.into(), file.clone()); + } + if let Some(filepath) = filepath { + tc.extra + .insert(extra_attrs::FILEPATH.into(), filepath.clone()); + } + BindingsTestCase::from(tc) + }) + .collect(); + Self { + name: name.into_string(), + tests, + disabled, + errors, + failures, + timestamp: timestamp.map(|t| t.timestamp()), + timestamp_micros: timestamp.map(|t| t.timestamp_micros()), + time: time.map(|t| t.as_secs_f64()), + test_cases, + properties: properties.into_iter().map(BindingsProperty::from).collect(), + system_out: system_out.map(|s| s.to_string()), + system_err: system_err.map(|s| s.to_string()), + extra: HashMap::from_iter( + extra + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())), + ), + } + } +} + +impl From for TestSuite { + fn from(val: BindingsTestSuite) -> Self { + let BindingsTestSuite { + name, + tests, + disabled, + errors, + failures, + timestamp: _, + timestamp_micros, + time, + test_cases, + properties, + system_out, + system_err, + extra, + } = val; + let mut test_suite = TestSuite::new(name); + test_suite.tests = tests; + test_suite.disabled = disabled; + test_suite.errors = errors; + test_suite.failures = failures; + test_suite.timestamp = timestamp_micros + .and_then(|micro_secs| { + let micros_delta = TimeDelta::microseconds(micro_secs); + DateTime::from_timestamp( + micros_delta.num_seconds(), + micros_delta.subsec_nanos() as u32, + ) + }) + .map(|dt| dt.fixed_offset()); + test_suite.time = time.map(Duration::from_secs_f64); + let file = test_suite.extra.get(extra_attrs::FILE); + let filepath = test_suite.extra.get(extra_attrs::FILEPATH); + test_suite.test_cases = test_cases + .into_iter() + .map(|mut tc| { + if let Some(file) = file { + tc.extra.insert(extra_attrs::FILE.into(), file.to_string()); + } + if let Some(filepath) = filepath { + tc.extra + .insert(extra_attrs::FILEPATH.into(), filepath.to_string()); + } + BindingsTestCase::try_into(tc) + }) + .filter_map(|t| { + // Removes any invalid test cases that could not be parsed correctly + t.ok() + }) + .collect(); + test_suite.properties = properties.into_iter().map(BindingsProperty::into).collect(); + test_suite.system_out = system_out.map(|s| s.into()); + test_suite.system_err = system_err.map(|s| s.into()); + test_suite.extra = extra + .into_iter() + .map(|(k, v)| (k.into(), v.into())) + .collect(); + test_suite + } +} diff --git a/context/src/junit/bindings/test_case.rs b/context/src/junit/bindings/test_case.rs new file mode 100644 index 00000000..ea3173b9 --- /dev/null +++ b/context/src/junit/bindings/test_case.rs @@ -0,0 +1,644 @@ +use std::{collections::HashMap, time::Duration}; + +use chrono::{DateTime, TimeDelta}; +use proto::test_context::test_run::{TestCaseRun, TestCaseRunStatus}; +#[cfg(feature = "pyo3")] +use pyo3::prelude::*; +#[cfg(feature = "pyo3")] +use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pyclass_enum, gen_stub_pymethods}; +use quick_junit::{NonSuccessKind, Property, TestCase, TestCaseStatus, TestRerun}; +#[cfg(feature = "wasm")] +use wasm_bindgen::prelude::*; + +fn non_empty_option(s: Option<&str>) -> Option { + s.filter(|s| !s.is_empty()).map(|s| s.to_string()) +} + +struct TimestampWrapper { + datetime: chrono::DateTime, + timestamp: i64, + timestamp_micros: i64, +} + +impl From for TimestampWrapper { + fn from(value: prost_wkt_types::Timestamp) -> Self { + let datetime = chrono::DateTime::from(value.clone()); + TimestampWrapper { + datetime, + timestamp: datetime.timestamp(), + timestamp_micros: datetime.timestamp_micros(), + } + } +} + +impl From for BindingsTestCase { + fn from( + TestCaseRun { + name, + parent_name, + classname, + started_at, + finished_at, + status, + // trunk-ignore(clippy/deprecated) + status_output_message, + id, + file, + // trunk-ignore(clippy/deprecated) + line, + // trunk-ignore(clippy/deprecated) + attempt_number, + is_quarantined, + codeowners, + attempt_index, + line_number, + test_output, + test_runner_information, + }: TestCaseRun, + ) -> Self { + let started_at = started_at.unwrap_or_default(); + let started_at_wrapper = TimestampWrapper::from(started_at); + let time = (chrono::DateTime::from(finished_at.unwrap_or_default()) + - started_at_wrapper.datetime) + .to_std() + .unwrap_or_default(); + let classname = if classname.is_empty() { + None + } else { + Some(classname) + }; + let typed_status = + TestCaseRunStatus::try_from(status).unwrap_or(TestCaseRunStatus::Unspecified); + + let mut extra = HashMap::from([ + ("id".to_string(), id.to_string()), + ("file".to_string(), file), + ("parent_name".to_string(), parent_name), + ("is_quarantined".to_string(), is_quarantined.to_string()), + ]); + + if let Some(line_number) = &line_number { + extra.insert("line".to_string(), line_number.number.to_string()); + } else if line != 0 { + // Handle deprecated field + extra.insert("line".to_string(), line.to_string()); + } + + if let Some(attempt_index) = &attempt_index { + extra.insert( + "attempt_number".to_string(), + attempt_index.number.to_string(), + ); + } else if attempt_number != 0 { + // Handle deprecated field + extra.insert("attempt_number".to_string(), attempt_number.to_string()); + } + + Self { + name, + classname, + codeowners: Some(codeowners.iter().map(|c| c.name.to_owned()).collect()), + assertions: None, + timestamp: Some(started_at_wrapper.timestamp), + timestamp_micros: Some(started_at_wrapper.timestamp_micros), + time: Some(time.as_secs_f64()), + status: BindingsTestCaseStatus { + status: typed_status.into(), + success: { + if typed_status == TestCaseRunStatus::Success { + Some(BindingsTestCaseStatusSuccess { flaky_runs: vec![] }) + } else { + None + } + }, + non_success: { + if typed_status == TestCaseRunStatus::Failure { + Some(BindingsTestCaseStatusNonSuccess { + kind: BindingsNonSuccessKind::Failure, + message: non_empty_option( + test_output + .as_ref() + .map(|fi| fi.message.as_str()) + .or(Some(status_output_message.as_str())), + ), + ty: None, + description: non_empty_option( + test_output.as_ref().map(|fi| fi.text.as_str()), + ), + reruns: vec![], + }) + } else { + None + } + }, + skipped: { + if typed_status == TestCaseRunStatus::Skipped { + Some(BindingsTestCaseStatusSkipped { + message: non_empty_option( + test_output + .as_ref() + .map(|fi| fi.message.as_str()) + .or(Some(status_output_message.as_str())), + ), + ty: None, + description: non_empty_option( + test_output.as_ref().map(|fi| fi.text.as_str()), + ), + }) + } else { + None + } + }, + }, + system_err: non_empty_option(test_output.as_ref().map(|fi| fi.system_err.as_str())), + system_out: non_empty_option(test_output.as_ref().map(|fi| fi.system_out.as_str())), + extra, + properties: vec![], + bazel_run_information: match test_runner_information { + Some(proto::test_context::test_run::test_case_run::TestRunnerInformation::BazelRunInformation( + bazel_run_information, + )) => { + let started_at_wrapper = TimestampWrapper::from(bazel_run_information.started_at.unwrap_or_default()); + let finished_at_wrapper = TimestampWrapper::from(bazel_run_information.finished_at.unwrap_or_default()); + + Some(BindingsBazelRunInformation { + label: bazel_run_information.label, + attempt_number: bazel_run_information.attempt_number, + started_at: Some(started_at_wrapper.timestamp), + started_at_micros: Some(started_at_wrapper.timestamp_micros), + finished_at: Some(finished_at_wrapper.timestamp), + finished_at_micros: Some(finished_at_wrapper.timestamp_micros), + }) + }, + _ => None, + }, + } + } +} + +#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] +#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] +#[derive(Clone, Debug)] +pub struct BindingsProperty { + pub name: String, + pub value: String, +} + +impl From for BindingsProperty { + fn from(Property { name, value }: Property) -> Self { + Self { + name: name.to_string(), + value: value.to_string(), + } + } +} + +impl From for Property { + fn from(val: BindingsProperty) -> Self { + let BindingsProperty { name, value } = val; + Property { + name: name.into(), + value: value.into(), + } + } +} + +#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] +#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] +#[derive(Clone, Debug)] +pub struct BindingsBazelRunInformation { + pub label: String, + pub attempt_number: i32, + pub started_at: Option, + pub started_at_micros: Option, + pub finished_at: Option, + pub finished_at_micros: Option, +} + +#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] +#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] +#[derive(Clone, Debug)] +pub struct BindingsTestCase { + pub name: String, + pub classname: Option, + pub assertions: Option, + pub timestamp: Option, + pub timestamp_micros: Option, + pub time: Option, + pub status: BindingsTestCaseStatus, + pub system_out: Option, + pub system_err: Option, + pub codeowners: Option>, + pub(crate) extra: HashMap, + pub properties: Vec, + pub bazel_run_information: Option, +} + +#[cfg(feature = "pyo3")] +#[gen_stub_pymethods] +#[pymethods] +impl BindingsTestCase { + fn py_extra(&self) -> HashMap { + self.extra.clone() + } +} + +#[cfg(feature = "wasm")] +#[wasm_bindgen] +impl BindingsTestCase { + pub fn js_extra(&self) -> Result { + let entries = self + .extra + .iter() + .fold(js_sys::Array::new(), |acc, (key, value)| { + let entry = js_sys::Array::new(); + entry.push(&js_sys::JsString::from(key.as_str())); + entry.push(&js_sys::JsString::from(value.as_str())); + acc.push(&entry); + acc + }); + js_sys::Object::from_entries(&entries) + } +} + +impl BindingsTestCase { + pub fn extra(&self) -> HashMap { + self.extra.clone() + } + + pub fn is_quarantined(&self) -> bool { + self.extra + .get("is_quarantined") + .is_some_and(|v| v == "true") + } +} + +impl From for BindingsTestCase { + fn from( + TestCase { + name, + classname, + assertions, + timestamp, + time, + status, + system_out, + system_err, + extra, + properties, + // NOTE: The above should be all fields, but here may be more added in the future due to + // `#[non_exhaustive]` + .. + }: TestCase, + ) -> Self { + Self { + name: name.into_string(), + classname: classname.map(|c| c.to_string()), + assertions, + timestamp: timestamp.map(|t| t.timestamp()), + timestamp_micros: timestamp.map(|t| t.timestamp_micros()), + time: time.map(|t| t.as_secs_f64()), + status: BindingsTestCaseStatus::from(status), + system_out: system_out.map(|s| s.to_string()), + system_err: system_err.map(|s| s.to_string()), + extra: HashMap::from_iter( + extra + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())), + ), + properties: properties.into_iter().map(BindingsProperty::from).collect(), + codeowners: None, + bazel_run_information: None, + } + } +} + +impl TryInto for BindingsTestCase { + type Error = (); + + fn try_into(self) -> Result { + let Self { + name, + classname, + assertions, + codeowners: _, + timestamp: _, + timestamp_micros, + time, + status, + system_out, + system_err, + extra, + properties, + bazel_run_information: _, + } = self; + // donotland: anything here? + let mut test_case = TestCase::new(name, status.try_into()?); + test_case.classname = classname.map(|c| c.into()); + test_case.assertions = assertions; + test_case.timestamp = timestamp_micros + .and_then(|micro_secs| { + let micros_delta = TimeDelta::microseconds(micro_secs); + DateTime::from_timestamp( + micros_delta.num_seconds(), + micros_delta.subsec_nanos() as u32, + ) + }) + .map(|dt| dt.fixed_offset()); + test_case.time = time.map(Duration::from_secs_f64); + test_case.system_out = system_out.map(|s| s.into()); + test_case.system_err = system_err.map(|s| s.into()); + test_case.extra = extra + .into_iter() + .map(|(k, v)| (k.into(), v.into())) + .collect(); + test_case.properties = properties.into_iter().map(BindingsProperty::into).collect(); + Ok(test_case) + } +} + +#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] +#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] +#[derive(Clone, Debug)] +pub struct BindingsTestCaseStatus { + pub status: BindingsTestCaseStatusStatus, + pub success: Option, + pub non_success: Option, + pub skipped: Option, +} + +impl From for BindingsTestCaseStatusStatus { + fn from(value: TestCaseRunStatus) -> Self { + match value { + TestCaseRunStatus::Success => BindingsTestCaseStatusStatus::Success, + TestCaseRunStatus::Failure => BindingsTestCaseStatusStatus::NonSuccess, + TestCaseRunStatus::Skipped => BindingsTestCaseStatusStatus::Skipped, + TestCaseRunStatus::Unspecified => BindingsTestCaseStatusStatus::Unspecified, + } + } +} + +impl From for BindingsTestCaseStatus { + fn from(value: TestCaseStatus) -> Self { + match value { + TestCaseStatus::Success { flaky_runs } => Self { + status: BindingsTestCaseStatusStatus::Success, + success: Some(BindingsTestCaseStatusSuccess { + flaky_runs: flaky_runs + .into_iter() + .map(BindingsTestRerun::from) + .collect(), + }), + non_success: None, + skipped: None, + }, + TestCaseStatus::NonSuccess { + kind, + message, + ty, + description, + reruns, + } => Self { + status: BindingsTestCaseStatusStatus::NonSuccess, + success: None, + non_success: Some(BindingsTestCaseStatusNonSuccess { + kind: BindingsNonSuccessKind::from(kind), + message: message.map(|m| m.into_string()), + ty: ty.map(|t| t.into_string()), + description: description.map(|d| d.into_string()), + reruns: reruns.into_iter().map(BindingsTestRerun::from).collect(), + }), + skipped: None, + }, + TestCaseStatus::Skipped { + message, + ty, + description, + } => Self { + status: BindingsTestCaseStatusStatus::Skipped, + success: None, + non_success: None, + skipped: Some(BindingsTestCaseStatusSkipped { + message: message.map(|m| m.into_string()), + ty: ty.map(|t| t.into_string()), + description: description.map(|d| d.into_string()), + }), + }, + } + } +} + +impl TryInto for BindingsTestCaseStatus { + type Error = (); + + fn try_into(self) -> Result { + let Self { + status, + success, + non_success, + skipped, + } = self; + match (status, success, non_success, skipped) { + (BindingsTestCaseStatusStatus::Success, Some(success_fields), None, None) => { + Ok(success_fields.into()) + } + (BindingsTestCaseStatusStatus::NonSuccess, None, Some(non_success_fields), None) => { + Ok(non_success_fields.into()) + } + (BindingsTestCaseStatusStatus::Skipped, None, None, Some(skipped_fields)) => { + Ok(skipped_fields.into()) + } + _ => Err(()), + } + } +} + +#[cfg_attr(feature = "pyo3", gen_stub_pyclass_enum, pyclass(eq, eq_int))] +#[cfg_attr(feature = "wasm", wasm_bindgen)] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum BindingsTestCaseStatusStatus { + Success, + NonSuccess, + Skipped, + Unspecified, +} + +#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] +#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] +#[derive(Clone, Debug)] +pub struct BindingsTestCaseStatusSuccess { + pub flaky_runs: Vec, +} + +impl From for TestCaseStatus { + fn from(val: BindingsTestCaseStatusSuccess) -> Self { + let BindingsTestCaseStatusSuccess { flaky_runs } = val; + TestCaseStatus::Success { + flaky_runs: flaky_runs + .into_iter() + .map(BindingsTestRerun::into) + .collect(), + } + } +} + +#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] +#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] +#[derive(Clone, Debug)] +pub struct BindingsTestCaseStatusNonSuccess { + pub kind: BindingsNonSuccessKind, + pub message: Option, + pub ty: Option, + pub description: Option, + pub reruns: Vec, +} + +impl From for TestCaseStatus { + fn from(val: BindingsTestCaseStatusNonSuccess) -> Self { + let BindingsTestCaseStatusNonSuccess { + kind, + message, + ty, + description, + reruns, + } = val; + TestCaseStatus::NonSuccess { + kind: kind.into(), + message: message.map(|m| m.into()), + ty: ty.map(|t| t.into()), + description: description.map(|d| d.into()), + reruns: reruns.into_iter().map(BindingsTestRerun::into).collect(), + } + } +} + +#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] +#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] +#[derive(Clone, Debug)] +pub struct BindingsTestCaseStatusSkipped { + pub message: Option, + pub ty: Option, + pub description: Option, +} + +impl From for TestCaseStatus { + fn from(val: BindingsTestCaseStatusSkipped) -> Self { + let BindingsTestCaseStatusSkipped { + message, + ty, + description, + } = val; + TestCaseStatus::Skipped { + message: message.map(|m| m.into()), + ty: ty.map(|t| t.into()), + description: description.map(|d| d.into()), + } + } +} + +#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] +#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] +#[derive(Clone, Debug)] +pub struct BindingsTestRerun { + pub kind: BindingsNonSuccessKind, + pub timestamp: Option, + pub timestamp_micros: Option, + pub time: Option, + pub message: Option, + pub ty: Option, + pub stack_trace: Option, + pub system_out: Option, + pub system_err: Option, + pub description: Option, +} + +impl From for BindingsTestRerun { + fn from( + TestRerun { + kind, + timestamp, + time, + message, + ty, + stack_trace, + system_out, + system_err, + description, + }: TestRerun, + ) -> Self { + Self { + kind: BindingsNonSuccessKind::from(kind), + timestamp: timestamp.map(|t| t.timestamp()), + timestamp_micros: timestamp.map(|t| t.timestamp_micros()), + time: time.map(|t| t.as_secs_f64()), + message: message.map(|m| m.to_string()), + ty: ty.map(|t| t.to_string()), + stack_trace: stack_trace.map(|st| st.to_string()), + system_out: system_out.map(|s| s.to_string()), + system_err: system_err.map(|s| s.to_string()), + description: description.map(|d| d.to_string()), + } + } +} + +impl From for TestRerun { + fn from(val: BindingsTestRerun) -> Self { + let BindingsTestRerun { + kind, + timestamp: _, + timestamp_micros, + time, + message, + ty, + stack_trace, + system_out, + system_err, + description, + } = val; + TestRerun { + kind: kind.into(), + timestamp: timestamp_micros + .and_then(|micro_secs| { + let micros_delta = TimeDelta::microseconds(micro_secs); + DateTime::from_timestamp( + micros_delta.num_seconds(), + micros_delta.subsec_nanos() as u32, + ) + }) + .map(|dt| dt.fixed_offset()), + time: time.map(Duration::from_secs_f64), + message: message.map(|m| m.into()), + ty: ty.map(|t| t.into()), + stack_trace: stack_trace.map(|st| st.into()), + system_out: system_out.map(|s| s.into()), + system_err: system_err.map(|s| s.into()), + description: description.map(|d| d.into()), + } + } +} + +#[cfg_attr(feature = "pyo3", gen_stub_pyclass_enum, pyclass(eq, eq_int))] +#[cfg_attr(feature = "wasm", wasm_bindgen)] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum BindingsNonSuccessKind { + Failure, + Error, +} + +impl From for BindingsNonSuccessKind { + fn from(value: NonSuccessKind) -> Self { + match value { + NonSuccessKind::Failure => BindingsNonSuccessKind::Failure, + NonSuccessKind::Error => BindingsNonSuccessKind::Error, + } + } +} + +impl From for NonSuccessKind { + fn from(val: BindingsNonSuccessKind) -> Self { + match val { + BindingsNonSuccessKind::Failure => NonSuccessKind::Failure, + BindingsNonSuccessKind::Error => NonSuccessKind::Error, + } + } +} diff --git a/context/src/junit/bindings/validation.rs b/context/src/junit/bindings/validation.rs new file mode 100644 index 00000000..1073c420 --- /dev/null +++ b/context/src/junit/bindings/validation.rs @@ -0,0 +1,105 @@ +#[cfg(feature = "pyo3")] +use pyo3::prelude::*; +#[cfg(feature = "pyo3")] +use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods}; +#[cfg(feature = "wasm")] +use wasm_bindgen::prelude::*; + +use crate::junit::validator::TestRunnerReportValidation; +use crate::junit::{ + bindings::suite::BindingsTestSuite, + validator::{ + JunitReportValidation, JunitReportValidationFlatIssue, JunitTestSuiteValidation, + JunitValidationLevel, JunitValidationType, + }, +}; + +#[cfg_attr(feature = "pyo3", gen_stub_pyclass, pyclass(get_all))] +#[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))] +#[derive(Clone, Debug)] +pub struct BindingsJunitReportValidation { + pub(crate) all_issues: Vec, + pub(crate) level: JunitValidationLevel, + pub(crate) test_runner_report: TestRunnerReportValidation, + pub(crate) test_suites: Vec, + pub(crate) valid_test_suites: Vec, +} + +impl From for BindingsJunitReportValidation { + fn from( + JunitReportValidation { + all_issues, + level, + test_suites, + valid_test_suites, + test_runner_report, + }: JunitReportValidation, + ) -> Self { + Self { + all_issues: all_issues + .into_iter() + .map(|i| JunitReportValidationFlatIssue { + level: JunitValidationLevel::from(&i), + error_type: JunitValidationType::from(&i), + error_message: i.to_string(), + }) + .collect(), + level, + test_suites, + valid_test_suites: valid_test_suites.into_iter().collect(), + test_runner_report, + } + } +} + +impl From for JunitReportValidation { + fn from( + BindingsJunitReportValidation { + all_issues: _, + level, + test_runner_report, + test_suites, + valid_test_suites, + }: BindingsJunitReportValidation, + ) -> Self { + let mut validation = Self { + all_issues: Vec::new(), + level, + test_runner_report, + test_suites, + valid_test_suites, + }; + validation.derive_all_issues(); + validation + } +} + +#[cfg_attr(feature = "pyo3", gen_stub_pymethods, pymethods)] +#[cfg_attr(feature = "wasm", wasm_bindgen)] +impl BindingsJunitReportValidation { + pub fn all_issues_owned(&self) -> Vec { + self.all_issues.clone() + } + + pub fn max_level(&self) -> JunitValidationLevel { + self.test_suites + .iter() + .map(|test_suite| test_suite.max_level()) + .max() + .map_or(self.level, |l| l.max(self.level)) + } + + pub fn num_invalid_issues(&self) -> usize { + self.all_issues + .iter() + .filter(|issue| issue.level == JunitValidationLevel::Invalid) + .count() + } + + pub fn num_suboptimal_issues(&self) -> usize { + self.all_issues + .iter() + .filter(|issue| issue.level == JunitValidationLevel::SubOptimal) + .count() + } +} diff --git a/proto/proto/test_context.proto b/proto/proto/test_context.proto index 888b57ee..539e930e 100644 --- a/proto/proto/test_context.proto +++ b/proto/proto/test_context.proto @@ -30,6 +30,13 @@ message TestOutput { string system_err = 4; } +message BazelRunInformation { + string label = 1; // test label for the execution (same test case id may currently have multiple labels) + int32 attempt_number = 2; // attempt number per label, not necessarily "test case" + google.protobuf.Timestamp started_at = 3; // start time of the target execution, may diverge from the JUnit + google.protobuf.Timestamp finished_at = 4; // finish time of the target execution, may diverge from the JUnit +} + message TestCaseRun { string id = 1; string name = 2; @@ -51,6 +58,10 @@ message TestCaseRun { optional AttemptNumber attempt_index = 14; optional LineNumber line_number = 15; optional TestOutput test_output = 16; + oneof test_runner_information { + BazelRunInformation bazel_run_information = 17; + // Other test runner information can be added here in the future + } } message UploaderMetadata { @@ -72,8 +83,8 @@ message BazelBuildInformation { message TestResult { repeated TestCaseRun test_case_runs = 1; - // use the uplaoder metadata in test report instead - UploaderMetadata uploader_metadata = 2 [deprecated = true];; + // use the uploader metadata in test report instead + UploaderMetadata uploader_metadata = 2 [deprecated = true]; oneof test_build_information { BazelBuildInformation bazel_build_information = 3; // Other build information can be added here in the future