1use std::backtrace::Backtrace;
7use std::cell::RefCell;
8use std::panic::{catch_unwind, AssertUnwindSafe};
9use std::sync::OnceLock;
10#[cfg(feature = "tokio")]
11use std::sync::{Arc, Mutex};
12
13#[cfg(feature = "tokio")]
14type SharedAsyncContext = Arc<Mutex<Option<TestContext>>>;
15#[cfg(feature = "tokio")]
16type GlobalAsyncContexts = Mutex<Vec<SharedAsyncContext>>;
17
18use crate::enums::{ContentType, LabelName, LinkType, Severity, Status};
19use crate::model::{Attachment, Label, Parameter, StepResult, TestResult, TestResultContainer};
20use crate::writer::{compute_history_id, generate_uuid, AllureWriter};
21
22static CONFIG: OnceLock<AllureConfig> = OnceLock::new();
24
25#[cfg(feature = "tokio")]
26tokio::task_local! {
27 static TOKIO_CONTEXT: RefCell<Option<SharedAsyncContext>>;
28}
29
30#[cfg(feature = "tokio")]
31fn global_async_context() -> &'static GlobalAsyncContexts {
32 static GLOBAL: OnceLock<GlobalAsyncContexts> = OnceLock::new();
33 GLOBAL.get_or_init(|| Mutex::new(Vec::new()))
34}
35
36#[cfg(feature = "tokio")]
37fn lock_unpoisoned<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
38 mutex.lock().unwrap_or_else(|e| e.into_inner())
39}
40
41#[cfg(feature = "tokio")]
42fn register_global_context(handle: SharedAsyncContext) {
43 let mut handles = lock_unpoisoned(global_async_context());
44 handles.push(handle);
45}
46
47#[cfg(feature = "tokio")]
48fn unregister_global_context(handle: &SharedAsyncContext) {
49 let mut handles = lock_unpoisoned(global_async_context());
50 handles.retain(|candidate| !Arc::ptr_eq(candidate, handle));
51}
52
53#[cfg(feature = "tokio")]
54fn current_global_context() -> Option<SharedAsyncContext> {
55 let handles = lock_unpoisoned(global_async_context());
56 if handles.len() == 1 {
57 Some(handles[0].clone())
58 } else {
59 None
60 }
61}
62
63#[cfg(feature = "tokio")]
64struct GlobalContextRegistration {
65 handle: SharedAsyncContext,
66}
67
68#[cfg(feature = "tokio")]
69impl GlobalContextRegistration {
70 fn new(handle: SharedAsyncContext) -> Self {
71 register_global_context(handle.clone());
72 Self { handle }
73 }
74}
75
76#[cfg(feature = "tokio")]
77impl Drop for GlobalContextRegistration {
78 fn drop(&mut self) {
79 unregister_global_context(&self.handle);
80 }
81}
82
83#[derive(Debug, Clone)]
85pub struct AllureConfig {
86 pub results_dir: String,
88 pub clean_results: bool,
90}
91
92impl Default for AllureConfig {
93 fn default() -> Self {
94 Self {
95 results_dir: crate::writer::DEFAULT_RESULTS_DIR.to_string(),
96 clean_results: true,
97 }
98 }
99}
100
101#[derive(Debug, Default)]
103pub struct AllureConfigBuilder {
104 config: AllureConfig,
105}
106
107impl AllureConfigBuilder {
108 pub fn new() -> Self {
110 Self::default()
111 }
112
113 pub fn results_dir(mut self, path: impl Into<String>) -> Self {
115 self.config.results_dir = path.into();
116 self
117 }
118
119 pub fn clean_results(mut self, clean: bool) -> Self {
121 self.config.clean_results = clean;
122 self
123 }
124
125 pub fn init(self) -> std::io::Result<()> {
127 let writer = AllureWriter::with_results_dir(&self.config.results_dir);
128 writer.init(self.config.clean_results)?;
129 CONFIG.set(self.config).ok();
130 Ok(())
131 }
132}
133
134pub fn configure() -> AllureConfigBuilder {
136 AllureConfigBuilder::new()
137}
138
139pub fn get_config() -> AllureConfig {
141 CONFIG.get().cloned().unwrap_or_default()
142}
143
144#[derive(Debug)]
146pub struct TestContext {
147 pub result: TestResult,
149 pub step_stack: Vec<StepResult>,
151 pub writer: AllureWriter,
153}
154
155impl TestContext {
156 pub fn new(name: impl Into<String>, full_name: impl Into<String>) -> Self {
158 let config = get_config();
159 let uuid = generate_uuid();
160 let mut result = TestResult::new(uuid, name.into());
161 result.full_name = Some(full_name.into());
162
163 result.labels.push(Label::language("rust"));
165 result.labels.push(Label::framework("allure-rs"));
166
167 if let Ok(hostname) = std::env::var("HOSTNAME") {
169 result.labels.push(Label::host(hostname));
170 } else if let Ok(hostname) = hostname::get() {
171 if let Some(name) = hostname.to_str() {
172 result.labels.push(Label::host(name));
173 }
174 }
175
176 let thread_id = format!("{:?}", std::thread::current().id());
177 result.labels.push(Label::thread(thread_id));
178
179 Self {
180 result,
181 step_stack: Vec::new(),
182 writer: AllureWriter::with_results_dir(config.results_dir),
183 }
184 }
185
186 pub fn add_label(&mut self, name: impl Into<String>, value: impl Into<String>) {
188 self.result.add_label(name, value);
189 }
190
191 pub fn add_label_name(&mut self, name: LabelName, value: impl Into<String>) {
193 self.result.add_label_name(name, value);
194 }
195
196 pub fn add_link(&mut self, url: impl Into<String>, name: Option<String>, link_type: LinkType) {
198 self.result.add_link(url, name, link_type);
199 }
200
201 pub fn add_parameter(&mut self, name: impl Into<String>, value: impl Into<String>) {
203 if let Some(step) = self.step_stack.last_mut() {
204 step.add_parameter(name, value);
205 } else {
206 self.result.add_parameter(name, value);
207 }
208 }
209
210 pub fn add_parameter_struct(&mut self, parameter: Parameter) {
212 if let Some(step) = self.step_stack.last_mut() {
213 step.parameters.push(parameter);
214 } else {
215 self.result.parameters.push(parameter);
216 }
217 }
218
219 pub fn add_attachment(&mut self, attachment: Attachment) {
221 if let Some(step) = self.step_stack.last_mut() {
222 step.add_attachment(attachment);
223 } else {
224 self.result.add_attachment(attachment);
225 }
226 }
227
228 pub fn start_step(&mut self, name: impl Into<String>) {
230 let step = StepResult::new(name);
231 self.step_stack.push(step);
232 }
233
234 pub fn finish_step(&mut self, status: Status, message: Option<String>, trace: Option<String>) {
236 if let Some(mut step) = self.step_stack.pop() {
237 match status {
238 Status::Passed => step.pass(),
239 Status::Failed => step.fail(message, trace),
240 Status::Broken => step.broken(message, trace),
241 _ => {
242 step.status = status;
243 step.stage = crate::enums::Stage::Finished;
244 step.stop = crate::model::current_time_ms();
245 }
246 }
247
248 if let Some(parent_step) = self.step_stack.last_mut() {
250 parent_step.add_step(step);
251 } else {
252 self.result.add_step(step);
253 }
254 }
255 }
256
257 pub fn compute_history_id(&mut self) {
259 if let Some(ref full_name) = self.result.full_name {
260 let history_id = compute_history_id(full_name, &self.result.parameters);
261 self.result.history_id = Some(history_id);
262 }
263 }
264
265 pub fn finish(&mut self, status: Status, message: Option<String>, trace: Option<String>) {
267 while !self.step_stack.is_empty() {
269 self.finish_step(Status::Broken, Some("Step not completed".to_string()), None);
270 }
271
272 self.compute_history_id();
274
275 match status {
276 Status::Passed => self.result.pass(),
277 Status::Failed => self.result.fail(message, trace),
278 Status::Broken => self.result.broken(message, trace),
279 Status::Skipped => {
280 if message.is_some() || trace.is_some() {
281 self.result.status_details = Some(crate::model::StatusDetails {
282 message,
283 trace,
284 ..Default::default()
285 });
286 }
287 self.result.status = status;
288 self.result.finish();
289 }
290 _ => {
291 self.result.status = status;
292 self.result.finish();
293 }
294 }
295
296 if let Err(e) = self.writer.write_test_result(&self.result) {
298 eprintln!("Failed to write Allure test result: {}", e);
299 }
300
301 let mut container = TestResultContainer::new(generate_uuid());
303 container.children.push(self.result.uuid.clone());
304 container.start = Some(self.result.start);
305 container.stop = Some(self.result.stop);
306 if let Err(e) = self.writer.write_container(&container) {
307 eprintln!("Failed to write Allure container: {}", e);
308 }
309 }
310
311 pub fn attach_text(&mut self, name: impl Into<String>, content: impl AsRef<str>) {
313 match self.writer.write_text_attachment(name, content) {
314 Ok(attachment) => self.add_attachment(attachment),
315 Err(e) => eprintln!("Failed to write text attachment: {}", e),
316 }
317 }
318
319 pub fn attach_json<T: serde::Serialize>(&mut self, name: impl Into<String>, value: &T) {
321 match self.writer.write_json_attachment(name, value) {
322 Ok(attachment) => self.add_attachment(attachment),
323 Err(e) => eprintln!("Failed to write JSON attachment: {}", e),
324 }
325 }
326
327 pub fn attach_binary(
329 &mut self,
330 name: impl Into<String>,
331 content: &[u8],
332 content_type: ContentType,
333 ) {
334 match self
335 .writer
336 .write_binary_attachment(name, content, content_type)
337 {
338 Ok(attachment) => self.add_attachment(attachment),
339 Err(e) => eprintln!("Failed to write binary attachment: {}", e),
340 }
341 }
342
343 pub fn attach_file(
345 &mut self,
346 name: impl Into<String>,
347 path: impl AsRef<std::path::Path>,
348 content_type: Option<ContentType>,
349 ) {
350 match self.writer.copy_file_attachment(name, path, content_type) {
351 Ok(attachment) => self.add_attachment(attachment),
352 Err(e) => eprintln!("Failed to copy file attachment: {}", e),
353 }
354 }
355}
356
357thread_local! {
359 static CURRENT_CONTEXT: RefCell<Option<TestContext>> = const { RefCell::new(None) };
360}
361
362pub fn set_context(ctx: TestContext) {
364 CURRENT_CONTEXT.with(|c| {
365 *c.borrow_mut() = Some(ctx);
366 });
367}
368
369pub fn take_context() -> Option<TestContext> {
371 #[cfg(feature = "tokio")]
372 {
373 if let Ok(context) = TOKIO_CONTEXT.try_with(|c| {
374 let handle_opt = c.borrow().clone();
375 handle_opt.and_then(|handle| {
376 let mut guard = lock_unpoisoned(&handle);
377 guard.take()
378 })
379 }) {
380 if context.is_some() {
381 return context;
382 }
383 }
384 }
385
386 let thread_local = CURRENT_CONTEXT.with(|c| c.borrow_mut().take());
387 if thread_local.is_some() {
388 return thread_local;
389 }
390
391 None
392}
393
394pub fn with_context<F, R>(f: F) -> Option<R>
396where
397 F: FnOnce(&mut TestContext) -> R,
398{
399 let mut f_opt = Some(f);
400
401 #[cfg(feature = "tokio")]
402 {
403 if let Ok(result) = TOKIO_CONTEXT.try_with(|c| {
404 let handle_opt = c.borrow().clone();
405 if let Some(handle) = handle_opt {
406 let mut guard = lock_unpoisoned(&handle);
407 if let Some(ctx) = guard.as_mut() {
408 if let Some(func) = f_opt.take() {
409 return Some(func(ctx));
410 }
411 }
412 }
413 None
414 }) {
415 if result.is_some() {
416 return result;
417 }
418 }
419 }
420
421 let thread_local = CURRENT_CONTEXT
422 .with(|c| {
423 let mut ctx = c.borrow_mut();
424 if let Some(ctx) = ctx.as_mut() {
425 if let Some(func) = f_opt.take() {
426 return Some(func(ctx));
427 }
428 }
429 None
430 })
431 .or_else(|| {
432 #[cfg(feature = "tokio")]
433 {
434 if let Some(handle) = current_global_context() {
435 let mut guard = lock_unpoisoned(&handle);
436 if let Some(ctx) = guard.as_mut() {
437 if let Some(func) = f_opt.take() {
438 return Some(func(ctx));
439 }
440 }
441 }
442 }
443 None
444 });
445
446 thread_local
447}
448
449pub fn run_test<F>(name: &str, full_name: &str, f: F)
451where
452 F: FnOnce() + std::panic::UnwindSafe,
453{
454 let ctx = TestContext::new(name, full_name);
455 set_context(ctx);
456
457 let result = catch_unwind(AssertUnwindSafe(f));
458
459 let (is_err, panic_payload) = match &result {
461 Ok(()) => (false, None),
462 Err(panic_info) => {
463 let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
464 Some(s.to_string())
465 } else if let Some(s) = panic_info.downcast_ref::<String>() {
466 Some(s.clone())
467 } else {
468 Some("Test panicked".to_string())
469 };
470 (true, msg)
471 }
472 };
473
474 if let Some(mut ctx) = take_context() {
476 if is_err {
477 let trace = capture_trace();
478 ctx.finish(Status::Failed, panic_payload, trace);
479 } else {
480 ctx.finish(Status::Passed, None, None);
481 }
482 }
483
484 if let Err(e) = result {
486 std::panic::resume_unwind(e);
487 }
488}
489
490#[doc(hidden)]
509pub fn with_test_context<F, R>(f: F) -> R
510where
511 F: FnOnce() -> R,
512{
513 let ctx = TestContext::new("doctest", "doctest::example");
514 set_context(ctx);
515 let result = f();
516 let _ = take_context(); result
518}
519
520pub fn label(name: impl Into<String>, value: impl Into<String>) {
535 with_context(|ctx| ctx.add_label(name, value));
536}
537
538pub fn epic(name: impl Into<String>) {
552 with_context(|ctx| ctx.add_label_name(LabelName::Epic, name));
553}
554
555pub fn feature(name: impl Into<String>) {
569 with_context(|ctx| ctx.add_label_name(LabelName::Feature, name));
570}
571
572pub fn story(name: impl Into<String>) {
586 with_context(|ctx| ctx.add_label_name(LabelName::Story, name));
587}
588
589pub fn suite(name: impl Into<String>) {
591 with_context(|ctx| ctx.add_label_name(LabelName::Suite, name));
592}
593
594pub fn parent_suite(name: impl Into<String>) {
596 with_context(|ctx| ctx.add_label_name(LabelName::ParentSuite, name));
597}
598
599pub fn sub_suite(name: impl Into<String>) {
601 with_context(|ctx| ctx.add_label_name(LabelName::SubSuite, name));
602}
603
604pub fn severity(severity: Severity) {
617 with_context(|ctx| ctx.add_label_name(LabelName::Severity, severity.as_str()));
618}
619
620pub fn owner(name: impl Into<String>) {
632 with_context(|ctx| ctx.add_label_name(LabelName::Owner, name));
633}
634
635pub fn tag(name: impl Into<String>) {
648 with_context(|ctx| ctx.add_label_name(LabelName::Tag, name));
649}
650
651pub fn tags(names: &[&str]) {
663 with_context(|ctx| {
664 for name in names {
665 ctx.add_label_name(LabelName::Tag, *name);
666 }
667 });
668}
669
670pub fn allure_id(id: impl Into<String>) {
672 with_context(|ctx| ctx.add_label_name(LabelName::AllureId, id));
673}
674
675pub fn title(name: impl Into<String>) {
689 with_context(|ctx| ctx.result.name = name.into());
690}
691
692pub fn description(text: impl Into<String>) {
694 with_context(|ctx| ctx.result.description = Some(text.into()));
695}
696
697pub fn description_html(html: impl Into<String>) {
699 with_context(|ctx| ctx.result.description_html = Some(html.into()));
700}
701
702pub fn issue(url: impl Into<String>, name: Option<String>) {
704 with_context(|ctx| ctx.add_link(url, name, LinkType::Issue));
705}
706
707pub fn tms(url: impl Into<String>, name: Option<String>) {
709 with_context(|ctx| ctx.add_link(url, name, LinkType::Tms));
710}
711
712pub fn link(url: impl Into<String>, name: Option<String>) {
714 with_context(|ctx| ctx.add_link(url, name, LinkType::Default));
715}
716
717pub fn parameter(name: impl Into<String>, value: impl ToString) {
733 with_context(|ctx| ctx.add_parameter(name, value.to_string()));
734}
735
736pub fn parameter_hidden(name: impl Into<String>, value: impl ToString) {
738 with_context(|ctx| ctx.add_parameter_struct(Parameter::hidden(name, value.to_string())));
739}
740
741pub fn parameter_masked(name: impl Into<String>, value: impl ToString) {
743 with_context(|ctx| ctx.add_parameter_struct(Parameter::masked(name, value.to_string())));
744}
745
746pub fn parameter_excluded(name: impl Into<String>, value: impl ToString) {
748 with_context(|ctx| ctx.add_parameter_struct(Parameter::excluded(name, value.to_string())));
749}
750
751pub fn step<F, R>(name: impl Into<String>, body: F) -> R
786where
787 F: FnOnce() -> R,
788{
789 let step_name = name.into();
790
791 with_context(|ctx| ctx.start_step(&step_name));
792
793 let result = catch_unwind(AssertUnwindSafe(body));
794
795 match &result {
796 Ok(_) => {
797 with_context(|ctx| ctx.finish_step(Status::Passed, None, None));
798 }
799 Err(panic_info) => {
800 let message = if let Some(s) = panic_info.downcast_ref::<&str>() {
801 Some(s.to_string())
802 } else if let Some(s) = panic_info.downcast_ref::<String>() {
803 Some(s.clone())
804 } else {
805 Some("Step panicked".to_string())
806 };
807 let trace = capture_trace();
808 with_context(|ctx| ctx.finish_step(Status::Failed, message, trace));
809 }
810 }
811
812 match result {
813 Ok(value) => value,
814 Err(e) => std::panic::resume_unwind(e),
815 }
816}
817
818pub fn log_step(name: impl Into<String>, status: Status) {
835 with_context(|ctx| {
836 ctx.start_step(name);
837 ctx.finish_step(status, None, None);
838 });
839}
840
841pub fn attach_text(name: impl Into<String>, content: impl AsRef<str>) {
854 with_context(|ctx| ctx.attach_text(name, content));
855}
856
857pub fn attach_json<T: serde::Serialize>(name: impl Into<String>, value: &T) {
882 with_context(|ctx| ctx.attach_json(name, value));
883}
884
885pub fn attach_binary(name: impl Into<String>, content: &[u8], content_type: ContentType) {
899 with_context(|ctx| ctx.attach_binary(name, content, content_type));
900}
901
902pub fn flaky() {
918 with_context(|ctx| {
919 let details = ctx
920 .result
921 .status_details
922 .get_or_insert_with(Default::default);
923 details.flaky = Some(true);
924 });
925}
926
927pub fn muted() {
944 with_context(|ctx| {
945 let details = ctx
946 .result
947 .status_details
948 .get_or_insert_with(Default::default);
949 details.muted = Some(true);
950 });
951}
952
953pub fn known_issue(issue_id: impl Into<String>) {
967 let id = issue_id.into();
968 with_context(|ctx| {
969 let details = ctx
970 .result
971 .status_details
972 .get_or_insert_with(Default::default);
973 details.known = Some(true);
974 ctx.add_link(&id, Some(id.clone()), LinkType::Issue);
976 });
977}
978
979pub fn skip(reason: impl Into<String>) {
981 let reason = reason.into();
982 if let Some(mut ctx) = take_context() {
983 ctx.finish(Status::Skipped, Some(reason), None);
984 }
985}
986
987pub fn display_name(name: impl Into<String>) {
991 with_context(|ctx| ctx.result.name = name.into());
992}
993
994pub fn test_case_id(id: impl Into<String>) {
998 with_context(|ctx| ctx.result.test_case_id = Some(id.into()));
999}
1000
1001pub fn attach_file(
1005 name: impl Into<String>,
1006 path: impl AsRef<std::path::Path>,
1007 content_type: Option<ContentType>,
1008) {
1009 with_context(|ctx| ctx.attach_file(name, path, content_type));
1010}
1011
1012fn capture_trace() -> Option<String> {
1014 let bt = Backtrace::force_capture();
1015 let snapshot = format!("{bt:?}");
1016 if snapshot.contains("disabled") {
1017 return None;
1018 }
1019 Some(snapshot)
1020}
1021
1022#[cfg(feature = "tokio")]
1024pub async fn with_async_context<F, R>(ctx: TestContext, fut: F) -> R
1025where
1026 F: std::future::Future<Output = R>,
1027{
1028 let handle = Arc::new(Mutex::new(Some(ctx)));
1029 let _registration = GlobalContextRegistration::new(handle.clone());
1030
1031 let cell = RefCell::new(Some(handle));
1032 TOKIO_CONTEXT.scope(cell, fut).await
1033}
1034
1035#[cfg(not(feature = "tokio"))]
1037pub async fn with_async_context<F, R>(ctx: TestContext, fut: F) -> R
1038where
1039 F: std::future::Future<Output = R>,
1040{
1041 set_context(ctx);
1042 let result = fut.await;
1043 let _ = take_context();
1044 result
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049 use super::*;
1050 use serde_json::Value;
1051 use std::path::PathBuf;
1052
1053 #[test]
1054 fn test_config_builder() {
1055 let config = AllureConfigBuilder::new()
1056 .results_dir("custom-results")
1057 .clean_results(false)
1058 .config;
1059
1060 assert_eq!(config.results_dir, "custom-results");
1061 assert!(!config.clean_results);
1062 }
1063
1064 #[test]
1065 fn test_context_creation() {
1066 let ctx = TestContext::new("My Test", "tests::my_test");
1067 assert_eq!(ctx.result.name, "My Test");
1068 assert_eq!(ctx.result.full_name, Some("tests::my_test".to_string()));
1069 assert!(ctx
1070 .result
1071 .labels
1072 .iter()
1073 .any(|l| l.name == "language" && l.value == "rust"));
1074 }
1075
1076 #[test]
1077 fn test_step_nesting() {
1078 let mut ctx = TestContext::new("Test", "test::test");
1079
1080 ctx.start_step("Step 1");
1081 ctx.start_step("Step 1.1");
1082 ctx.finish_step(Status::Passed, None, None);
1083 ctx.finish_step(Status::Passed, None, None);
1084
1085 assert_eq!(ctx.result.steps.len(), 1);
1086 assert_eq!(ctx.result.steps[0].name, "Step 1");
1087 assert_eq!(ctx.result.steps[0].steps.len(), 1);
1088 assert_eq!(ctx.result.steps[0].steps[0].name, "Step 1.1");
1089 }
1090
1091 #[test]
1092 fn test_thread_local_context() {
1093 let ctx = TestContext::new("Test", "test::test");
1094 set_context(ctx);
1095
1096 with_context(|ctx| {
1097 ctx.add_label("custom", "value");
1098 });
1099
1100 let ctx = take_context().unwrap();
1101 assert!(ctx
1102 .result
1103 .labels
1104 .iter()
1105 .any(|l| l.name == "custom" && l.value == "value"));
1106 }
1107
1108 #[test]
1109 fn test_capture_trace_runs() {
1110 let _maybe_trace = capture_trace();
1112 }
1113
1114 #[test]
1115 fn test_run_test_writes_results_and_container_on_panic() {
1116 let desired_dir = PathBuf::from("target/allure-runtime-tests");
1117 let _ = std::fs::remove_dir_all(&desired_dir);
1118
1119 let config_ref = CONFIG.get_or_init(|| AllureConfig {
1120 results_dir: desired_dir.to_string_lossy().to_string(),
1121 clean_results: true,
1122 });
1123 let dir = PathBuf::from(&config_ref.results_dir);
1124 let _ = std::fs::remove_dir_all(&dir);
1125 std::fs::create_dir_all(&dir).unwrap();
1126
1127 let outcome = std::panic::catch_unwind(|| {
1128 run_test("panic_test", "runtime::panic_test", || {
1129 panic!("runtime boom");
1130 });
1131 });
1132 assert!(outcome.is_err());
1133
1134 let mut result_files = Vec::new();
1135 let mut container_files = Vec::new();
1136 for entry in std::fs::read_dir(&dir).unwrap() {
1137 let path = entry.unwrap().path();
1138 if path.extension().and_then(|e| e.to_str()) == Some("json") {
1139 let name = path.file_name().unwrap().to_string_lossy().to_string();
1140 if name.contains("-result.json") {
1141 result_files.push(path.clone());
1142 } else if name.contains("-container.json") {
1143 container_files.push(path.clone());
1144 }
1145 }
1146 }
1147
1148 assert!(!result_files.is_empty());
1149 assert!(!container_files.is_empty());
1150
1151 let panic_result_json = result_files
1152 .iter()
1153 .find_map(|path| {
1154 let result_json: Value =
1155 serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?;
1156 (result_json["name"] == "panic_test").then_some(result_json)
1157 })
1158 .expect("panic_test result json should exist");
1159
1160 assert_eq!(panic_result_json["status"], "failed");
1161 assert!(panic_result_json["statusDetails"]["message"]
1162 .as_str()
1163 .unwrap()
1164 .contains("runtime boom"));
1165 }
1166
1167 #[cfg(feature = "tokio")]
1168 #[tokio::test(flavor = "current_thread")]
1169 async fn test_take_context_reads_tokio_task_local() {
1170 let ctx = TestContext::new("tokio_ctx", "module::tokio_ctx");
1171 let taken = with_async_context(ctx, async {
1172 let inner = take_context();
1173 assert!(inner.is_some());
1174 inner.unwrap().result.name
1175 })
1176 .await;
1177 assert_eq!(taken, "tokio_ctx");
1178 }
1179
1180 #[cfg(feature = "tokio")]
1181 #[tokio::test(flavor = "current_thread")]
1182 async fn test_with_context_uses_tokio_task_local() {
1183 let ctx = TestContext::new("tokio_ctx", "module::tokio_ctx");
1184 with_async_context(ctx, async {
1185 let mut seen = None;
1186 with_context(|c| {
1187 seen = Some(c.result.name.clone());
1188 });
1189 assert_eq!(seen.as_deref(), Some("tokio_ctx"));
1190 })
1191 .await;
1192 }
1193
1194 #[cfg(feature = "tokio")]
1195 #[test]
1196 fn test_global_context_avoids_ambiguous_assignment() {
1197 let launch_barrier = std::sync::Arc::new(tokio::sync::Barrier::new(2));
1198 let probe_barrier = std::sync::Arc::new(tokio::sync::Barrier::new(2));
1199 let settle_barrier = std::sync::Arc::new(tokio::sync::Barrier::new(2));
1200
1201 let run_in_runtime =
1202 |name: &'static str,
1203 launch_barrier: std::sync::Arc<tokio::sync::Barrier>,
1204 probe_barrier: std::sync::Arc<tokio::sync::Barrier>,
1205 settle_barrier: std::sync::Arc<tokio::sync::Barrier>| {
1206 std::thread::spawn(move || {
1207 let rt = tokio::runtime::Builder::new_current_thread()
1208 .enable_all()
1209 .build()
1210 .unwrap();
1211
1212 rt.block_on(async move {
1213 let ctx = TestContext::new(name, format!("module::{name}"));
1214 with_async_context(ctx, async move {
1215 launch_barrier.wait().await;
1216 tokio::spawn(async move {
1217 probe_barrier.wait().await;
1218 let mut seen = None;
1219 with_context(|c| {
1220 seen = Some(c.result.name.clone());
1221 });
1222 settle_barrier.wait().await;
1223 seen
1224 })
1225 .await
1226 .unwrap()
1227 })
1228 .await
1229 })
1230 })
1231 };
1232
1233 let t1 = run_in_runtime(
1234 "ctx-one",
1235 launch_barrier.clone(),
1236 probe_barrier.clone(),
1237 settle_barrier.clone(),
1238 );
1239 let t2 = run_in_runtime("ctx-two", launch_barrier, probe_barrier, settle_barrier);
1240
1241 let seen1 = t1.join().unwrap();
1242 let seen2 = t2.join().unwrap();
1243
1244 assert!(seen1.is_none());
1245 assert!(seen2.is_none());
1246 }
1247
1248 #[test]
1249 fn test_with_test_context_clears_after_use() {
1250 with_test_context(|| {
1251 label("temp", "value");
1252 });
1253 assert!(take_context().is_none());
1254 }
1255
1256 #[test]
1257 fn test_tags_and_metadata_helpers() {
1258 let ctx = TestContext::new("meta", "module::meta");
1259 set_context(ctx);
1260
1261 label("env", "staging");
1262 tags(&["smoke", "api"]);
1263 title("Custom Title");
1264 description("Markdown");
1265 description_html("<p>HTML</p>");
1266 test_case_id("TC-1");
1267
1268 let ctx = take_context().unwrap();
1269 assert_eq!(ctx.result.name, "Custom Title");
1270 assert_eq!(ctx.result.description.as_deref(), Some("Markdown"));
1271 assert_eq!(ctx.result.description_html.as_deref(), Some("<p>HTML</p>"));
1272 assert_eq!(ctx.result.test_case_id.as_deref(), Some("TC-1"));
1273 assert!(ctx.result.labels.iter().any(|l| l.value == "staging"));
1274 assert!(ctx.result.labels.iter().any(|l| l.value == "smoke"));
1275 assert!(ctx.result.labels.iter().any(|l| l.value == "api"));
1276 }
1277
1278 #[test]
1279 fn test_step_failure_records_message_and_rethrows() {
1280 let ctx = TestContext::new("step_fail", "module::step_fail");
1281 set_context(ctx);
1282
1283 let result = std::panic::catch_unwind(|| {
1284 step("will panic", || panic!("boom step"));
1285 });
1286 assert!(result.is_err());
1287
1288 let ctx = take_context().unwrap();
1289 assert_eq!(ctx.result.steps.len(), 1);
1290 let step = &ctx.result.steps[0];
1291 assert_eq!(step.status, Status::Failed);
1292 assert!(step
1293 .status_details
1294 .as_ref()
1295 .unwrap()
1296 .message
1297 .as_ref()
1298 .unwrap()
1299 .contains("boom step"));
1300 }
1301
1302 #[test]
1303 fn test_finish_step_skipped_branch() {
1304 let mut ctx = TestContext::new("skip_step", "module::skip_step");
1305 ctx.start_step("inner");
1306 ctx.finish_step(
1307 Status::Skipped,
1308 Some("not run".into()),
1309 Some("trace".into()),
1310 );
1311 assert_eq!(ctx.result.steps[0].status, Status::Skipped);
1312 assert_eq!(ctx.result.steps[0].stage, crate::enums::Stage::Finished);
1313 }
1314
1315 #[test]
1316 fn test_finish_step_broken_and_unknown_branches() {
1317 let mut ctx = TestContext::new("broken_step", "module::broken_step");
1318 ctx.start_step("broken");
1319 ctx.finish_step(Status::Broken, Some("oops".into()), None);
1320 assert_eq!(ctx.result.steps[0].status, Status::Broken);
1321 assert!(ctx.result.steps[0]
1322 .status_details
1323 .as_ref()
1324 .unwrap()
1325 .message
1326 .as_ref()
1327 .unwrap()
1328 .contains("oops"));
1329
1330 ctx.start_step("unknown");
1331 ctx.finish_step(Status::Unknown, None, None);
1332 assert_eq!(ctx.result.steps[1].status, Status::Unknown);
1333 assert_eq!(ctx.result.steps[1].stage, crate::enums::Stage::Finished);
1334 }
1335
1336 #[test]
1337 fn test_muted_sets_flag() {
1338 let ctx = TestContext::new("muted_test", "module::muted_test");
1339 set_context(ctx);
1340 muted();
1341 let ctx = take_context().unwrap();
1342 let details = ctx.result.status_details.unwrap();
1343 assert_eq!(details.muted, Some(true));
1344 }
1345
1346 #[test]
1347 fn test_host_env_override_used_in_context_creation() {
1348 std::env::set_var("HOSTNAME", "test-host");
1349 let ctx = TestContext::new("hosted", "module::hosted");
1350 assert!(ctx
1351 .result
1352 .labels
1353 .iter()
1354 .any(|l| l.name == "host" && l.value == "test-host"));
1355 }
1356
1357 #[test]
1358 fn test_add_parameter_struct_applies_to_steps() {
1359 let mut ctx = TestContext::new("params", "module::params");
1360 ctx.start_step("outer");
1361 ctx.add_parameter_struct(crate::model::Parameter::excluded("k", "v"));
1362 assert_eq!(ctx.step_stack[0].parameters.len(), 1);
1363 assert_eq!(ctx.step_stack[0].parameters[0].excluded, Some(true));
1364 }
1365
1366 #[test]
1367 fn test_finish_writes_and_breaks_unfinished_steps() {
1368 let temp = tempfile::tempdir().unwrap();
1369 CONFIG.get_or_init(|| AllureConfig {
1370 results_dir: temp.path().to_string_lossy().to_string(),
1371 clean_results: true,
1372 });
1373 let mut ctx = TestContext::new("unclosed", "module::unclosed");
1374 ctx.start_step("still running");
1375 ctx.finish(Status::Passed, None, None);
1376 assert_eq!(ctx.result.steps[0].status, Status::Broken);
1377 assert!(ctx.result.steps[0]
1378 .status_details
1379 .as_ref()
1380 .unwrap()
1381 .message
1382 .as_ref()
1383 .unwrap()
1384 .contains("Step not completed"));
1385 }
1386
1387 #[test]
1388 fn test_finish_handles_broken_status_with_details() {
1389 let temp = tempfile::tempdir().unwrap();
1390 CONFIG.get_or_init(|| AllureConfig {
1391 results_dir: temp.path().to_string_lossy().to_string(),
1392 clean_results: true,
1393 });
1394 let mut ctx = TestContext::new("broken_test", "module::broken_test");
1395 ctx.finish(Status::Broken, Some("fail".into()), Some("trace".into()));
1396 assert_eq!(ctx.result.status, Status::Broken);
1397 let details = ctx.result.status_details.as_ref().unwrap();
1398 assert_eq!(details.message.as_deref(), Some("fail"));
1399 assert_eq!(details.trace.as_deref(), Some("trace"));
1400 }
1401
1402 #[test]
1403 fn test_context_creation_uses_hostname_when_env_missing() {
1404 let original = std::env::var("HOSTNAME").ok();
1406 std::env::remove_var("HOSTNAME");
1407 let ctx = TestContext::new("host", "module::host");
1408 if let Some(orig) = original {
1409 std::env::set_var("HOSTNAME", orig);
1410 }
1411 assert!(ctx.result.labels.iter().any(|l| l.name == "host"));
1412 }
1413}