From 4cf2c3e91e7e44d61b7aa8fadd1a75b0194cffb5 Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Wed, 2 Oct 2024 09:17:43 -0700 Subject: [PATCH 1/2] 1. improve junit-mock for testing (#107) --- cli/tests/upload.rs | 4 +- junit-mock/src/lib.rs | 133 ++++++++++++++++++++++++++++++++--------- junit-mock/src/main.rs | 4 +- 3 files changed, 108 insertions(+), 33 deletions(-) diff --git a/cli/tests/upload.rs b/cli/tests/upload.rs index 38c49021..a406b0c2 100644 --- a/cli/tests/upload.rs +++ b/cli/tests/upload.rs @@ -4,7 +4,6 @@ use std::path::Path; use assert_cmd::Command; use assert_matches::assert_matches; -use clap::Parser; use junit_mock::JunitMock; use tempfile::tempdir; use test_utils::mock_git_repo::setup_repo_with_commit; @@ -22,8 +21,7 @@ fn generate_mock_git_repo>(directory: T) { } fn generate_mock_junit_xmls>(directory: T) { - let options = junit_mock::Options::try_parse_from(&[""]).unwrap(); - let mut jm = JunitMock::new(options); + let mut jm = JunitMock::new(junit_mock::Options::default()); let reports = jm.generate_reports(); JunitMock::write_reports_to_file(directory.as_ref(), reports).unwrap(); } diff --git a/junit-mock/src/lib.rs b/junit-mock/src/lib.rs index d6925822..d94b6a4b 100644 --- a/junit-mock/src/lib.rs +++ b/junit-mock/src/lib.rs @@ -1,8 +1,9 @@ use std::fs::File; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::Result; +use chrono::{DateTime, FixedOffset}; use clap::Parser; use fake::Fake; use quick_junit::{NonSuccessKind, Report, TestCase, TestCaseStatus, TestRerun, TestSuite}; @@ -51,7 +52,7 @@ macro_rules! percentages_parser { }; } -#[derive(Debug, Parser)] +#[derive(Debug, Parser, Clone)] pub struct Options { #[command(flatten, next_help_heading = "Global Options")] pub global: GlobalOptions, @@ -69,15 +70,30 @@ pub struct Options { pub test_rerun: TestRerunOptions, } -#[derive(Debug, Parser)] +impl Default for Options { + fn default() -> Self { + Options::try_parse_from([""]).unwrap() + } +} + +#[test] +fn options_can_be_defaulted_without_panicing() { + Options::default(); +} + +#[derive(Debug, Parser, Clone)] #[group()] pub struct GlobalOptions { /// Seed for all generated data, defaults to randomly generated seed #[arg(long)] pub seed: Option, + + /// Timestamp for all data to be based on, defaults to now + #[arg(long)] + pub timestamp: Option>, } -#[derive(Debug, Parser)] +#[derive(Debug, Parser, Clone)] #[group()] pub struct ReportOptions { /// A list of report names to generate (conflicts with --report-random-count) @@ -93,7 +109,7 @@ pub struct ReportOptions { pub report_duration_range: Vec, } -#[derive(Debug, Parser)] +#[derive(Debug, Parser, Clone)] #[group()] pub struct TestSuiteOptions { /// A list of test suite names to generate (conflicts with --test-suite-random-count) @@ -119,7 +135,7 @@ pub struct TestSuiteOptions { percentages_parser!(four_percentages_parser, 4); -#[derive(Debug, Parser)] +#[derive(Debug, Parser, Clone)] #[group()] pub struct TestCaseOptions { /// A list of test case names to generate (conflicts with --test-case-random-count, requires --test-case-classnames) @@ -163,7 +179,7 @@ pub struct TestCaseOptions { percentages_parser!(two_percentages_parser, 2); -#[derive(Debug, Parser)] +#[derive(Debug, Parser, Clone)] #[group()] pub struct TestRerunOptions { /// Inclusive range of the number of reruns of a test that was not skipped @@ -173,26 +189,40 @@ pub struct TestRerunOptions { /// The chance of a test rerun failing and erroring (must add up to 100) #[arg(long, value_parser = two_percentages_parser, default_value = "50,50")] pub test_rerun_fail_to_error_percentage: Vec>, + + /// The chance of a system out message being added to the test rerun + #[arg(long, value_parser = clap::value_parser!(u8).range(0..=100), default_value = "50")] + pub test_rerun_sys_out_percentage: u8, + + /// Inclusive range of time between test case timestamps + #[arg(long, num_args = 1..=2, value_names = ["DURATION_RANGE_START", "DURATION_RANGE_END"], default_values = ["30s", "1m"])] + pub test_rerun_duration_range: Vec, + + /// The chance of a system error message being added to the test rerun + #[arg(long, value_parser = clap::value_parser!(u8).range(0..=100), default_value = "50")] + pub test_rerun_sys_err_percentage: u8, } +#[derive(Debug, Clone)] pub struct JunitMock { seed: u64, - rng: StdRng, options: Options, // state for generating reports - timestamp: chrono::DateTime, + rng: StdRng, + timestamp: DateTime, total_duration: Duration, } impl JunitMock { pub fn new(options: Options) -> Self { let (seed, rng) = JunitMock::rng_from_seed(&options); + let timestamp = options.global.timestamp.unwrap_or_default(); Self { seed, - rng, options, - timestamp: chrono::Utc::now(), + rng, + timestamp, total_duration: Duration::new(0, 0), } } @@ -219,7 +249,11 @@ impl JunitMock { } pub fn generate_reports(&mut self) -> Vec { - self.timestamp = chrono::Utc::now(); + self.timestamp = self + .options + .global + .timestamp + .unwrap_or_else(|| chrono::Utc::now().fixed_offset()); self.options .report @@ -253,13 +287,17 @@ impl JunitMock { pub fn write_reports_to_file, U: AsRef<[Report]>>( directory: T, reports: U, - ) -> Result<()> { - for (i, report) in reports.as_ref().iter().enumerate() { - let path = directory.as_ref().join(format!("junit-{}.xml", i)); - let file = File::create(path)?; - report.serialize(file)?; - } - Ok(()) + ) -> Result> { + reports.as_ref().iter().enumerate().try_fold( + Vec::new(), + |mut acc, (i, report)| -> Result> { + let path = directory.as_ref().join(format!("junit-{}.xml", i)); + let file = File::create(&path)?; + report.serialize(file)?; + acc.push(path); + Ok(acc) + }, + ) } fn generate_test_suites(&mut self) -> Vec { @@ -329,14 +367,27 @@ impl JunitMock { .iter() .zip(classnames.iter()) .map(|(test_case_name, test_case_classname)| -> TestCase { - let mut test_case = TestCase::new(test_case_name, self.generate_test_case_status()); + let last_duration = self.total_duration; + let timestamp = self.timestamp; + + let test_case_status = self.generate_test_case_status(); + let is_skipped = matches!(&test_case_status, TestCaseStatus::Skipped { .. }); + + let mut test_case = TestCase::new(test_case_name, test_case_status); + let file: String = + fake::faker::filesystem::en::FilePath().fake_with_rng(&mut self.rng); + test_case.extra.insert("file".into(), file.into()); test_case.set_classname(format!("{test_case_classname}/{test_case_name}")); test_case.set_assertions(self.rng.gen_range(1..10)); - test_case.set_timestamp(self.timestamp); - let duration = - self.fake_duration(self.options.test_case.test_case_duration_range.clone()); - test_case.set_time(duration); + test_case.set_timestamp(timestamp); + let duration = if is_skipped { + Default::default() + } else { + self.fake_duration(self.options.test_case.test_case_duration_range.clone()) + }; + test_case.set_time((self.total_duration + duration) - last_duration); self.increment_duration(duration); + if self.rand_bool(self.options.test_case.test_case_sys_out_percentage) { test_case.set_system_out(self.fake_paragraphs()); } @@ -412,15 +463,39 @@ impl JunitMock { .expect("test rerun failure percentage must be set"); (0..count) .map(|_| { - TestRerun::new(if self.rand_percentage() <= failure_to_error_threshold { - NonSuccessKind::Failure - } else { - NonSuccessKind::Error - }) + let mut test_rerun = + TestRerun::new(if self.rand_percentage() <= failure_to_error_threshold { + NonSuccessKind::Failure + } else { + NonSuccessKind::Error + }); + + test_rerun.set_timestamp(self.timestamp); + let duration = + self.fake_duration(self.options.test_rerun.test_rerun_duration_range.clone()); + test_rerun.set_time(duration); + self.increment_duration(duration); + + test_rerun.set_message(self.fake_sentence()); + if self.rand_bool(self.options.test_rerun.test_rerun_sys_out_percentage) { + test_rerun.set_system_out(self.fake_paragraphs()); + } + if self.rand_bool(self.options.test_rerun.test_rerun_sys_err_percentage) { + test_rerun.set_system_err(self.fake_paragraphs()); + } + test_rerun.set_description(self.fake_sentence()); + + test_rerun }) .collect() } + fn fake_sentence(&mut self) -> String { + let paragraphs: Vec = + fake::faker::lorem::en::Sentences(1..2).fake_with_rng(&mut self.rng); + paragraphs.join(" ") + } + fn fake_paragraphs(&mut self) -> String { let paragraphs: Vec = fake::faker::lorem::en::Paragraphs(1..3).fake_with_rng(&mut self.rng); diff --git a/junit-mock/src/main.rs b/junit-mock/src/main.rs index 05ee5966..f2eef87c 100644 --- a/junit-mock/src/main.rs +++ b/junit-mock/src/main.rs @@ -22,5 +22,7 @@ fn main() -> Result<()> { let reports = jm.generate_reports(); - JunitMock::write_reports_to_file(directory, &reports) + JunitMock::write_reports_to_file(directory, &reports)?; + + Ok(()) } From 936e7671b6cf6238e8593c01737a05cfbc468525 Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Wed, 2 Oct 2024 09:50:33 -0700 Subject: [PATCH 2/2] 2. add context crate for junit, env parsing and validation (#108) --- Cargo.lock | 50 ++- Cargo.toml | 2 +- context/Cargo.toml | 16 + context/src/env/mod.rs | 6 + context/src/env/parser.rs | 398 +++++++++++++++++++++ context/src/env/validator.rs | 274 ++++++++++++++ context/src/junit/date_parser.rs | 85 +++++ context/src/junit/mod.rs | 3 + context/src/junit/parser.rs | 588 +++++++++++++++++++++++++++++++ context/src/junit/validator.rs | 307 ++++++++++++++++ context/src/lib.rs | 36 ++ context/tests/env.rs | 218 ++++++++++++ context/tests/junit.rs | 308 ++++++++++++++++ 13 files changed, 2288 insertions(+), 3 deletions(-) create mode 100644 context/Cargo.toml create mode 100644 context/src/env/mod.rs create mode 100644 context/src/env/parser.rs create mode 100644 context/src/env/validator.rs create mode 100644 context/src/junit/date_parser.rs create mode 100644 context/src/junit/mod.rs create mode 100644 context/src/junit/parser.rs create mode 100644 context/src/junit/validator.rs create mode 100644 context/src/lib.rs create mode 100644 context/tests/env.rs create mode 100644 context/tests/junit.rs diff --git a/Cargo.lock b/Cargo.lock index 781527dd..80f1e5b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -441,6 +441,20 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "context" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "junit-mock", + "pretty_assertions", + "quick-junit", + "quick-xml", + "speedate", + "thiserror", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1939,9 +1953,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.36.1" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96a05e2e8efddfa51a84ca47cec303fac86c8541b686d37cac5efc0e094417bc" +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" dependencies = [ "memchr", ] @@ -2502,6 +2516,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "speedate" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08a20480dbd4c693f0b0f3210f2cee5bfa21a176c1fa4df0e65cc0474e7fa557" +dependencies = [ + "strum", + "strum_macros", +] + [[package]] name = "spin" version = "0.9.8" @@ -2523,6 +2547,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.77", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/Cargo.toml b/Cargo.toml index 5e7bc86c..d2beb673 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["cli", "codeowners", "junit-mock"] +members = ["cli", "codeowners", "junit-mock", "context"] resolver = "2" [profile.release] diff --git a/context/Cargo.toml b/context/Cargo.toml new file mode 100644 index 00000000..88cbbaf3 --- /dev/null +++ b/context/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "context" +version = "0.1.0" +edition = "2021" + +[dev-dependencies] +pretty_assertions = "0.6" +junit-mock = { path = "../junit-mock" } + +[dependencies] +anyhow = "1.0.44" +chrono = "0.4.33" +quick-junit = "0.5.0" +quick-xml = "0.36.2" +speedate = "0.14.4" +thiserror = "1.0.63" diff --git a/context/src/env/mod.rs b/context/src/env/mod.rs new file mode 100644 index 00000000..33688cf1 --- /dev/null +++ b/context/src/env/mod.rs @@ -0,0 +1,6 @@ +use std::collections::HashMap; + +pub mod parser; +pub mod validator; + +pub type EnvVars = HashMap; diff --git a/context/src/env/parser.rs b/context/src/env/parser.rs new file mode 100644 index 00000000..bd199ca9 --- /dev/null +++ b/context/src/env/parser.rs @@ -0,0 +1,398 @@ +use thiserror::Error; + +use crate::safe_truncate_string; + +use super::EnvVars; + +#[derive(Error, Debug, Copy, Clone, PartialEq, Eq)] +pub enum EnvParseError { + #[error("no env vars passed")] + EnvVarsEmpty, + #[error("could not parse CI platform from env vars")] + CIPlatform, +} + +// TODO(TRUNK-12908): Switch to using a crate for parsing the CI platform and related env vars +mod ci_platform_env_key { + /// https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#default-environment-variables + pub const GITHUB_ACTIONS: &str = "GITHUB_ACTIONS"; + /// https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables + pub const JENKINS_PIPELINE: &str = "BUILD_ID"; + /// https://circleci.com/docs/variables/#built-in-environment-variables + pub const CIRCLECI: &str = "CIRCLECI"; + /// https://buildkite.com/docs/pipelines/environment-variables#buildkite-environment-variables + pub const BUILDKITE: &str = "BUILDKITE"; + /// https://docs.semaphoreci.com/ci-cd-environment/environment-variables/#semaphore + pub const SEMAPHORE: &str = "SEMAPHORE"; + /// https://docs.travis-ci.com/user/environment-variables/#default-environment-variables + pub const TRAVIS_CI: &str = "TRAVIS"; + /// https://docs.webapp.io/layerfile-reference/build-env#webappio + pub const WEBAPPIO: &str = "WEBAPPIO"; + /// https://docs.aws.amazon.com/codebuild/latest/userguide/build-env-ref-env-vars.html + pub const AWS_CODEBUILD: &str = "CODEBUILD_BUILD_ID"; + /// https://support.atlassian.com/bitbucket-cloud/docs/variables-and-secrets/ + pub const BITBUCKET: &str = "BITBUCKET_BUILD_NUMBER"; + /// https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml#system-variables-devops-services + pub const AZURE_PIPELINES: &str = "TF_BUILD"; + /// https://docs.gitlab.com/ee/ci/variables/predefined_variables.html#predefined-variables + pub const GITLAB_CI: &str = "GITLAB_CI"; + /// https://docs.drone.io/pipeline/environment/reference/drone/ + pub const DRONE: &str = "DRONE"; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CIPlatform { + GitHubActions, + JenkinsPipeline, + CircleCI, + Buildkite, + Semaphore, + TravisCI, + Webappio, + AWSCodeBuild, + BitbucketPipelines, + AzurePipelines, + GitLabCI, + Drone, +} + +impl TryFrom<&str> for CIPlatform { + type Error = EnvParseError; + + fn try_from(value: &str) -> Result { + let ci_platform = match value { + ci_platform_env_key::GITHUB_ACTIONS => CIPlatform::GitHubActions, + ci_platform_env_key::JENKINS_PIPELINE => CIPlatform::JenkinsPipeline, + ci_platform_env_key::CIRCLECI => CIPlatform::CircleCI, + ci_platform_env_key::BUILDKITE => CIPlatform::Buildkite, + ci_platform_env_key::SEMAPHORE => CIPlatform::Semaphore, + ci_platform_env_key::TRAVIS_CI => CIPlatform::TravisCI, + ci_platform_env_key::WEBAPPIO => CIPlatform::Webappio, + ci_platform_env_key::AWS_CODEBUILD => CIPlatform::AWSCodeBuild, + ci_platform_env_key::BITBUCKET => CIPlatform::BitbucketPipelines, + ci_platform_env_key::AZURE_PIPELINES => CIPlatform::AzurePipelines, + ci_platform_env_key::GITLAB_CI => CIPlatform::GitLabCI, + ci_platform_env_key::DRONE => CIPlatform::Drone, + _ => return Err(EnvParseError::CIPlatform), + }; + + Ok(ci_platform) + } +} + +impl TryFrom<&EnvVars> for CIPlatform { + type Error = EnvParseError; + + fn try_from(value: &EnvVars) -> Result { + let mut ci_platform = Err(EnvParseError::EnvVarsEmpty); + for (key, ..) in value.iter() { + ci_platform = CIPlatform::try_from(key.as_str()); + if ci_platform.is_ok() { + break; + } + } + ci_platform + } +} + +#[derive(Error, Debug, Copy, Clone, PartialEq, Eq)] +pub enum CIInfoParseError { + #[error("could not parse branch class")] + BranchClass, +} + +const MAX_BRANCH_NAME_SIZE: usize = 1000; + +#[derive(Debug, Clone)] +pub struct CIInfoParser<'a> { + errors: Vec, + ci_info: CIInfo, + env_vars: &'a EnvVars, +} + +impl<'a> CIInfoParser<'a> { + pub fn new(platform: CIPlatform, env_vars: &'a EnvVars) -> Self { + Self { + errors: Vec::new(), + ci_info: CIInfo::new(platform), + env_vars, + } + } + + pub fn ci_info(&self) -> &CIInfo { + &self.ci_info + } + + pub fn info_ci_info(self) -> CIInfo { + self.ci_info + } + + pub fn parse(&mut self) -> anyhow::Result<()> { + match self.ci_info.platform { + CIPlatform::GitHubActions => self.parse_github_actions(), + CIPlatform::JenkinsPipeline => self.parse_jenkins_pipeline(), + CIPlatform::Buildkite => self.parse_buildkite(), + CIPlatform::Semaphore => self.parse_semaphore(), + CIPlatform::GitLabCI => self.parse_gitlab_ci(), + CIPlatform::Drone => self.parse_drone(), + CIPlatform::CircleCI + | CIPlatform::TravisCI + | CIPlatform::Webappio + | CIPlatform::AWSCodeBuild + | CIPlatform::BitbucketPipelines + | CIPlatform::AzurePipelines => { + // TODO(TRUNK-12908): Switch to using a crate for parsing the CI platform and related env vars + // TODO(TRUNK-12909): parse more platforms + } + }; + self.clean_branch(); + self.parse_brach_class(); + Ok(()) + } + + fn clean_branch(&mut self) { + if let Some(branch) = &mut self.ci_info.branch { + let new_branch = branch + .replace("refs/heads/", "") + .replace("refs/", "") + .replace("origin/", ""); + + *branch = String::from(safe_truncate_string::(&new_branch)); + } + } + + fn parse_brach_class(&mut self) { + if let Some(branch) = &self.ci_info.branch { + match BranchClass::try_from(branch.as_str()) { + Ok(branch_class) => { + self.ci_info.branch_class = Some(branch_class); + } + Err(err) => { + self.errors.push(err); + } + } + } + } + + fn parse_github_actions(&mut self) { + if let Some(branch) = self.get_env_var("GITHUB_REF") { + if branch.starts_with("refs/pull/") { + self.ci_info.pr_number = Self::parse_pr_number(branch.splitn(3, "/").last()); + } + self.ci_info.branch = Some(branch); + } + self.ci_info.actor = self.get_env_var("GITHUB_ACTOR"); + if let (Some(repo_name), Some(run_id)) = ( + self.get_env_var("GITHUB_REPOSITORY"), + self.get_env_var("GITHUB_RUN_ID"), + ) { + let mut job_url = format!("https://github.com/{repo_name}/actions/runs/{run_id}"); + if let Some(pr_number) = self.ci_info.pr_number { + job_url = format!("{job_url}?pr={pr_number}"); + } + self.ci_info.job_url = Some(job_url); + } + } + + fn parse_jenkins_pipeline(&mut self) { + self.ci_info.job_url = self.get_env_var("BUILD_URL"); + self.ci_info.branch = self + .get_env_var("CHANGE_BRANCH") + .or_else(|| self.get_env_var("BRANCH_NAME")); + self.ci_info.pr_number = Self::parse_pr_number(self.get_env_var("CHANGE_ID")); + self.ci_info.actor = self.get_env_var("CHANGE_AUTHOR_EMAIL"); + self.ci_info.committer_name = self.get_env_var("CHANGE_AUTHOR_DISPLAY_NAME"); + self.ci_info.committer_email = self.get_env_var("CHANGE_AUTHOR_EMAIL"); + self.ci_info.author_name = self.get_env_var("CHANGE_AUTHOR_DISPLAY_NAME"); + self.ci_info.author_email = self.get_env_var("CHANGE_AUTHOR_EMAIL"); + } + + fn parse_buildkite(&mut self) { + self.ci_info.job_url = self.get_env_var("BUILDKITE_BUILD_URL"); + self.ci_info.branch = self.get_env_var("BUILDKITE_BRANCH"); + self.ci_info.pr_number = Self::parse_pr_number(self.get_env_var("BUILDKITE_PULL_REQUEST")); + self.ci_info.actor = self.get_env_var("BUILDKITE_BUILD_AUTHOR_EMAIL"); + self.ci_info.committer_name = self.get_env_var("BUILDKITE_BUILD_AUTHOR"); + self.ci_info.committer_email = self.get_env_var("BUILDKITE_BUILD_AUTHOR_EMAIL"); + self.ci_info.author_name = self.get_env_var("BUILDKITE_BUILD_AUTHOR"); + self.ci_info.author_email = self.get_env_var("BUILDKITE_BUILD_AUTHOR_EMAIL"); + } + + fn parse_semaphore(&mut self) { + if let (Some(org_url), Some(project_id), Some(job_id)) = ( + self.get_env_var("SEMAPHORE_ORGANIZATION_URL"), + self.get_env_var("SEMAPHORE_PROJECT_ID"), + self.get_env_var("SEMAPHORE_JOB_ID"), + ) { + self.ci_info.job_url = Some(format!("{org_url}/projects/{project_id}/jobs/{job_id}")); + } + self.ci_info.branch = self + .get_env_var("SEMAPHORE_GIT_PR_BRANCH") + .or_else(|| self.get_env_var("SEMAPHORE_GIT_BRANCH")); + self.ci_info.pr_number = Self::parse_pr_number(self.get_env_var("SEMAPHORE_GIT_PR_NUMBER")); + self.ci_info.actor = self.get_env_var("SEMAPHORE_GIT_COMMIT_AUTHOR"); + self.ci_info.committer_name = self.get_env_var("SEMAPHORE_GIT_COMMITTER"); + self.ci_info.author_name = self.get_env_var("SEMAPHORE_GIT_COMMIT_AUTHOR"); + } + + fn parse_gitlab_ci(&mut self) { + self.ci_info.job_url = self.get_env_var("CI_JOB_URL"); + if let Some(branch) = self + .get_env_var("CI_COMMIT_REF_NAME") + .or_else(|| self.get_env_var("CI_COMMIT_BRANCH")) + .or_else(|| self.get_env_var("CI_MERGE_REQUEST_SOURCE_BRANCH_NAME")) + { + self.ci_info.branch = Some(if branch.starts_with("remotes/") { + branch.replacen("remotes/", "", 1) + } else { + branch + }); + } + self.ci_info.pr_number = Self::parse_pr_number(self.get_env_var("CI_MERGE_REQUEST_IID")); + // `CI_COMMIT_AUTHOR` has format `Name ` + // https://docs.gitlab.com/ee/ci/variables/predefined_variables.html + if let Some((name, email)) = self + .get_env_var("CI_COMMIT_AUTHOR") + .as_ref() + .and_then(|author| author.split_once('<')) + .map(|(name_with_space, email_with_end_angle_bracket)| { + ( + String::from(name_with_space.trim()), + email_with_end_angle_bracket.replace('>', ""), + ) + }) + { + self.ci_info.actor = Some(name.clone()); + self.ci_info.committer_name = Some(name.clone()); + self.ci_info.committer_email = Some(email.clone()); + self.ci_info.author_name = Some(name); + self.ci_info.author_email = Some(email); + } + self.ci_info.commit_message = self.get_env_var("CI_COMMIT_MESSAGE"); + self.ci_info.title = self.get_env_var("CI_MERGE_REQUEST_TITLE"); + } + + fn parse_drone(&mut self) { + self.ci_info.branch = self.get_env_var("DRONE_SOURCE_BRANCH"); + self.ci_info.pr_number = Self::parse_pr_number(self.get_env_var("DRONE_PULL_REQUEST")); + self.ci_info.actor = self.get_env_var("DRONE_COMMIT_AUTHOR"); + self.ci_info.committer_name = self.get_env_var("DRONE_COMMIT_AUTHOR_NAME"); + self.ci_info.committer_email = self.get_env_var("DRONE_COMMIT_AUTHOR_EMAIL"); + self.ci_info.author_name = self.get_env_var("DRONE_COMMIT_AUTHOR_NAME"); + self.ci_info.author_email = self.get_env_var("DRONE_COMMIT_AUTHOR_EMAIL"); + self.ci_info.title = self.get_env_var("DRONE_PULL_REQUEST_TITLE"); + self.ci_info.job_url = self.get_env_var("DRONE_BUILD_LINK"); + } + + fn get_env_var>(&self, env_var: T) -> Option { + self.env_vars + .get(env_var.as_ref()) + .and_then(|s| if s.is_empty() { None } else { Some(s) }) + .cloned() + } + + fn parse_pr_number>(env_var: Option) -> Option { + env_var.and_then(|pr_number_str| pr_number_str.as_ref().parse::().ok()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CIInfo { + pub platform: CIPlatform, + pub job_url: Option, + pub branch: Option, + pub branch_class: Option, + pub pr_number: Option, + pub actor: Option, + pub committer_name: Option, + pub committer_email: Option, + pub author_name: Option, + pub author_email: Option, + pub commit_message: Option, + pub title: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BranchClass { + PullRequest, + ProtectedBranch, + Merge, +} + +impl TryFrom<&str> for BranchClass { + type Error = CIInfoParseError; + + fn try_from(value: &str) -> Result { + if value.starts_with("remotes/pull/") || value.starts_with("pull/") { + Ok(BranchClass::PullRequest) + } else if matches!(value, "master" | "main") { + Ok(BranchClass::ProtectedBranch) + } else if value.contains("/trunk-merge/") { + Ok(BranchClass::Merge) + } else { + Err(CIInfoParseError::BranchClass) + } + } +} + +impl CIInfo { + pub fn new(platform: CIPlatform) -> Self { + Self { + platform, + job_url: None, + branch: None, + branch_class: None, + pr_number: None, + actor: None, + committer_name: None, + committer_email: None, + author_name: None, + author_email: None, + commit_message: None, + title: None, + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct EnvParser<'a> { + errors: Vec, + ci_info_parser: Option>, +} + +impl<'a> EnvParser<'a> { + pub fn new() -> Self { + Default::default() + } + + pub fn errors(&self) -> &Vec { + &self.errors + } + + pub fn ci_info_parser(&self) -> &Option { + &self.ci_info_parser + } + + pub fn into_ci_info_parser(self) -> Option> { + self.ci_info_parser + } + + pub fn parse(&mut self, env_vars: &'a EnvVars) -> anyhow::Result<()> { + self.parse_ci_platform(env_vars); + if let Some(ci_info) = &mut self.ci_info_parser { + ci_info.parse()?; + } + Ok(()) + } + + fn parse_ci_platform(&mut self, env_vars: &'a EnvVars) { + match CIPlatform::try_from(env_vars) { + Ok(ci_platform) => { + self.ci_info_parser = Some(CIInfoParser::new(ci_platform, &env_vars)); + } + Err(err) => { + self.errors.push(err); + } + } + } +} diff --git a/context/src/env/validator.rs b/context/src/env/validator.rs new file mode 100644 index 00000000..d21aa7e1 --- /dev/null +++ b/context/src/env/validator.rs @@ -0,0 +1,274 @@ +use thiserror::Error; + +use crate::{validate_field_len, FieldLen}; + +use super::parser::{BranchClass, CIInfo}; + +pub const MAX_BRANCH_NAME_LEN: usize = 36; +pub const MAX_EMAIL_LEN: usize = 254; +pub const MAX_FIELD_LEN: usize = 1000; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum EnvValidationLevel { + Valid = 0, + SubOptimal = 1, + Invalid = 2, +} + +impl Default for EnvValidationLevel { + fn default() -> Self { + Self::Valid + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EnvValidationIssue { + SubOptimal(EnvValidationIssueSubOptimal), + Invalid(EnvValidationIssueInvalid), +} + +#[derive(Error, Debug, Clone, PartialEq, Eq)] +pub enum EnvValidationIssueSubOptimal { + #[error("CI info actor too short")] + CIInfoActorTooShort(String), + #[error("CI info actor too long, truncated to {}", MAX_FIELD_LEN)] + CIInfoActorTooLong(String), + #[error("CI info author email too short")] + CIInfoAuthorEmailTooShort(String), + #[error("CI info author email too long, truncated to {}", MAX_EMAIL_LEN)] + CIInfoAuthorEmailTooLong(String), + #[error("CI info author name too short")] + CIInfoAuthorNameTooShort(String), + #[error("CI info author name too long, truncated to {}", MAX_FIELD_LEN)] + CIInfoAuthorNameTooLong(String), + #[error("CI info branch name too long, truncated to {}", MAX_BRANCH_NAME_LEN)] + CIInfoBranchNameTooLong(String), + #[error("CI info commit message too short")] + CIInfoCommitMessageTooShort(String), + #[error("CI info commit message too long, truncated to {}", MAX_FIELD_LEN)] + CIInfoCommitMessageTooLong(String), + #[error("CI info committer email too short")] + CIInfoCommitterEmailTooShort(String), + #[error("CI info committer email too long, truncated to {}", MAX_EMAIL_LEN)] + CIInfoCommitterEmailTooLong(String), + #[error("CI info committer name too short")] + CIInfoCommitterNameTooShort(String), + #[error("CI info committer name too long, truncated to {}", MAX_FIELD_LEN)] + CIInfoCommitterNameTooLong(String), + #[error("CI info job URL too short")] + CIInfoJobURLTooShort(String), + #[error("CI info job URL too long, truncated to {}", MAX_FIELD_LEN)] + CIInfoJobURLTooLong(String), + #[error("CI info title too short")] + CIInfoTitleTooShort(String), + #[error("CI info title too long, truncated to {}", MAX_FIELD_LEN)] + CIInfoTitleTooLong(String), +} + +#[derive(Error, Debug, Clone, PartialEq, Eq)] +pub enum EnvValidationIssueInvalid { + #[error("CI info branch name too short")] + CIInfoBranchNameTooShort(String), + #[error("CI info is classified as a PR, but has not PR number")] + CIInfoPRNumberMissing, + #[error("CI info has a PR number, but branch is not classified as a PR")] + CIInfoPRNumberConflictsWithBranchClass, +} + +impl From<&EnvValidationIssue> for EnvValidationLevel { + fn from(value: &EnvValidationIssue) -> Self { + match value { + EnvValidationIssue::SubOptimal(..) => EnvValidationLevel::SubOptimal, + EnvValidationIssue::Invalid(..) => EnvValidationLevel::Invalid, + } + } +} + +pub fn validate(ci_info: &CIInfo) -> EnvValidation { + let mut env_validation = EnvValidation::default(); + + match validate_field_len::(optional_string_to_empty_str(&ci_info.actor)) { + FieldLen::Valid => (), + FieldLen::TooShort(s) => { + env_validation.add_issue(EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoActorTooShort(s), + )); + } + FieldLen::TooLong(s) => { + env_validation.add_issue(EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoActorTooLong(s), + )); + } + }; + + match validate_field_len::(optional_string_to_empty_str( + &ci_info.author_email, + )) { + FieldLen::Valid => (), + FieldLen::TooShort(s) => { + env_validation.add_issue(EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoAuthorEmailTooShort(s), + )); + } + FieldLen::TooLong(s) => { + env_validation.add_issue(EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoAuthorEmailTooLong(s), + )); + } + }; + + match validate_field_len::(optional_string_to_empty_str(&ci_info.author_name)) + { + FieldLen::Valid => (), + FieldLen::TooShort(s) => { + env_validation.add_issue(EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoAuthorNameTooShort(s), + )); + } + FieldLen::TooLong(s) => { + env_validation.add_issue(EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoAuthorNameTooLong(s), + )); + } + }; + + match validate_field_len::(optional_string_to_empty_str( + &ci_info.branch, + )) { + FieldLen::Valid => (), + FieldLen::TooShort(s) => { + env_validation.add_issue(EnvValidationIssue::Invalid( + EnvValidationIssueInvalid::CIInfoBranchNameTooShort(s), + )); + } + FieldLen::TooLong(s) => { + env_validation.add_issue(EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoBranchNameTooLong(s), + )); + } + }; + + match validate_field_len::(optional_string_to_empty_str( + &ci_info.commit_message, + )) { + FieldLen::Valid => (), + FieldLen::TooShort(s) => { + env_validation.add_issue(EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoCommitMessageTooShort(s), + )); + } + FieldLen::TooLong(s) => { + env_validation.add_issue(EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoCommitMessageTooLong(s), + )); + } + }; + + match validate_field_len::(optional_string_to_empty_str( + &ci_info.committer_email, + )) { + FieldLen::Valid => (), + FieldLen::TooShort(s) => { + env_validation.add_issue(EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoCommitterEmailTooShort(s), + )); + } + FieldLen::TooLong(s) => { + env_validation.add_issue(EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoCommitterEmailTooLong(s), + )); + } + }; + + match validate_field_len::(optional_string_to_empty_str( + &ci_info.committer_name, + )) { + FieldLen::Valid => (), + FieldLen::TooShort(s) => { + env_validation.add_issue(EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoCommitterNameTooShort(s), + )); + } + FieldLen::TooLong(s) => { + env_validation.add_issue(EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoCommitterNameTooLong(s), + )); + } + }; + + match validate_field_len::(optional_string_to_empty_str(&ci_info.job_url)) { + FieldLen::Valid => (), + FieldLen::TooShort(s) => { + env_validation.add_issue(EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoJobURLTooShort(s), + )); + } + FieldLen::TooLong(s) => { + env_validation.add_issue(EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoJobURLTooLong(s), + )); + } + }; + + match validate_field_len::(optional_string_to_empty_str(&ci_info.title)) { + FieldLen::Valid => (), + FieldLen::TooShort(s) => { + env_validation.add_issue(EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoTitleTooShort(s), + )); + } + FieldLen::TooLong(s) => { + env_validation.add_issue(EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoTitleTooLong(s), + )); + } + }; + + if let Some(branch_class) = &ci_info.branch_class { + match (branch_class, ci_info.pr_number) { + (BranchClass::PullRequest, None) => { + env_validation.add_issue(EnvValidationIssue::Invalid( + EnvValidationIssueInvalid::CIInfoPRNumberMissing, + )); + } + (BranchClass::Merge | BranchClass::ProtectedBranch, Some(..)) => { + env_validation.add_issue(EnvValidationIssue::Invalid( + EnvValidationIssueInvalid::CIInfoPRNumberConflictsWithBranchClass, + )); + } + (BranchClass::PullRequest, Some(..)) + | (BranchClass::Merge | BranchClass::ProtectedBranch, None) => (), + }; + } + + env_validation +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct EnvValidation { + level: EnvValidationLevel, + issues: Vec, +} + +impl EnvValidation { + pub fn level(&self) -> EnvValidationLevel { + self.level + } + + pub fn issues(&self) -> &[EnvValidationIssue] { + &self.issues + } + + pub fn max_level(&self) -> EnvValidationLevel { + self.level + } + + fn add_issue(&mut self, issue: EnvValidationIssue) { + self.level = self.level.max(EnvValidationLevel::from(&issue)); + self.issues.push(issue); + } +} + +fn optional_string_to_empty_str<'a>(optional_string: &'a Option) -> &'a str { + optional_string.as_ref().map_or("", |s| &s) +} diff --git a/context/src/junit/date_parser.rs b/context/src/junit/date_parser.rs new file mode 100644 index 00000000..8f9c1cb4 --- /dev/null +++ b/context/src/junit/date_parser.rs @@ -0,0 +1,85 @@ +use std::time::Duration; + +use chrono::{DateTime as ChronoDateTime, FixedOffset}; +use speedate::{Date as SpeedateDate, DateTime as SpeedateDateTime}; + +#[derive(Debug, Copy, Clone)] +enum DateType { + DateTime, + NaiveDate, +} + +#[derive(Debug, Clone, Default)] +struct TimestampAndOffset { + timestamp_secs_micros: Option<(i64, u32)>, + offset_secs: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct JunitDateParser { + date_type: Option, +} + +impl JunitDateParser { + pub fn parse_date>( + &mut self, + date_str: T, + ) -> Option> { + let date_str = date_str.as_ref(); + + let timestamp_and_offset = match self.date_type { + Some(DateType::DateTime) => Self::parse_date_time(date_str), + Some(DateType::NaiveDate) => Self::parse_naive_date(date_str), + None => Self::parse_date_time(date_str).or_else(|| Self::parse_naive_date(date_str)), + }; + + self.convert_to_chrono_date_time(timestamp_and_offset.unwrap_or_default()) + } + + fn parse_date_time>(date_str: T) -> Option { + SpeedateDateTime::parse_str(date_str.as_ref()) + .ok() + .map(|dt| TimestampAndOffset { + timestamp_secs_micros: Some((dt.timestamp(), dt.time.microsecond)), + offset_secs: dt.time.tz_offset, + }) + } + + fn parse_naive_date>(date_str: T) -> Option { + SpeedateDate::parse_str(date_str.as_ref()) + .ok() + .map(|d| TimestampAndOffset { + timestamp_secs_micros: Some((d.timestamp(), 0)), + offset_secs: None, + }) + } + + fn convert_to_chrono_date_time( + &mut self, + TimestampAndOffset { + timestamp_secs_micros, + offset_secs, + }: TimestampAndOffset, + ) -> Option> { + match ( + timestamp_secs_micros.and_then(|(secs, micros)| { + let duration = Duration::from_micros(micros.into()); + ChronoDateTime::from_timestamp( + secs, + duration.as_nanos().try_into().unwrap_or_default(), + ) + }), + offset_secs.and_then(|secs| FixedOffset::east_opt(secs)), + ) { + (Some(chrono_date_time), Some(fixed_offset)) => { + self.date_type = Some(DateType::DateTime); + Some(chrono_date_time.with_timezone(&fixed_offset)) + } + (Some(chrono_date_time), None) => { + self.date_type = Some(DateType::NaiveDate); + Some(chrono_date_time.fixed_offset()) + } + (None, None) | (None, Some(..)) => None, + } + } +} diff --git a/context/src/junit/mod.rs b/context/src/junit/mod.rs new file mode 100644 index 00000000..dbd79f93 --- /dev/null +++ b/context/src/junit/mod.rs @@ -0,0 +1,3 @@ +mod date_parser; +pub mod parser; +pub mod validator; diff --git a/context/src/junit/parser.rs b/context/src/junit/parser.rs new file mode 100644 index 00000000..796df250 --- /dev/null +++ b/context/src/junit/parser.rs @@ -0,0 +1,588 @@ +use std::io::BufRead; + +use quick_junit::{NonSuccessKind, Report, TestCase, TestCaseStatus, TestRerun, TestSuite}; +use quick_xml::{ + events::{BytesStart, BytesText, Event}, + Reader, +}; +use thiserror::Error; + +use super::date_parser::JunitDateParser; + +const TAG_REPORT: &[u8] = b"testsuites"; +const TAG_TEST_SUITE: &[u8] = b"testsuite"; +const TAG_TEST_CASE: &[u8] = b"testcase"; +const TAG_TEST_CASE_STATUS_FAILURE: &[u8] = b"failure"; +const TAG_TEST_CASE_STATUS_ERROR: &[u8] = b"error"; +const TAG_TEST_CASE_STATUS_SKIPPED: &[u8] = b"skipped"; +const TAG_TEST_RERUN_FAILURE: &[u8] = b"rerunFailure"; +const TAG_TEST_RERUN_ERROR: &[u8] = b"rerunError"; +const TAG_TEST_RERUN_FLAKY_FAILURE: &[u8] = b"flakyFailure"; +const TAG_TEST_RERUN_FLAKY_ERROR: &[u8] = b"flakyError"; +const TAG_TEST_RERUN_STACK_TRACE: &[u8] = b"stackTrace"; +const TAG_SYSTEM_OUT: &[u8] = b"system-out"; +const TAG_SYSTEM_ERR: &[u8] = b"system-err"; + +pub mod extra_attrs { + pub const FILE: &str = "file"; + pub const FILEPATH: &str = "filepath"; + pub const LINE: &str = "line"; +} + +#[derive(Error, Debug, Copy, Clone, PartialEq, Eq)] +pub enum JunitParseError { + #[error("could not parse report name")] + ReportName, + #[error("no reports found")] + ReportNotFound, + #[error("multiple reports found")] + ReportMultipleFound, + #[error("report end tag found without start tag")] + ReportStartTagNotFound, + #[error("could not parse test suite name")] + TestSuiteName, + #[error("test suite found without a report found")] + TestSuiteReportNotFound, + #[error("test suite end tag found without start tag")] + TestSuiteStartTagNotFound, + #[error("could not parse test case name")] + TestCaseName, + #[error("test case found without a test suite found")] + TestCaseTestSuiteNotFound, + #[error("test case end tag found without start tag")] + TestCaseStartTagNotFound, + #[error("test case status found without a test case found")] + TestCaseStatusTestCaseNotFound, + #[error("test rerun found without a test case found")] + TestRerunStartTagNotFound, + #[error("test rerun end tag found without start tag")] + TestRerunTestCaseNotFound, + #[error("system out is empty")] + SystemOutEmpty, + #[error("system err is empty")] + SystemErrEmpty, + #[error("stack trace is empty")] + StackTraceEmpty, +} + +#[derive(Debug, Clone)] +enum Text { + SystemOut(Option), + SystemErr(Option), + StackTrace(Option), +} + +#[derive(Debug, Clone, Default)] +pub struct JunitParser { + date_parser: JunitDateParser, + errors: Vec, + reports: Vec, + current_report: Option, + current_test_suite: Option, + current_test_case: Option, + current_test_rerun: Option, + current_text: Option, +} + +impl JunitParser { + pub fn new() -> Self { + Default::default() + } + + pub fn errors(&self) -> &Vec { + &self.errors + } + + pub fn reports(&self) -> &Vec { + &self.reports + } + + pub fn into_reports(self) -> Vec { + self.reports + } + + pub fn parse(&mut self, xml: R) -> anyhow::Result<()> { + let mut reader = Reader::from_reader(xml); + reader.config_mut().trim_text(true); + + let mut buf = Vec::new(); + loop { + if self + .match_event(reader.read_event_into(&mut buf)?) + .is_none() + { + break; + } + buf.clear(); + } + + match self.reports.len() { + 0 => self.errors.push(JunitParseError::ReportNotFound), + 1 => { + // There should only be 1 report per JUnit.xml file + } + _ => self.errors.push(JunitParseError::ReportMultipleFound), + }; + + Ok(()) + } + + fn match_event(&mut self, event: Event) -> Option<()> { + match event { + Event::Eof => return None, + Event::Start(e) => match e.name().as_ref() { + TAG_REPORT => self.open_report(&e), + TAG_TEST_SUITE => self.open_test_suite(&e), + TAG_TEST_CASE => self.open_test_case(&e), + TAG_TEST_CASE_STATUS_FAILURE + | TAG_TEST_CASE_STATUS_ERROR + | TAG_TEST_CASE_STATUS_SKIPPED => self.set_test_case_status(&e), + TAG_TEST_RERUN_FAILURE + | TAG_TEST_RERUN_ERROR + | TAG_TEST_RERUN_FLAKY_FAILURE + | TAG_TEST_RERUN_FLAKY_ERROR => { + self.open_test_rerun(&e); + } + TAG_TEST_RERUN_STACK_TRACE => self.open_text(Text::StackTrace(None)), + TAG_SYSTEM_OUT => self.open_text(Text::SystemOut(None)), + TAG_SYSTEM_ERR => self.open_text(Text::SystemErr(None)), + _ => (), + }, + Event::End(e) => match e.name().as_ref() { + TAG_REPORT => self.close_report(), + TAG_TEST_SUITE => self.close_test_suite(), + TAG_TEST_CASE => self.close_test_case(), + TAG_TEST_CASE_STATUS_FAILURE + | TAG_TEST_CASE_STATUS_ERROR + | TAG_TEST_CASE_STATUS_SKIPPED => { + // There's only 1 status per test case, so there's nothing to close + } + TAG_TEST_RERUN_FAILURE + | TAG_TEST_RERUN_ERROR + | TAG_TEST_RERUN_FLAKY_FAILURE + | TAG_TEST_RERUN_FLAKY_ERROR => { + self.close_test_rerun(); + } + TAG_TEST_RERUN_STACK_TRACE | TAG_SYSTEM_OUT | TAG_SYSTEM_ERR => { + self.close_system_text() + } + _ => (), + }, + Event::Empty(e) => match e.name().as_ref() { + TAG_REPORT => { + self.open_report(&e); + self.close_report(); + } + TAG_TEST_SUITE => { + self.open_test_suite(&e); + self.close_test_suite(); + } + TAG_TEST_CASE => { + self.open_test_case(&e); + self.close_test_case(); + } + TAG_TEST_CASE_STATUS_FAILURE + | TAG_TEST_CASE_STATUS_ERROR + | TAG_TEST_CASE_STATUS_SKIPPED => { + self.set_test_case_status(&e); + } + TAG_TEST_RERUN_FAILURE + | TAG_TEST_RERUN_ERROR + | TAG_TEST_RERUN_FLAKY_FAILURE + | TAG_TEST_RERUN_FLAKY_ERROR => { + self.open_test_rerun(&e); + self.close_test_rerun(); + } + TAG_TEST_RERUN_STACK_TRACE => self.errors.push(JunitParseError::StackTraceEmpty), + TAG_SYSTEM_OUT => self.errors.push(JunitParseError::SystemOutEmpty), + TAG_SYSTEM_ERR => self.errors.push(JunitParseError::SystemErrEmpty), + _ => (), + }, + Event::CData(e) => { + if let Ok(e) = e.minimal_escape() { + self.match_text(&e); + } + } + Event::Text(e) => { + self.match_text(&e); + } + _ => (), + }; + Some(()) + } + + fn match_text(&mut self, e: &BytesText) { + if self.current_text.is_some() { + self.set_text_value(&e); + } else if self.current_test_rerun.is_some() { + self.set_test_rerun_description(&e); + } else { + self.set_test_case_status_description(&e); + } + } + + fn open_report(&mut self, e: &BytesStart) { + let report_name = parse_attr::name(e).unwrap_or_default(); + if report_name.is_empty() { + self.errors.push(JunitParseError::ReportName); + } + let mut report = Report::new(report_name); + + if let Some(timestamp) = parse_attr::timestamp(e, &mut self.date_parser) { + report.set_timestamp(timestamp); + } + + if let Some(time) = parse_attr::time(e) { + report.set_time(time); + } + + self.current_report = Some(report); + } + + fn close_report(&mut self) { + if let Some(report) = self.current_report.take() { + self.reports.push(report); + } else { + self.errors.push(JunitParseError::ReportStartTagNotFound); + } + } + + fn open_test_suite(&mut self, e: &BytesStart) { + let test_suite_name = parse_attr::name(e).unwrap_or_default(); + if test_suite_name.is_empty() { + self.errors.push(JunitParseError::TestSuiteName); + }; + let mut test_suite = TestSuite::new(test_suite_name); + + if let Some(timestamp) = parse_attr::timestamp(e, &mut self.date_parser) { + test_suite.set_timestamp(timestamp); + } + + if let Some(time) = parse_attr::time(e) { + test_suite.set_time(time); + } + + if let Some(file) = parse_attr::file(e) { + test_suite + .extra + .insert(extra_attrs::FILE.into(), file.into()); + } + + if let Some(filepath) = parse_attr::filepath(e) { + test_suite + .extra + .insert(extra_attrs::FILEPATH.into(), filepath.into()); + } + + if let Some(line) = parse_attr::line(e) { + test_suite + .extra + .insert(extra_attrs::LINE.into(), line.to_string().into()); + } + + self.current_test_suite = Some(test_suite); + } + + fn close_test_suite(&mut self) { + if let Some(report) = self.current_report.as_mut() { + if let Some(test_suite) = self.current_test_suite.take() { + report.add_test_suite(test_suite); + } else { + self.errors.push(JunitParseError::TestSuiteStartTagNotFound); + } + } else { + self.errors.push(JunitParseError::TestSuiteReportNotFound); + } + } + + fn open_test_case(&mut self, e: &BytesStart) { + let test_case_name = parse_attr::name(e).unwrap_or_default(); + if test_case_name.is_empty() { + self.errors.push(JunitParseError::TestCaseName); + }; + let mut test_case = TestCase::new(test_case_name, TestCaseStatus::success()); + + if let Some(timestamp) = parse_attr::timestamp(e, &mut self.date_parser) { + test_case.set_timestamp(timestamp); + } + + if let Some(time) = parse_attr::time(e) { + test_case.set_time(time); + } + + if let Some(assertions) = parse_attr::assertions(e) { + test_case.set_assertions(assertions); + } + + if let Some(classname) = parse_attr::classname(e) { + test_case.set_classname(classname); + } + + if let Some(file) = parse_attr::file(e) { + test_case.extra.insert("file".into(), file.into()); + } + + if let Some(filepath) = parse_attr::filepath(e) { + test_case.extra.insert("filepath".into(), filepath.into()); + } + + if let Some(line) = parse_attr::line(e) { + test_case + .extra + .insert("line".into(), line.to_string().into()); + } + + self.current_test_case = Some(test_case); + } + + fn close_test_case(&mut self) { + if let Some(test_suite) = self.current_test_suite.as_mut() { + if let Some(test_case) = self.current_test_case.take() { + test_suite.add_test_case(test_case); + } else { + self.errors.push(JunitParseError::TestCaseStartTagNotFound); + } + } else { + self.errors.push(JunitParseError::TestCaseTestSuiteNotFound); + } + } + + fn set_test_case_status(&mut self, e: &BytesStart) { + if let Some(test_case) = self.current_test_case.as_mut() { + let tag = e.name(); + let mut test_case_status = if tag.as_ref() == TAG_TEST_CASE_STATUS_SKIPPED { + TestCaseStatus::skipped() + } else { + let non_success_kind = if tag.as_ref() == TAG_TEST_CASE_STATUS_FAILURE { + NonSuccessKind::Failure + } else { + NonSuccessKind::Error + }; + TestCaseStatus::non_success(non_success_kind) + }; + + if let Some(message) = parse_attr::message(e) { + test_case_status.set_message(message); + } + + if let Some(r#type) = parse_attr::r#type(e) { + test_case_status.set_type(r#type); + } + + test_case.status = test_case_status; + } else { + self.errors + .push(JunitParseError::TestCaseStatusTestCaseNotFound); + } + } + + fn set_test_case_status_description(&mut self, e: &BytesText) { + if let (Some(test_case), Some(description)) = + (&mut self.current_test_case, unescape_and_truncate::text(e)) + { + test_case.status.set_description(description); + } + } + + fn open_test_rerun(&mut self, e: &BytesStart) { + let mut test_rerun = match e.name().as_ref() { + TAG_TEST_RERUN_FAILURE => TestRerun::new(NonSuccessKind::Failure), + TAG_TEST_RERUN_ERROR => TestRerun::new(NonSuccessKind::Error), + TAG_TEST_RERUN_FLAKY_FAILURE => TestRerun::new(NonSuccessKind::Failure), + TAG_TEST_RERUN_FLAKY_ERROR => TestRerun::new(NonSuccessKind::Error), + _ => return, + }; + + if let Some(timestamp) = parse_attr::timestamp(e, &mut self.date_parser) { + test_rerun.set_timestamp(timestamp); + } + + if let Some(time) = parse_attr::time(e) { + test_rerun.set_time(time); + } + + if let Some(message) = parse_attr::message(e) { + test_rerun.set_message(message); + } + + if let Some(r#type) = parse_attr::r#type(e) { + test_rerun.set_type(r#type); + } + + self.current_test_rerun = Some(test_rerun); + } + + fn set_test_rerun_description(&mut self, e: &BytesText) { + if let (Some(test_rerun), Some(description)) = + (&mut self.current_test_rerun, unescape_and_truncate::text(e)) + { + test_rerun.set_description(description); + } + } + + fn close_test_rerun(&mut self) { + if let Some(test_case) = self.current_test_case.as_mut() { + if let Some(test_rerun) = self.current_test_rerun.take() { + test_case.status.add_rerun(test_rerun); + } else { + self.errors.push(JunitParseError::TestRerunStartTagNotFound); + } + } else { + self.errors.push(JunitParseError::TestRerunTestCaseNotFound); + } + } + + fn open_text(&mut self, text: Text) { + self.current_text = Some(text); + } + + fn set_text_value(&mut self, e: &BytesText) { + if let (Some(text), Some(value)) = (&mut self.current_text, unescape_and_truncate::text(e)) + { + let inner_value = match text { + Text::SystemOut(v) => v, + Text::SystemErr(v) => v, + Text::StackTrace(v) => v, + }; + *inner_value = Some(String::from(value)); + } + } + + fn close_system_text(&mut self) { + if let Some(test_rerun) = self.current_test_rerun.as_mut() { + match self.current_text.take() { + Some(Text::StackTrace(Some(s))) => { + test_rerun.set_stack_trace(s); + } + Some(Text::SystemOut(Some(s))) => { + test_rerun.set_system_out(s); + } + Some(Text::SystemErr(Some(s))) => { + test_rerun.set_system_err(s); + } + _ => (), + }; + } else if let Some(test_case) = self.current_test_case.as_mut() { + match self.current_text.take() { + Some(Text::SystemOut(Some(s))) => { + test_case.set_system_out(s); + } + Some(Text::SystemErr(Some(s))) => { + test_case.set_system_err(s); + } + _ => (), + }; + } else if let Some(test_suite) = self.current_test_suite.as_mut() { + match self.current_text.take() { + Some(Text::SystemOut(Some(s))) => { + test_suite.set_system_out(s); + } + Some(Text::SystemErr(Some(s))) => { + test_suite.set_system_err(s); + } + _ => (), + }; + } + } +} + +mod parse_attr { + use std::{borrow::Cow, str::FromStr, time::Duration}; + + use chrono::{DateTime, FixedOffset}; + use quick_xml::events::BytesStart; + + use crate::junit::date_parser::JunitDateParser; + + use super::{extra_attrs, unescape_and_truncate}; + + pub fn name<'a>(e: &'a BytesStart<'a>) -> Option> { + parse_string_attr(e, "name") + } + + pub fn timestamp( + e: &BytesStart, + date_parser: &mut JunitDateParser, + ) -> Option> { + parse_string_attr(e, "timestamp").and_then(|value| date_parser.parse_date(&value)) + } + + pub fn time(e: &BytesStart) -> Option { + parse_string_attr_into_other_type(e, "time") + .map(|seconds: f64| Duration::from_secs_f64(seconds)) + } + + pub fn assertions(e: &BytesStart) -> Option { + parse_string_attr_into_other_type(e, "assertions") + } + + pub fn classname<'a>(e: &'a BytesStart<'a>) -> Option> { + parse_string_attr(e, "classname") + } + + pub fn message<'a>(e: &'a BytesStart<'a>) -> Option> { + parse_string_attr(e, "message") + } + + pub fn r#type<'a>(e: &'a BytesStart<'a>) -> Option> { + parse_string_attr(e, "type") + } + + pub fn file<'a>(e: &'a BytesStart<'a>) -> Option> { + parse_string_attr(e, extra_attrs::FILE) + } + + pub fn filepath<'a>(e: &'a BytesStart<'a>) -> Option> { + parse_string_attr(e, extra_attrs::FILEPATH) + } + + pub fn line<'a>(e: &'a BytesStart<'a>) -> Option { + parse_string_attr_into_other_type(e, extra_attrs::LINE) + } + + fn parse_string_attr<'a>( + e: &'a BytesStart<'a>, + attr_name: &'static str, + ) -> Option> { + e.try_get_attribute(attr_name) + .ok() + .flatten() + .and_then(|attr| unescape_and_truncate::attr(&attr)) + } + + fn parse_string_attr_into_other_type<'a, T: FromStr>( + e: &'a BytesStart<'a>, + attr_name: &'static str, + ) -> Option { + parse_string_attr(e, attr_name).and_then(|value| value.parse::().ok()) + } +} + +mod unescape_and_truncate { + use std::borrow::Cow; + + use quick_xml::events::{attributes::Attribute, BytesText}; + + use crate::safe_truncate_str; + + const MAX_TEXT_FIELD_SIZE: usize = 8_000; + + pub fn attr<'a>(v: &Attribute<'a>) -> Option> { + v.unescape_value() + .ok() + .map(|b| safe_truncate_cow::(b)) + } + + pub fn text<'a>(v: &BytesText<'a>) -> Option> { + v.unescape() + .ok() + .map(|b| safe_truncate_cow::(b)) + } + + fn safe_truncate_cow<'a, const MAX_LEN: usize>(value: Cow<'a, str>) -> Cow<'a, str> { + match value { + Cow::Borrowed(b) => Cow::Borrowed(safe_truncate_str::(b)), + Cow::Owned(b) => Cow::Owned(String::from(safe_truncate_str::(b.as_str()))), + } + } +} diff --git a/context/src/junit/validator.rs b/context/src/junit/validator.rs new file mode 100644 index 00000000..cdedb31d --- /dev/null +++ b/context/src/junit/validator.rs @@ -0,0 +1,307 @@ +use chrono::{DateTime, FixedOffset, Utc}; +use quick_junit::Report; +use thiserror::Error; + +use crate::{validate_field_len, FieldLen}; + +use super::parser::extra_attrs; + +pub const MAX_FIELD_LEN: usize = 1_000; + +const TIMESTAMP_OLD_DAYS: u32 = 30; +const TIMESTAMP_STALE_HOURS: u32 = 1; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum JunitValidationLevel { + Valid = 0, + SubOptimal = 1, + Invalid = 2, +} + +impl Default for JunitValidationLevel { + fn default() -> Self { + Self::Valid + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum JunitValidationIssue { + SubOptimal(SO), + Invalid(I), +} + +impl From<&JunitValidationIssue> for JunitValidationLevel { + fn from(value: &JunitValidationIssue) -> Self { + match value { + JunitValidationIssue::SubOptimal(..) => JunitValidationLevel::SubOptimal, + JunitValidationIssue::Invalid(..) => JunitValidationLevel::Invalid, + } + } +} + +pub fn validate(report: &Report) -> JunitReportValidation { + let mut report_validation = JunitReportValidation::default(); + + for test_suite in report.test_suites.iter() { + let mut test_suite_validation = JunitTestSuiteValidation::default(); + + match validate_field_len::(test_suite.name.as_str()) { + FieldLen::Valid => (), + FieldLen::TooShort(s) => { + test_suite_validation.add_issue(JunitValidationIssue::Invalid( + JunitTestSuiteValidationIssueInvalid::TestSuiteNameTooShort(s), + )); + } + FieldLen::TooLong(s) => { + test_suite_validation.add_issue(JunitValidationIssue::SubOptimal( + JunitTestSuiteValidationIssueSubOptimal::TestSuiteNameTooLong(s), + )); + } + }; + + for test_case in test_suite.test_cases.iter() { + let mut test_case_validation = JunitTestCaseValidation::default(); + + match validate_field_len::(test_case.name.as_str()) { + FieldLen::Valid => (), + FieldLen::TooShort(s) => { + test_case_validation.add_issue(JunitValidationIssue::Invalid( + JunitTestCaseValidationIssueInvalid::TestCaseNameTooShort(s), + )); + } + FieldLen::TooLong(s) => { + test_case_validation.add_issue(JunitValidationIssue::SubOptimal( + JunitTestCaseValidationIssueSubOptimal::TestCaseNameTooLong(s), + )); + } + }; + + match validate_field_len::( + test_case + .extra + .get(extra_attrs::FILE) + .or(test_case.extra.get(extra_attrs::FILEPATH)) + .as_ref() + .map(|s| s.as_str()) + .unwrap_or_default(), + ) { + FieldLen::Valid => (), + FieldLen::TooShort(s) => { + test_case_validation.add_issue(JunitValidationIssue::SubOptimal( + JunitTestCaseValidationIssueSubOptimal::TestCaseFileOrFilepathTooShort(s), + )); + } + FieldLen::TooLong(s) => { + test_case_validation.add_issue(JunitValidationIssue::SubOptimal( + JunitTestCaseValidationIssueSubOptimal::TestCaseFileOrFilepathTooLong(s), + )); + } + }; + + match validate_field_len::( + test_case + .classname + .as_ref() + .map(|s| s.as_str()) + .unwrap_or_default(), + ) { + FieldLen::Valid => (), + FieldLen::TooShort(s) => { + test_case_validation.add_issue(JunitValidationIssue::SubOptimal( + JunitTestCaseValidationIssueSubOptimal::TestCaseClassnameTooShort(s), + )); + } + FieldLen::TooLong(s) => { + test_case_validation.add_issue(JunitValidationIssue::SubOptimal( + JunitTestCaseValidationIssueSubOptimal::TestCaseClassnameTooLong(s), + )); + } + }; + + if test_case.time.or(test_suite.time).or(report.time).is_none() { + test_case_validation.add_issue(JunitValidationIssue::SubOptimal( + JunitTestCaseValidationIssueSubOptimal::TestCaseNoTimeDuration, + )); + } + + if let Some(timestamp) = test_case + .timestamp + .or(test_suite.timestamp) + .or(report.timestamp) + { + let now = Utc::now().fixed_offset(); + let time_since_timestamp = now - timestamp; + + if timestamp > now { + test_case_validation.add_issue(JunitValidationIssue::SubOptimal( + JunitTestCaseValidationIssueSubOptimal::TestCaseFutureTimestamp(timestamp), + )); + } else if time_since_timestamp.num_days() > i64::from(TIMESTAMP_OLD_DAYS) { + test_case_validation.add_issue(JunitValidationIssue::SubOptimal( + JunitTestCaseValidationIssueSubOptimal::TestCaseOldTimestamp(timestamp), + )); + } else if time_since_timestamp.num_hours() > i64::from(TIMESTAMP_STALE_HOURS) { + test_case_validation.add_issue(JunitValidationIssue::SubOptimal( + JunitTestCaseValidationIssueSubOptimal::TestCaseStaleTimestamp(timestamp), + )); + } + } else { + test_case_validation.add_issue(JunitValidationIssue::SubOptimal( + JunitTestCaseValidationIssueSubOptimal::TestCaseNoTimestamp, + )); + } + + test_suite_validation.test_cases.push(test_case_validation); + } + + report_validation.test_suites.push(test_suite_validation); + } + + report_validation +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct JunitReportValidation { + test_suites: Vec, +} + +impl JunitReportValidation { + pub fn test_suites(&self) -> &[JunitTestSuiteValidation] { + &self.test_suites + } + + pub fn max_level(&self) -> JunitValidationLevel { + self.test_suites + .iter() + .map(|test_suite| test_suite.max_level()) + .max() + .unwrap_or(JunitValidationLevel::Valid) + } + + pub fn test_suites_max_level(&self) -> Option { + self.test_suites + .iter() + .map(|test_suite| test_suite.level) + .max() + } +} + +pub type JunitTestSuiteValidationIssue = JunitValidationIssue< + JunitTestSuiteValidationIssueSubOptimal, + JunitTestSuiteValidationIssueInvalid, +>; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct JunitTestSuiteValidation { + level: JunitValidationLevel, + issues: Vec, + test_cases: Vec, +} + +impl JunitTestSuiteValidation { + pub fn level(&self) -> &JunitValidationLevel { + &self.level + } + + pub fn issues(&self) -> &[JunitTestSuiteValidationIssue] { + &self.issues + } + + pub fn test_cases(&self) -> &[JunitTestCaseValidation] { + &self.test_cases + } + + pub fn max_level(&self) -> JunitValidationLevel { + self.test_cases + .iter() + .map(|test_suite| test_suite.level) + .max() + .map_or(self.level, |l| l.max(self.level)) + } + + pub fn test_cases_max_level(&self) -> Option { + self.test_cases + .iter() + .map(|test_case| test_case.level) + .max() + } + + fn add_issue(&mut self, issue: JunitTestSuiteValidationIssue) { + self.level = self.level.max(JunitValidationLevel::from(&issue)); + self.issues.push(issue); + } +} + +#[derive(Error, Debug, Clone, PartialEq, Eq)] +pub enum JunitTestSuiteValidationIssueSubOptimal { + #[error("test suite name too long, truncated to {}", MAX_FIELD_LEN)] + TestSuiteNameTooLong(String), +} + +#[derive(Error, Debug, Clone, PartialEq, Eq)] +pub enum JunitTestSuiteValidationIssueInvalid { + #[error("test suite name too short")] + TestSuiteNameTooShort(String), +} + +pub type JunitTestCaseValidationIssue = JunitValidationIssue< + JunitTestCaseValidationIssueSubOptimal, + JunitTestCaseValidationIssueInvalid, +>; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct JunitTestCaseValidation { + level: JunitValidationLevel, + issues: Vec, +} + +impl JunitTestCaseValidation { + pub fn level(&self) -> &JunitValidationLevel { + &self.level + } + + pub fn issues(&self) -> &[JunitTestCaseValidationIssue] { + &self.issues + } + + fn add_issue(&mut self, issue: JunitTestCaseValidationIssue) { + self.level = self.level.max(JunitValidationLevel::from(&issue)); + self.issues.push(issue); + } +} + +#[derive(Error, Debug, Clone, PartialEq, Eq)] +pub enum JunitTestCaseValidationIssueSubOptimal { + #[error("test case name too long, truncated to {}", MAX_FIELD_LEN)] + TestCaseNameTooLong(String), + #[error("test case file or filepath too short")] + TestCaseFileOrFilepathTooShort(String), + #[error("test case file or filepath too long")] + TestCaseFileOrFilepathTooLong(String), + #[error("test case classname too short")] + TestCaseClassnameTooShort(String), + #[error("test case classname too long, truncated to {}", MAX_FIELD_LEN)] + TestCaseClassnameTooLong(String), + #[error("test case or parent has no time duration")] + TestCaseNoTimeDuration, + #[error("test case or parent has no timestamp")] + TestCaseNoTimestamp, + #[error("test case or parent has future timestamp")] + TestCaseFutureTimestamp(DateTime), + #[error( + "test case or parent has old (> {} day(s)) timestamp", + TIMESTAMP_OLD_DAYS + )] + TestCaseOldTimestamp(DateTime), + #[error( + "test case or parent has stale (> {} hour(s)) timestamp", + TIMESTAMP_STALE_HOURS + )] + TestCaseStaleTimestamp(DateTime), +} + +#[derive(Error, Debug, Clone, PartialEq, Eq)] +pub enum JunitTestCaseValidationIssueInvalid { + #[error("test case name too short")] + TestCaseNameTooShort(String), +} diff --git a/context/src/lib.rs b/context/src/lib.rs new file mode 100644 index 00000000..78de46a7 --- /dev/null +++ b/context/src/lib.rs @@ -0,0 +1,36 @@ +// NOTE: This lint isn't applicable since we compile with nightly +/* trunk-ignore(clippy/E0554) */ +#![feature(round_char_boundary)] + +pub mod env; +pub mod junit; + +fn safe_truncate_string<'a, const MAX_LEN: usize, T: AsRef>(value: &'a T) -> &'a str { + safe_truncate_str::(value.as_ref()) +} + +fn safe_truncate_str<'a, const MAX_LEN: usize>(value: &'a str) -> &'a str { + &value.trim()[..value.floor_char_boundary(MAX_LEN)] +} + +#[derive(Debug, Clone)] +enum FieldLen { + TooShort(String), + TooLong(String), + Valid, +} + +fn validate_field_len>(field: T) -> FieldLen { + let trimmed_field = field.as_ref().trim(); + let trimmed_field_len = trimmed_field.len(); + + if trimmed_field_len == 0 { + FieldLen::TooShort(String::from(trimmed_field)) + } else if (1..=MAX_LEN).contains(&trimmed_field_len) { + FieldLen::Valid + } else { + FieldLen::TooLong(String::from(safe_truncate_string::( + &trimmed_field, + ))) + } +} diff --git a/context/tests/env.rs b/context/tests/env.rs new file mode 100644 index 00000000..b5fa83dc --- /dev/null +++ b/context/tests/env.rs @@ -0,0 +1,218 @@ +use context::env::{ + self, + parser::{CIInfo, CIPlatform, EnvParser}, + validator::{EnvValidationIssue, EnvValidationIssueSubOptimal, EnvValidationLevel}, + EnvVars, +}; + +#[test] +fn test_simple_buildkite() { + let job_url = String::from("https://buildkite.com/test/builds/123"); + let branch = String::from("some-branch-name"); + let env_vars = EnvVars::from_iter( + vec![ + ( + String::from("BUILDKITE_PULL_REQUEST"), + String::from("false"), + ), + (String::from("BUILDKITE_BRANCH"), String::from(&branch)), + (String::from("BUILDKITE_BUILD_URL"), String::from(&job_url)), + ( + String::from("BUILDKITE_BUILD_AUTHOR_EMAIL"), + String::from(""), + ), + (String::from("BUILDKITE"), String::from("true")), + ] + .into_iter(), + ); + + let mut env_parser = EnvParser::new(); + env_parser.parse(&env_vars).unwrap(); + + let ci_info = env_parser.into_ci_info_parser().unwrap().info_ci_info(); + + pretty_assertions::assert_eq!( + ci_info, + CIInfo { + platform: CIPlatform::Buildkite, + job_url: Some(job_url), + branch: Some(branch), + branch_class: None, + pr_number: None, + actor: None, + committer_name: None, + committer_email: None, + author_name: None, + author_email: None, + commit_message: None, + title: None, + } + ); + + let env_validation = env::validator::validate(&ci_info); + assert_eq!(env_validation.max_level(), EnvValidationLevel::SubOptimal); + pretty_assertions::assert_eq!( + env_validation.issues(), + &[ + EnvValidationIssue::SubOptimal(EnvValidationIssueSubOptimal::CIInfoActorTooShort( + String::from("") + )), + EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoAuthorEmailTooShort(String::from(""),), + ), + EnvValidationIssue::SubOptimal(EnvValidationIssueSubOptimal::CIInfoAuthorNameTooShort( + String::from(""), + ),), + EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoCommitMessageTooShort(String::from(""),), + ), + EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoCommitterEmailTooShort(String::from(""),), + ), + EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoCommitterNameTooShort(String::from(""),), + ), + EnvValidationIssue::SubOptimal(EnvValidationIssueSubOptimal::CIInfoTitleTooShort( + String::from(""), + ),), + ] + ); +} + +#[test] +fn test_simple_drone() { + let job_url = String::from("https://drone.io/test/builds/123"); + let branch = String::from("some-branch-name"); + let pr_number = 123; + let title = String::from("some title"); + let actor = String::from("username"); + let name = String::from("firstname lastname"); + let email = String::from("user@example.com"); + let env_vars = EnvVars::from_iter( + vec![ + (String::from("DRONE_BUILD_LINK"), String::from(&job_url)), + (String::from("DRONE_SOURCE_BRANCH"), String::from(&branch)), + (String::from("DRONE_PULL_REQUEST"), pr_number.to_string()), + ( + String::from("DRONE_PULL_REQUEST_TITLE"), + String::from(&title), + ), + (String::from("DRONE_COMMIT_AUTHOR"), String::from(&actor)), + ( + String::from("DRONE_COMMIT_AUTHOR_NAME"), + String::from(&name), + ), + ( + String::from("DRONE_COMMIT_AUTHOR_EMAIL"), + String::from(&email), + ), + (String::from("DRONE"), String::from("true")), + ] + .into_iter(), + ); + + let mut env_parser = EnvParser::new(); + env_parser.parse(&env_vars).unwrap(); + + let ci_info = env_parser.into_ci_info_parser().unwrap().info_ci_info(); + + pretty_assertions::assert_eq!( + ci_info, + CIInfo { + platform: CIPlatform::Drone, + job_url: Some(job_url), + branch: Some(branch), + branch_class: None, + pr_number: Some(pr_number), + actor: Some(actor), + committer_name: Some(name.clone()), + committer_email: Some(email.clone()), + author_name: Some(name.clone()), + author_email: Some(email.clone()), + commit_message: None, + title: Some(title), + } + ); + + let env_validation = env::validator::validate(&ci_info); + assert_eq!(env_validation.max_level(), EnvValidationLevel::SubOptimal); + pretty_assertions::assert_eq!( + env_validation.issues(), + &[EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoCommitMessageTooShort(String::from("")) + ),] + ); +} + +#[test] +fn test_simple_github() { + let run_id = String::from("42069"); + let actor = String::from("username"); + let repository = String::from("test/tester"); + let branch = String::from("some-branch-name"); + + let env_vars = EnvVars::from_iter( + vec![ + (String::from("GITHUB_ACTIONS"), String::from("true")), + (String::from("GITHUB_RUN_ID"), String::from(&run_id)), + (String::from("GITHUB_ACTOR"), String::from(&actor)), + (String::from("GITHUB_REPOSITORY"), String::from(&repository)), + ( + String::from("GITHUB_REF"), + format!("refs/heads/origin/{branch}"), + ), + ] + .into_iter(), + ); + + let mut env_parser = EnvParser::new(); + env_parser.parse(&env_vars).unwrap(); + + let ci_info = env_parser.into_ci_info_parser().unwrap().info_ci_info(); + + pretty_assertions::assert_eq!( + ci_info, + CIInfo { + platform: CIPlatform::GitHubActions, + job_url: Some(format!( + "https://github.com/{repository}/actions/runs/{run_id}" + )), + branch: Some(branch), + branch_class: None, + pr_number: None, + actor: Some(actor), + committer_name: None, + committer_email: None, + author_name: None, + author_email: None, + commit_message: None, + title: None, + } + ); + + let env_validation = env::validator::validate(&ci_info); + assert_eq!(env_validation.max_level(), EnvValidationLevel::SubOptimal); + pretty_assertions::assert_eq!( + env_validation.issues(), + &[ + EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoAuthorEmailTooShort(String::from(""),), + ), + EnvValidationIssue::SubOptimal(EnvValidationIssueSubOptimal::CIInfoAuthorNameTooShort( + String::from(""), + ),), + EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoCommitMessageTooShort(String::from(""),), + ), + EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoCommitterEmailTooShort(String::from(""),), + ), + EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoCommitterNameTooShort(String::from(""),), + ), + EnvValidationIssue::SubOptimal(EnvValidationIssueSubOptimal::CIInfoTitleTooShort( + String::from(""), + ),), + ] + ); +} diff --git a/context/tests/junit.rs b/context/tests/junit.rs new file mode 100644 index 00000000..887a96d9 --- /dev/null +++ b/context/tests/junit.rs @@ -0,0 +1,308 @@ +use std::io::BufReader; + +use chrono::{NaiveTime, TimeDelta, Utc}; +use junit_mock::JunitMock; +use quick_junit::Report; + +use context::junit::{ + self, + parser::JunitParser, + validator::{ + JunitTestCaseValidationIssue, JunitTestCaseValidationIssueInvalid, + JunitTestCaseValidationIssueSubOptimal, JunitTestSuiteValidationIssue, + JunitTestSuiteValidationIssueInvalid, JunitTestSuiteValidationIssueSubOptimal, + JunitValidationIssue, JunitValidationLevel, + }, +}; + +fn generate_mock_junit_reports( + report_count: usize, + test_suite_count: Option, + test_case_count: Option, +) -> (u64, Vec) { + let mut options = junit_mock::Options::default(); + options.global.timestamp = Utc::now() + .fixed_offset() + .checked_sub_signed(TimeDelta::hours(1)); + options.report.report_random_count = report_count; + // NOTE: Large JUnit.xml files make `pretty_assertions::assert_eq` choke when showing diffs + options.test_suite.test_suite_random_count = test_suite_count.map(|c| c.min(5)).unwrap_or(1); + options.test_case.test_case_random_count = test_case_count.map(|c| c.min(10)).unwrap_or(10); + + let mut jm = JunitMock::new(options); + let seed = jm.get_seed(); + let reports = jm.generate_reports(); + (seed, reports) +} + +fn serialize_report(report: &Report) -> Vec { + let mut serialized_report = Vec::new(); + report.serialize(&mut serialized_report).unwrap(); + serialized_report +} + +fn parse_report>(serialized_report: T) -> Report { + let mut junit_parser = JunitParser::new(); + junit_parser + .parse(BufReader::new(&serialized_report.as_ref()[..])) + .unwrap(); + + assert_eq!(junit_parser.errors(), &[]); + + let mut parsed_reports = junit_parser.into_reports(); + assert_eq!(parsed_reports.len(), 1); + + parsed_reports.pop().unwrap() +} + +#[test] +fn validate_test_suite_name_too_short() { + let (seed, mut generated_reports) = generate_mock_junit_reports(1, Some(1), None); + let mut generated_report = generated_reports.pop().unwrap(); + + for test_suite in &mut generated_report.test_suites { + test_suite.name = String::new().into(); + } + + let report_validation = junit::validator::validate(&generated_report); + + assert_eq!( + report_validation.max_level(), + JunitValidationLevel::Invalid, + "failed to validate with seed `{}`", + seed, + ); + + pretty_assertions::assert_eq!( + report_validation + .test_suites() + .iter() + .flat_map(|test_suite| Vec::from(test_suite.issues())) + .collect::>(), + vec![JunitValidationIssue::Invalid( + JunitTestSuiteValidationIssueInvalid::TestSuiteNameTooShort(String::new()), + )], + "failed to validate with seed `{}`", + seed, + ); +} + +#[test] +fn validate_test_case_name_too_short() { + let (seed, mut generated_reports) = generate_mock_junit_reports(1, Some(1), Some(1)); + let mut generated_report = generated_reports.pop().unwrap(); + + for test_suite in &mut generated_report.test_suites { + for test_case in &mut test_suite.test_cases { + test_case.name = String::new().into(); + } + } + + let report_validation = junit::validator::validate(&generated_report); + + assert_eq!( + report_validation.max_level(), + JunitValidationLevel::Invalid, + "failed to validate with seed `{}`", + seed, + ); + + pretty_assertions::assert_eq!( + report_validation + .test_suites() + .iter() + .flat_map(|test_suite| test_suite.test_cases()) + .flat_map(|test_case| Vec::from(test_case.issues())) + .collect::>(), + vec![JunitValidationIssue::Invalid( + JunitTestCaseValidationIssueInvalid::TestCaseNameTooShort(String::new()), + )], + "failed to validate with seed `{}`", + seed, + ); +} + +#[test] +fn validate_test_suite_name_too_long() { + let (seed, mut generated_reports) = generate_mock_junit_reports(1, Some(1), None); + let mut generated_report = generated_reports.pop().unwrap(); + + for test_suite in &mut generated_report.test_suites { + test_suite.name = "a".repeat(junit::validator::MAX_FIELD_LEN + 1).into(); + } + + let report_validation = junit::validator::validate(&generated_report); + + assert_eq!( + report_validation.max_level(), + JunitValidationLevel::SubOptimal, + "failed to validate with seed `{}`", + seed, + ); + + pretty_assertions::assert_eq!( + report_validation + .test_suites() + .iter() + .flat_map(|test_suite| Vec::from(test_suite.issues())) + .collect::>(), + vec![JunitValidationIssue::SubOptimal( + JunitTestSuiteValidationIssueSubOptimal::TestSuiteNameTooLong( + "a".repeat(junit::validator::MAX_FIELD_LEN) + ), + )], + "failed to validate with seed `{}`", + seed, + ); +} + +#[test] +fn validate_test_case_name_too_long() { + let (seed, mut generated_reports) = generate_mock_junit_reports(1, Some(1), Some(1)); + let mut generated_report = generated_reports.pop().unwrap(); + + for test_suite in &mut generated_report.test_suites { + for test_case in &mut test_suite.test_cases { + test_case.name = "a".repeat(junit::validator::MAX_FIELD_LEN + 1).into(); + } + } + + let report_validation = junit::validator::validate(&generated_report); + + assert_eq!( + report_validation.max_level(), + JunitValidationLevel::SubOptimal, + "failed to validate with seed `{}`", + seed, + ); + + pretty_assertions::assert_eq!( + report_validation + .test_suites() + .iter() + .flat_map(|test_suite| test_suite.test_cases()) + .flat_map(|test_case| Vec::from(test_case.issues())) + .collect::>(), + vec![JunitValidationIssue::SubOptimal( + JunitTestCaseValidationIssueSubOptimal::TestCaseNameTooLong( + "a".repeat(junit::validator::MAX_FIELD_LEN) + ), + )], + "failed to validate with seed `{}`", + seed, + ); +} + +#[test] +fn validate_max_level() { + let (seed, mut generated_reports) = generate_mock_junit_reports(1, Some(1), Some(1)); + let mut generated_report = generated_reports.pop().unwrap(); + + for test_suite in &mut generated_report.test_suites { + test_suite.name = "a".repeat(junit::validator::MAX_FIELD_LEN + 1).into(); + for test_case in &mut test_suite.test_cases { + test_case.name = String::new().into(); + } + } + + let report_validation = junit::validator::validate(&generated_report); + + assert_eq!( + report_validation.max_level(), + JunitValidationLevel::Invalid, + "failed to validate with seed `{}`", + seed, + ); + + pretty_assertions::assert_eq!( + report_validation + .test_suites() + .iter() + .flat_map(|test_suite| Vec::from(test_suite.issues())) + .collect::>(), + vec![JunitValidationIssue::SubOptimal( + JunitTestSuiteValidationIssueSubOptimal::TestSuiteNameTooLong( + "a".repeat(junit::validator::MAX_FIELD_LEN) + ), + )], + "failed to validate with seed `{}`", + seed, + ); + + pretty_assertions::assert_eq!( + report_validation + .test_suites() + .iter() + .flat_map(|test_suite| test_suite.test_cases()) + .flat_map(|test_case| Vec::from(test_case.issues())) + .collect::>(), + vec![JunitValidationIssue::Invalid( + JunitTestCaseValidationIssueInvalid::TestCaseNameTooShort(String::new()), + )], + "failed to validate with seed `{}`", + seed, + ); +} + +#[test] +fn parse_naive_date() { + let (seed, mut generated_reports) = generate_mock_junit_reports(1, Some(0), Some(0)); + + let mut generated_report = generated_reports.pop().unwrap(); + generated_report.timestamp = None; + + let naive_date = Utc::now() + .fixed_offset() + .with_time(NaiveTime::default()) + .unwrap(); + let serialized_generated_report = + String::from_utf8_lossy(&serialize_report(&mut generated_report)).replace( + "