use anyhow::{bail, Result};
use async_trait::async_trait;
use chrono::{DateTime, Duration, Utc};
use evg_api_rs::models::stats::{EvgTestStats, EvgTestStatsRequest};
use evg_api_rs::EvgApiClient;
use std::collections::HashMap;
use std::fmt::{Display, Formatter};
use std::sync::Arc;
const HOOK_DELIMITER: char = ':';
#[derive(Debug, Clone)]
pub struct HookRuntimeHistory {
pub test_name: String,
pub hook_name: String,
pub average_runtime: f64,
}
impl Display for HookRuntimeHistory {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}:{} : {}",
self.test_name, self.hook_name, self.average_runtime
)
}
}
#[derive(Debug, Clone)]
pub struct TestRuntimeHistory {
pub test_name: String,
pub average_runtime: f64,
pub hooks: Vec<HookRuntimeHistory>,
}
impl Display for TestRuntimeHistory {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
writeln!(f, "{}: {}", self.test_name, self.average_runtime)?;
for hook in &self.hooks {
writeln!(f, "- {}", hook)?;
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct TaskRuntimeHistory {
pub task_name: String,
pub test_map: HashMap<String, TestRuntimeHistory>,
}
#[async_trait]
pub trait TaskHistoryService: Send + Sync {
async fn get_task_history(&self, task: &str, variant: &str) -> Result<TaskRuntimeHistory>;
}
pub struct TaskHistoryServiceImpl {
evg_client: Arc<dyn EvgApiClient>,
lookback_days: u64,
evg_project: String,
}
impl TaskHistoryServiceImpl {
pub fn new(evg_client: Arc<dyn EvgApiClient>, lookback_days: u64, evg_project: String) -> Self {
Self {
evg_client,
lookback_days,
evg_project,
}
}
}
#[async_trait]
impl TaskHistoryService for TaskHistoryServiceImpl {
async fn get_task_history(&self, task: &str, variant: &str) -> Result<TaskRuntimeHistory> {
let today = Utc::now();
let lookback = Duration::days(self.lookback_days as i64);
let start_date = today - lookback;
let request = EvgTestStatsRequest {
after_date: date_to_string(&start_date),
before_date: date_to_string(&today),
group_num_days: self.lookback_days,
variants: variant.to_string(),
tasks: task.to_string(),
tests: None,
};
let stats = self
.evg_client
.get_test_stats(&self.evg_project, &request)
.await;
if let Ok(stats) = stats {
let hook_map = gather_hook_stats(&stats);
let test_map = gather_test_stats(&stats, &hook_map);
Ok(TaskRuntimeHistory {
task_name: task.to_string(),
test_map,
})
} else {
bail!("Error from evergreen: {:?}", stats)
}
}
}
fn gather_test_stats(
stat_list: &[EvgTestStats],
hook_map: &HashMap<String, Vec<HookRuntimeHistory>>,
) -> HashMap<String, TestRuntimeHistory> {
let mut test_map: HashMap<String, TestRuntimeHistory> = HashMap::new();
for stat in stat_list {
let normalized_test_file = normalize_test_file(&stat.test_file);
if !is_hook(&normalized_test_file) {
let test_name = get_test_name(&normalized_test_file);
if let Some(v) = test_map.get_mut(&test_name) {
v.test_name = normalized_test_file;
v.average_runtime += stat.avg_duration_pass;
} else {
test_map.insert(
test_name.clone(),
TestRuntimeHistory {
test_name: normalized_test_file,
average_runtime: stat.avg_duration_pass,
hooks: hook_map
.get(&test_name.to_string())
.unwrap_or(&vec![])
.clone(),
},
);
}
}
}
test_map
}
fn gather_hook_stats(stat_list: &[EvgTestStats]) -> HashMap<String, Vec<HookRuntimeHistory>> {
let mut hook_map: HashMap<String, Vec<HookRuntimeHistory>> = HashMap::new();
for stat in stat_list {
let normalized_test_file = normalize_test_file(&stat.test_file);
if is_hook(&normalized_test_file) {
let test_name = hook_test_name(&normalized_test_file);
let hook_name = hook_hook_name(&normalized_test_file);
if let Some(v) = hook_map.get_mut(&test_name.to_string()) {
v.push(HookRuntimeHistory {
test_name: test_name.to_string(),
hook_name: hook_name.to_string(),
average_runtime: stat.avg_duration_pass,
});
} else {
hook_map.insert(
test_name.to_string(),
vec![HookRuntimeHistory {
test_name: test_name.to_string(),
hook_name: hook_name.to_string(),
average_runtime: stat.avg_duration_pass,
}],
);
}
}
}
hook_map
}
fn date_to_string(date: &DateTime<Utc>) -> String {
date.format("%Y-%m-%d").to_string()
}
fn is_hook(identifier: &str) -> bool {
identifier.contains(HOOK_DELIMITER)
}
fn hook_test_name(identifier: &str) -> &str {
identifier.split(HOOK_DELIMITER).next().unwrap()
}
fn hook_hook_name(identifier: &str) -> &str {
identifier.split(HOOK_DELIMITER).last().unwrap()
}
fn normalize_test_file(test_file: &str) -> String {
test_file.replace('\\', "/")
}
pub fn get_test_name(test_file: &str) -> String {
let s = test_file.split('/');
s.last().unwrap().trim_end_matches(".js").to_string()
}
#[cfg(test)]
mod tests {
use evg_api_rs::{models::task::EvgTask, BoxedStream, EvgError};
use rstest::rstest;
use simple_error::SimpleError;
use super::*;
#[rstest]
#[case("some/random/test", false)]
#[case("some/random/test:hook1", true)]
fn test_is_hook(#[case] hook_name: &str, #[case] expected_is_hook: bool) {
assert_eq!(is_hook(hook_name), expected_is_hook);
}
#[test]
fn test_hook_test_name() {
assert_eq!(hook_test_name("my_test:my_hook"), "my_test");
}
#[test]
fn test_hook_hook_name() {
assert_eq!(hook_hook_name("my_test:my_hook"), "my_hook");
}
#[rstest]
#[case("jstests\\core\\add1.js", "jstests/core/add1.js")]
#[case("jstests\\core\\add1", "jstests/core/add1")]
#[case("jstests/core/add1.js", "jstests/core/add1.js")]
#[case("jstests/core/add1", "jstests/core/add1")]
fn test_normalize_tests(#[case] test_file: &str, #[case] expected_name: &str) {
let normalized_name = normalize_test_file(test_file);
assert_eq!(&normalized_name, expected_name);
}
#[rstest]
#[case("jstests/core/add1.js", "add1")]
#[case("jstests/core/add1", "add1")]
#[case("add1.js", "add1")]
fn test_get_test_name(#[case] test_file: &str, #[case] expected_name: &str) {
assert_eq!(get_test_name(test_file), expected_name.to_string());
}
#[tokio::test]
async fn test_get_task_history_should_fail_if_evg_call_fails() {
let mock_evg_client = MockEvgClient { return_error: true };
let task_history_service =
TaskHistoryServiceImpl::new(Arc::new(mock_evg_client), 14, "my-project".to_string());
let result = task_history_service
.get_task_history("my_task", "my_variant")
.await;
assert!(result.is_err());
}
struct MockEvgClient {
return_error: bool,
}
#[async_trait]
impl EvgApiClient for MockEvgClient {
async fn get_task(&self, _task_id: &str) -> Result<EvgTask, EvgError> {
todo!()
}
async fn get_version(
&self,
_version_id: &str,
) -> Result<evg_api_rs::models::version::EvgVersion, EvgError> {
todo!()
}
async fn get_build(
&self,
_build_id: &str,
) -> Result<Option<evg_api_rs::models::build::EvgBuild>, EvgError> {
todo!()
}
async fn get_tests(
&self,
_task_id: &str,
) -> Result<Vec<evg_api_rs::models::test::EvgTest>, EvgError> {
todo!()
}
async fn get_test_stats(
&self,
_project_id: &str,
_query: &EvgTestStatsRequest,
) -> Result<Vec<EvgTestStats>, EvgError> {
if self.return_error {
Err(Box::new(SimpleError::new("Error from evergreen")))
} else {
todo!()
}
}
async fn get_task_stats(
&self,
_project_id: &str,
_query: &evg_api_rs::models::stats::EvgTaskStatsRequest,
) -> Result<Vec<evg_api_rs::models::stats::EvgTaskStats>, EvgError> {
todo!()
}
fn stream_versions(
&self,
_project_id: &str,
) -> BoxedStream<evg_api_rs::models::version::EvgVersion> {
todo!()
}
fn stream_user_patches(
&self,
_user_id: &str,
_limit: Option<usize>,
) -> BoxedStream<evg_api_rs::models::patch::EvgPatch> {
todo!()
}
fn stream_project_patches(
&self,
_project_id: &str,
_limit: Option<usize>,
) -> BoxedStream<evg_api_rs::models::patch::EvgPatch> {
todo!()
}
fn stream_build_tasks(
&self,
_build_id: &str,
_status: Option<&str>,
) -> BoxedStream<evg_api_rs::models::task::EvgTask> {
todo!()
}
fn stream_log(
&self,
_task: &evg_api_rs::models::task::EvgTask,
_log_name: &str,
) -> BoxedStream<String> {
todo!()
}
fn stream_test_log(
&self,
_test: &evg_api_rs::models::test::EvgTest,
) -> BoxedStream<String> {
todo!()
}
}
}