use indexmap::IndexMap;
use serde_json;
use std::{collections::HashMap, fs, path::Path};
use tanu_core::{
eyre, http,
runner::{self, Test},
ModuleName, ProjectName, Reporter, TestName,
};
use crate::models::{
generate_history_id, History, HistoryItem, HistoryTime, Label, Parameter, ParameterMode, Stage,
Status, StatusDetails, Step, TestResult, MAX_HISTORY_ITEMS,
};
fn to_status(status: http::StatusCode) -> Status {
if status.is_success() {
Status::Passed
} else if status.is_client_error() || status.is_server_error() {
Status::Failed
} else {
Status::Broken
}
}
fn to_test_status(test: &Test) -> Status {
match &test.result {
Ok(_) => Status::Passed,
Err(runner::Error::ErrorReturned(_)) => Status::Failed,
Err(runner::Error::Panicked(_)) => Status::Broken,
}
}
fn system_time_to_unix_millis(time: std::time::SystemTime) -> i64 {
time.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
fn push_header_parameters(
parameters: &mut Vec<Parameter>,
prefix: &str,
headers: &http::header::HeaderMap,
) {
for (name, value) in headers.iter() {
let header_name = name.as_str();
let is_sensitive = matches!(
header_name,
"authorization"
| "proxy-authorization"
| "cookie"
| "set-cookie"
| "x-api-key"
| "x-auth-token"
);
let (value, mode) = if is_sensitive {
("<masked>".to_string(), Some(ParameterMode::Masked))
} else {
(String::from_utf8_lossy(value.as_bytes()).into_owned(), None)
};
parameters.push(Parameter {
name: format!("{prefix}.{header_name}"),
value,
excluded: None,
mode,
});
}
}
pub struct AllureReporter {
pub results_dir: String,
buffer: IndexMap<(ProjectName, ModuleName, TestName), Buffer>,
history: History,
current_run_results: Vec<RunResult>,
environment: HashMap<String, String>,
}
struct RunResult {
history_id: String,
status: Status,
status_details: Option<String>,
start: i64,
stop: i64,
uuid: String,
}
enum Event {
Check(Box<runner::Check>),
Http(Box<http::Log>),
}
impl From<&Event> for Step {
fn from(event: &Event) -> Self {
match event {
Event::Check(check) => {
let now = system_time_to_unix_millis(std::time::SystemTime::now());
Step {
name: strip_ansi_escapes::strip_str(&check.expr),
parameters: Default::default(),
attachments: Default::default(),
status: if check.result {
Status::Passed
} else {
Status::Failed
},
status_details: Default::default(),
stage: Some(Stage::Finished),
start: Some(now),
stop: Some(now),
steps: vec![],
}
}
Event::Http(log) => Step {
name: log.request.url.to_string(),
parameters: {
let mut parameters = Vec::new();
push_header_parameters(&mut parameters, "request.header", &log.request.headers);
push_header_parameters(
&mut parameters,
"response.header",
&log.response.headers,
);
parameters
},
attachments: Default::default(),
status: to_status(log.response.status),
status_details: Default::default(),
stage: Some(Stage::Finished),
start: Some(system_time_to_unix_millis(log.started_at)),
stop: Some(system_time_to_unix_millis(log.ended_at)),
steps: vec![],
},
}
}
}
#[derive(Default)]
struct Buffer {
events: Vec<Event>,
}
impl Default for AllureReporter {
fn default() -> Self {
AllureReporter::new()
}
}
impl AllureReporter {
pub fn new() -> Self {
Self::with_results_dir("allure-results")
}
pub fn with_results_dir(results_dir: impl Into<String>) -> Self {
let results_dir = results_dir.into();
let history = Self::load_history(&results_dir);
let environment = Self::initialize_environment();
AllureReporter {
results_dir,
buffer: IndexMap::new(),
history,
current_run_results: Vec::new(),
environment,
}
}
fn initialize_environment() -> HashMap<String, String> {
let mut environment = Self::load_default_environment();
Self::load_env_with_prefix(&mut environment, "TANU_ALLURE_");
environment
}
fn load_default_environment() -> HashMap<String, String> {
let mut environment = HashMap::new();
environment.insert("os_platform".to_string(), std::env::consts::OS.to_string());
environment.insert("os_arch".to_string(), std::env::consts::ARCH.to_string());
environment.insert(
"tanu_allure_version".to_string(),
env!("CARGO_PKG_VERSION").to_string(),
);
environment
}
fn load_env_with_prefix(environment: &mut HashMap<String, String>, prefix: &str) {
for (key, value) in std::env::vars() {
if let Some(stripped_key) = key.strip_prefix(prefix) {
environment.insert(stripped_key.to_string(), value);
}
}
}
pub fn add_environment(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.environment.insert(key.into(), value.into());
}
pub fn set_environment(&mut self, env: HashMap<String, String>) {
self.environment.extend(env);
}
pub fn load_from_env(&mut self, prefix: &str) {
Self::load_env_with_prefix(&mut self.environment, prefix);
}
fn load_history(results_dir: &str) -> History {
let path = Path::new(results_dir).join("history").join("history.json");
if !path.exists() {
return History::new();
}
fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn ensure_results_dir(&self) -> eyre::Result<()> {
let path = Path::new(&self.results_dir);
if !path.exists() {
fs::create_dir_all(path)?;
}
Ok(())
}
fn map_to_allure_test_result(
&self,
project: &str,
module: &str,
test_name: &str,
events: &[Event],
test: &Test,
) -> TestResult {
let status = to_test_status(test);
let status_details = if let Err(e) = &test.result {
Some(StatusDetails {
known: None,
muted: None,
flaky: None,
message: Some(strip_ansi_escapes::strip_str(e.to_string())),
trace: None,
})
} else {
None
};
let steps: Vec<_> = events.iter().map(Step::from).collect();
let parameters = vec![Parameter {
name: "Project".to_string(),
value: project.to_string(),
excluded: Some(true), mode: Default::default(),
}];
let history_id = generate_history_id(project, module, test_name, ¶meters);
TestResult {
uuid: uuid::Uuid::new_v4(),
history_id,
test_case_id: Default::default(),
name: test_name.to_string(),
full_name: Some(format!("{project}::{module}::{test_name}")),
description: Default::default(),
description_html: Default::default(),
links: Default::default(),
labels: vec![
Label::ParentSuite(project.to_string()),
Label::Suite(module.to_string()),
Label::Host(
hostname::get()
.map(|h| h.to_string_lossy().into_owned())
.unwrap_or_else(|_| "unknown".to_string()),
),
Label::Thread(test.worker_id.to_string()),
],
parameters,
attachments: Default::default(),
status,
status_details,
stage: Some(Stage::Finished),
start: Some(system_time_to_unix_millis(test.started_at)),
stop: Some(system_time_to_unix_millis(test.ended_at)),
steps,
}
}
}
#[async_trait::async_trait]
impl Reporter for AllureReporter {
async fn on_check(
&mut self,
project_name: String,
module_name: String,
test_name: String,
check: Box<runner::Check>,
) -> eyre::Result<()> {
self.buffer
.entry((project_name, module_name, test_name))
.or_default()
.events
.push(Event::Check(check));
Ok(())
}
async fn on_call(
&mut self,
project_name: String,
module_name: String,
test_name: String,
log: runner::CallLog,
) -> eyre::Result<()> {
if let runner::CallLog::Http(http_log) = log {
self.buffer
.entry((project_name, module_name, test_name))
.or_default()
.events
.push(Event::Http(http_log));
}
Ok(())
}
async fn on_end(
&mut self,
project: String,
module: String,
test_name: String,
test: Test,
) -> eyre::Result<()> {
self.ensure_results_dir()?;
let buffer = self
.buffer
.get(&(project.clone(), module.clone(), test_name.clone()))
.ok_or_else(|| eyre::eyre!("test case \"{test_name}\" not found in the buffer"))?;
let test_result =
self.map_to_allure_test_result(&project, &module, &test_name, &buffer.events, &test);
let file_name = format!("{}-result.json", test_result.uuid);
let file_path = Path::new(&self.results_dir).join(file_name);
let json = serde_json::to_string_pretty(&test_result)?;
fs::write(file_path, json)?;
self.current_run_results.push(RunResult {
history_id: test_result.history_id.clone(),
status: test_result.status.clone(),
status_details: test_result
.status_details
.as_ref()
.and_then(|d| d.message.clone()),
start: test_result.start.unwrap_or(0),
stop: test_result.stop.unwrap_or(0),
uuid: test_result.uuid.to_string(),
});
Ok(())
}
async fn on_summary(&mut self, _summary: runner::TestSummary) -> eyre::Result<()> {
self.write_history()?;
self.write_environment()?;
Ok(())
}
}
impl AllureReporter {
fn write_history(&mut self) -> eyre::Result<()> {
for result in &self.current_run_results {
let entry = self.history.entry(result.history_id.clone()).or_default();
entry.statistic.record(&result.status);
entry.items.insert(
0,
HistoryItem {
uid: result.uuid.clone(),
report_url: None,
status: result.status.clone(),
status_details: result.status_details.clone(),
time: HistoryTime {
start: result.start,
stop: result.stop,
duration: (result.stop - result.start) / 1000,
},
},
);
entry.items.truncate(MAX_HISTORY_ITEMS);
}
let history_dir = Path::new(&self.results_dir).join("history");
fs::create_dir_all(&history_dir)?;
let json = serde_json::to_string_pretty(&self.history)?;
fs::write(history_dir.join("history.json"), json)?;
Ok(())
}
fn write_environment(&self) -> eyre::Result<()> {
if self.environment.is_empty() {
return Ok(());
}
self.ensure_results_dir()?;
let mut lines: Vec<String> = self
.environment
.iter()
.map(|(key, value)| {
let escaped_key = key
.replace('\\', "\\\\")
.replace('=', "\\=")
.replace(':', "\\:");
let escaped_value = value
.replace('\\', "\\\\")
.replace('\n', "\\n")
.replace('\r', "\\r");
format!("{} = {}", escaped_key, escaped_value)
})
.collect();
lines.sort();
let file_path = Path::new(&self.results_dir).join("environment.properties");
fs::write(file_path, lines.join("\n"))?;
Ok(())
}
}