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 GlobalAsyncContext = Mutex<Option<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 GlobalAsyncContext {
32 static GLOBAL: OnceLock<GlobalAsyncContext> = OnceLock::new();
33 GLOBAL.get_or_init(|| Mutex::new(None))
34}
35
36#[derive(Debug, Clone)]
38pub struct AllureConfig {
39 pub results_dir: String,
41 pub clean_results: bool,
43}
44
45impl Default for AllureConfig {
46 fn default() -> Self {
47 Self {
48 results_dir: crate::writer::DEFAULT_RESULTS_DIR.to_string(),
49 clean_results: true,
50 }
51 }
52}
53
54#[derive(Debug, Default)]
56pub struct AllureConfigBuilder {
57 config: AllureConfig,
58}
59
60impl AllureConfigBuilder {
61 pub fn new() -> Self {
63 Self::default()
64 }
65
66 pub fn results_dir(mut self, path: impl Into<String>) -> Self {
68 self.config.results_dir = path.into();
69 self
70 }
71
72 pub fn clean_results(mut self, clean: bool) -> Self {
74 self.config.clean_results = clean;
75 self
76 }
77
78 pub fn init(self) -> std::io::Result<()> {
80 let writer = AllureWriter::with_results_dir(&self.config.results_dir);
81 writer.init(self.config.clean_results)?;
82 CONFIG.set(self.config).ok();
83 Ok(())
84 }
85}
86
87pub fn configure() -> AllureConfigBuilder {
89 AllureConfigBuilder::new()
90}
91
92pub fn get_config() -> AllureConfig {
94 CONFIG.get().cloned().unwrap_or_default()
95}
96
97#[derive(Debug)]
99pub struct TestContext {
100 pub result: TestResult,
102 pub step_stack: Vec<StepResult>,
104 pub writer: AllureWriter,
106}
107
108impl TestContext {
109 pub fn new(name: impl Into<String>, full_name: impl Into<String>) -> Self {
111 let config = get_config();
112 let uuid = generate_uuid();
113 let mut result = TestResult::new(uuid, name.into());
114 result.full_name = Some(full_name.into());
115
116 result.labels.push(Label::language("rust"));
118 result.labels.push(Label::framework("allure-rs"));
119
120 if let Ok(hostname) = std::env::var("HOSTNAME") {
122 result.labels.push(Label::host(hostname));
123 } else if let Ok(hostname) = hostname::get() {
124 if let Some(name) = hostname.to_str() {
125 result.labels.push(Label::host(name));
126 }
127 }
128
129 let thread_id = format!("{:?}", std::thread::current().id());
130 result.labels.push(Label::thread(thread_id));
131
132 Self {
133 result,
134 step_stack: Vec::new(),
135 writer: AllureWriter::with_results_dir(config.results_dir),
136 }
137 }
138
139 pub fn add_label(&mut self, name: impl Into<String>, value: impl Into<String>) {
141 self.result.add_label(name, value);
142 }
143
144 pub fn add_label_name(&mut self, name: LabelName, value: impl Into<String>) {
146 self.result.add_label_name(name, value);
147 }
148
149 pub fn add_link(&mut self, url: impl Into<String>, name: Option<String>, link_type: LinkType) {
151 self.result.add_link(url, name, link_type);
152 }
153
154 pub fn add_parameter(&mut self, name: impl Into<String>, value: impl Into<String>) {
156 if let Some(step) = self.step_stack.last_mut() {
157 step.add_parameter(name, value);
158 } else {
159 self.result.add_parameter(name, value);
160 }
161 }
162
163 pub fn add_parameter_struct(&mut self, parameter: Parameter) {
165 if let Some(step) = self.step_stack.last_mut() {
166 step.parameters.push(parameter);
167 } else {
168 self.result.parameters.push(parameter);
169 }
170 }
171
172 pub fn add_attachment(&mut self, attachment: Attachment) {
174 if let Some(step) = self.step_stack.last_mut() {
175 step.add_attachment(attachment);
176 } else {
177 self.result.add_attachment(attachment);
178 }
179 }
180
181 pub fn start_step(&mut self, name: impl Into<String>) {
183 let step = StepResult::new(name);
184 self.step_stack.push(step);
185 }
186
187 pub fn finish_step(&mut self, status: Status, message: Option<String>, trace: Option<String>) {
189 if let Some(mut step) = self.step_stack.pop() {
190 match status {
191 Status::Passed => step.pass(),
192 Status::Failed => step.fail(message, trace),
193 Status::Broken => step.broken(message, trace),
194 _ => {
195 step.status = status;
196 step.stage = crate::enums::Stage::Finished;
197 step.stop = crate::model::current_time_ms();
198 }
199 }
200
201 if let Some(parent_step) = self.step_stack.last_mut() {
203 parent_step.add_step(step);
204 } else {
205 self.result.add_step(step);
206 }
207 }
208 }
209
210 pub fn compute_history_id(&mut self) {
212 if let Some(ref full_name) = self.result.full_name {
213 let history_id = compute_history_id(full_name, &self.result.parameters);
214 self.result.history_id = Some(history_id);
215 }
216 }
217
218 pub fn finish(&mut self, status: Status, message: Option<String>, trace: Option<String>) {
220 while !self.step_stack.is_empty() {
222 self.finish_step(Status::Broken, Some("Step not completed".to_string()), None);
223 }
224
225 self.compute_history_id();
227
228 match status {
229 Status::Passed => self.result.pass(),
230 Status::Failed => self.result.fail(message, trace),
231 Status::Broken => self.result.broken(message, trace),
232 Status::Skipped => {
233 if message.is_some() || trace.is_some() {
234 self.result.status_details = Some(crate::model::StatusDetails {
235 message,
236 trace,
237 ..Default::default()
238 });
239 }
240 self.result.status = status;
241 self.result.finish();
242 }
243 _ => {
244 self.result.status = status;
245 self.result.finish();
246 }
247 }
248
249 if let Err(e) = self.writer.write_test_result(&self.result) {
251 eprintln!("Failed to write Allure test result: {}", e);
252 }
253
254 let mut container = TestResultContainer::new(generate_uuid());
256 container.children.push(self.result.uuid.clone());
257 container.start = Some(self.result.start);
258 container.stop = Some(self.result.stop);
259 if let Err(e) = self.writer.write_container(&container) {
260 eprintln!("Failed to write Allure container: {}", e);
261 }
262 }
263
264 pub fn attach_text(&mut self, name: impl Into<String>, content: impl AsRef<str>) {
266 match self.writer.write_text_attachment(name, content) {
267 Ok(attachment) => self.add_attachment(attachment),
268 Err(e) => eprintln!("Failed to write text attachment: {}", e),
269 }
270 }
271
272 pub fn attach_json<T: serde::Serialize>(&mut self, name: impl Into<String>, value: &T) {
274 match self.writer.write_json_attachment(name, value) {
275 Ok(attachment) => self.add_attachment(attachment),
276 Err(e) => eprintln!("Failed to write JSON attachment: {}", e),
277 }
278 }
279
280 pub fn attach_binary(
282 &mut self,
283 name: impl Into<String>,
284 content: &[u8],
285 content_type: ContentType,
286 ) {
287 match self
288 .writer
289 .write_binary_attachment(name, content, content_type)
290 {
291 Ok(attachment) => self.add_attachment(attachment),
292 Err(e) => eprintln!("Failed to write binary attachment: {}", e),
293 }
294 }
295
296 pub fn attach_file(
298 &mut self,
299 name: impl Into<String>,
300 path: impl AsRef<std::path::Path>,
301 content_type: Option<ContentType>,
302 ) {
303 match self.writer.copy_file_attachment(name, path, content_type) {
304 Ok(attachment) => self.add_attachment(attachment),
305 Err(e) => eprintln!("Failed to copy file attachment: {}", e),
306 }
307 }
308}
309
310thread_local! {
312 static CURRENT_CONTEXT: RefCell<Option<TestContext>> = const { RefCell::new(None) };
313}
314
315pub fn set_context(ctx: TestContext) {
317 CURRENT_CONTEXT.with(|c| {
318 *c.borrow_mut() = Some(ctx);
319 });
320}
321
322pub fn take_context() -> Option<TestContext> {
324 #[cfg(feature = "tokio")]
325 {
326 if let Ok(context) = TOKIO_CONTEXT.try_with(|c| {
327 let handle_opt = c.borrow().clone();
328 handle_opt.and_then(|handle| {
329 let mut guard = handle.lock().unwrap();
330 guard.take()
331 })
332 }) {
333 if context.is_some() {
334 return context;
335 }
336 }
337 }
338
339 let thread_local = CURRENT_CONTEXT.with(|c| c.borrow_mut().take());
340 if thread_local.is_some() {
341 return thread_local;
342 }
343
344 #[cfg(feature = "tokio")]
345 {
346 let global = global_async_context().lock().unwrap().clone();
347 if let Some(handle) = global {
348 let mut guard = handle.lock().unwrap();
349 if let Some(ctx) = guard.take() {
350 return Some(ctx);
351 }
352 }
353 }
354
355 None
356}
357
358pub fn with_context<F, R>(f: F) -> Option<R>
360where
361 F: FnOnce(&mut TestContext) -> R,
362{
363 let mut f_opt = Some(f);
364
365 #[cfg(feature = "tokio")]
366 {
367 if let Ok(result) = TOKIO_CONTEXT.try_with(|c| {
368 let handle_opt = c.borrow().clone();
369 if let Some(handle) = handle_opt {
370 let mut guard = handle.lock().unwrap();
371 if let Some(ctx) = guard.as_mut() {
372 if let Some(func) = f_opt.take() {
373 return Some(func(ctx));
374 }
375 }
376 }
377 None
378 }) {
379 if result.is_some() {
380 return result;
381 }
382 }
383 }
384
385 let thread_local = CURRENT_CONTEXT
386 .with(|c| {
387 let mut ctx = c.borrow_mut();
388 if let Some(ctx) = ctx.as_mut() {
389 if let Some(func) = f_opt.take() {
390 return Some(func(ctx));
391 }
392 }
393 None
394 })
395 .or_else(|| {
396 #[cfg(feature = "tokio")]
397 {
398 let handle_opt = global_async_context().lock().unwrap().clone();
399 if let Some(handle) = handle_opt {
400 let mut guard = handle.lock().unwrap();
401 if let Some(ctx) = guard.as_mut() {
402 if let Some(func) = f_opt.take() {
403 return Some(func(ctx));
404 }
405 }
406 }
407 }
408 None
409 });
410
411 thread_local
412}
413
414pub fn run_test<F>(name: &str, full_name: &str, f: F)
416where
417 F: FnOnce() + std::panic::UnwindSafe,
418{
419 let ctx = TestContext::new(name, full_name);
420 set_context(ctx);
421
422 let result = catch_unwind(AssertUnwindSafe(f));
423
424 let (is_err, panic_payload) = match &result {
426 Ok(()) => (false, None),
427 Err(panic_info) => {
428 let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
429 Some(s.to_string())
430 } else if let Some(s) = panic_info.downcast_ref::<String>() {
431 Some(s.clone())
432 } else {
433 Some("Test panicked".to_string())
434 };
435 (true, msg)
436 }
437 };
438
439 if let Some(mut ctx) = take_context() {
441 if is_err {
442 let trace = capture_trace();
443 ctx.finish(Status::Failed, panic_payload, trace);
444 } else {
445 ctx.finish(Status::Passed, None, None);
446 }
447 }
448
449 if let Err(e) = result {
451 std::panic::resume_unwind(e);
452 }
453}
454
455#[doc(hidden)]
474pub fn with_test_context<F, R>(f: F) -> R
475where
476 F: FnOnce() -> R,
477{
478 let ctx = TestContext::new("doctest", "doctest::example");
479 set_context(ctx);
480 let result = f();
481 let _ = take_context(); result
483}
484
485pub fn label(name: impl Into<String>, value: impl Into<String>) {
500 with_context(|ctx| ctx.add_label(name, value));
501}
502
503pub fn epic(name: impl Into<String>) {
517 with_context(|ctx| ctx.add_label_name(LabelName::Epic, name));
518}
519
520pub fn feature(name: impl Into<String>) {
534 with_context(|ctx| ctx.add_label_name(LabelName::Feature, name));
535}
536
537pub fn story(name: impl Into<String>) {
551 with_context(|ctx| ctx.add_label_name(LabelName::Story, name));
552}
553
554pub fn suite(name: impl Into<String>) {
556 with_context(|ctx| ctx.add_label_name(LabelName::Suite, name));
557}
558
559pub fn parent_suite(name: impl Into<String>) {
561 with_context(|ctx| ctx.add_label_name(LabelName::ParentSuite, name));
562}
563
564pub fn sub_suite(name: impl Into<String>) {
566 with_context(|ctx| ctx.add_label_name(LabelName::SubSuite, name));
567}
568
569pub fn severity(severity: Severity) {
582 with_context(|ctx| ctx.add_label_name(LabelName::Severity, severity.as_str()));
583}
584
585pub fn owner(name: impl Into<String>) {
597 with_context(|ctx| ctx.add_label_name(LabelName::Owner, name));
598}
599
600pub fn tag(name: impl Into<String>) {
613 with_context(|ctx| ctx.add_label_name(LabelName::Tag, name));
614}
615
616pub fn tags(names: &[&str]) {
628 with_context(|ctx| {
629 for name in names {
630 ctx.add_label_name(LabelName::Tag, *name);
631 }
632 });
633}
634
635pub fn allure_id(id: impl Into<String>) {
637 with_context(|ctx| ctx.add_label_name(LabelName::AllureId, id));
638}
639
640pub fn title(name: impl Into<String>) {
654 with_context(|ctx| ctx.result.name = name.into());
655}
656
657pub fn description(text: impl Into<String>) {
659 with_context(|ctx| ctx.result.description = Some(text.into()));
660}
661
662pub fn description_html(html: impl Into<String>) {
664 with_context(|ctx| ctx.result.description_html = Some(html.into()));
665}
666
667pub fn issue(url: impl Into<String>, name: Option<String>) {
669 with_context(|ctx| ctx.add_link(url, name, LinkType::Issue));
670}
671
672pub fn tms(url: impl Into<String>, name: Option<String>) {
674 with_context(|ctx| ctx.add_link(url, name, LinkType::Tms));
675}
676
677pub fn link(url: impl Into<String>, name: Option<String>) {
679 with_context(|ctx| ctx.add_link(url, name, LinkType::Default));
680}
681
682pub fn parameter(name: impl Into<String>, value: impl ToString) {
698 with_context(|ctx| ctx.add_parameter(name, value.to_string()));
699}
700
701pub fn parameter_hidden(name: impl Into<String>, value: impl ToString) {
703 with_context(|ctx| ctx.add_parameter_struct(Parameter::hidden(name, value.to_string())));
704}
705
706pub fn parameter_masked(name: impl Into<String>, value: impl ToString) {
708 with_context(|ctx| ctx.add_parameter_struct(Parameter::masked(name, value.to_string())));
709}
710
711pub fn parameter_excluded(name: impl Into<String>, value: impl ToString) {
713 with_context(|ctx| ctx.add_parameter_struct(Parameter::excluded(name, value.to_string())));
714}
715
716pub fn step<F, R>(name: impl Into<String>, body: F) -> R
751where
752 F: FnOnce() -> R,
753{
754 let step_name = name.into();
755
756 with_context(|ctx| ctx.start_step(&step_name));
757
758 let result = catch_unwind(AssertUnwindSafe(body));
759
760 match &result {
761 Ok(_) => {
762 with_context(|ctx| ctx.finish_step(Status::Passed, None, None));
763 }
764 Err(panic_info) => {
765 let message = if let Some(s) = panic_info.downcast_ref::<&str>() {
766 Some(s.to_string())
767 } else if let Some(s) = panic_info.downcast_ref::<String>() {
768 Some(s.clone())
769 } else {
770 Some("Step panicked".to_string())
771 };
772 let trace = capture_trace();
773 with_context(|ctx| ctx.finish_step(Status::Failed, message, trace));
774 }
775 }
776
777 match result {
778 Ok(value) => value,
779 Err(e) => std::panic::resume_unwind(e),
780 }
781}
782
783pub fn log_step(name: impl Into<String>, status: Status) {
800 with_context(|ctx| {
801 ctx.start_step(name);
802 ctx.finish_step(status, None, None);
803 });
804}
805
806pub fn attach_text(name: impl Into<String>, content: impl AsRef<str>) {
819 with_context(|ctx| ctx.attach_text(name, content));
820}
821
822pub fn attach_json<T: serde::Serialize>(name: impl Into<String>, value: &T) {
847 with_context(|ctx| ctx.attach_json(name, value));
848}
849
850pub fn attach_binary(name: impl Into<String>, content: &[u8], content_type: ContentType) {
864 with_context(|ctx| ctx.attach_binary(name, content, content_type));
865}
866
867pub fn flaky() {
883 with_context(|ctx| {
884 let details = ctx
885 .result
886 .status_details
887 .get_or_insert_with(Default::default);
888 details.flaky = Some(true);
889 });
890}
891
892pub fn muted() {
909 with_context(|ctx| {
910 let details = ctx
911 .result
912 .status_details
913 .get_or_insert_with(Default::default);
914 details.muted = Some(true);
915 });
916}
917
918pub fn known_issue(issue_id: impl Into<String>) {
932 let id = issue_id.into();
933 with_context(|ctx| {
934 let details = ctx
935 .result
936 .status_details
937 .get_or_insert_with(Default::default);
938 details.known = Some(true);
939 ctx.add_link(&id, Some(id.clone()), LinkType::Issue);
941 });
942}
943
944pub fn skip(reason: impl Into<String>) {
946 let reason = reason.into();
947 if let Some(mut ctx) = take_context() {
948 ctx.finish(Status::Skipped, Some(reason), None);
949 }
950}
951
952pub fn display_name(name: impl Into<String>) {
956 with_context(|ctx| ctx.result.name = name.into());
957}
958
959pub fn test_case_id(id: impl Into<String>) {
963 with_context(|ctx| ctx.result.test_case_id = Some(id.into()));
964}
965
966pub fn attach_file(
970 name: impl Into<String>,
971 path: impl AsRef<std::path::Path>,
972 content_type: Option<ContentType>,
973) {
974 with_context(|ctx| ctx.attach_file(name, path, content_type));
975}
976
977fn capture_trace() -> Option<String> {
979 let bt = Backtrace::force_capture();
980 let snapshot = format!("{bt:?}");
981 if snapshot.contains("disabled") {
982 return None;
983 }
984 Some(snapshot)
985}
986
987#[cfg(feature = "tokio")]
989pub async fn with_async_context<F, R>(ctx: TestContext, fut: F) -> R
990where
991 F: std::future::Future<Output = R>,
992{
993 let handle = Arc::new(Mutex::new(Some(ctx)));
994 {
995 let mut slot = global_async_context().lock().unwrap();
996 *slot = Some(handle.clone());
997 }
998
999 let cell = RefCell::new(Some(handle));
1000 let result = TOKIO_CONTEXT.scope(cell, fut).await;
1001
1002 let mut slot = global_async_context().lock().unwrap();
1003 slot.take();
1004
1005 result
1006}
1007
1008#[cfg(not(feature = "tokio"))]
1010pub async fn with_async_context<F, R>(ctx: TestContext, fut: F) -> R
1011where
1012 F: std::future::Future<Output = R>,
1013{
1014 set_context(ctx);
1015 let result = fut.await;
1016 let _ = take_context();
1017 result
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022 use super::*;
1023 use serde_json::Value;
1024 use std::path::PathBuf;
1025
1026 #[test]
1027 fn test_config_builder() {
1028 let config = AllureConfigBuilder::new()
1029 .results_dir("custom-results")
1030 .clean_results(false)
1031 .config;
1032
1033 assert_eq!(config.results_dir, "custom-results");
1034 assert!(!config.clean_results);
1035 }
1036
1037 #[test]
1038 fn test_context_creation() {
1039 let ctx = TestContext::new("My Test", "tests::my_test");
1040 assert_eq!(ctx.result.name, "My Test");
1041 assert_eq!(ctx.result.full_name, Some("tests::my_test".to_string()));
1042 assert!(ctx
1043 .result
1044 .labels
1045 .iter()
1046 .any(|l| l.name == "language" && l.value == "rust"));
1047 }
1048
1049 #[test]
1050 fn test_step_nesting() {
1051 let mut ctx = TestContext::new("Test", "test::test");
1052
1053 ctx.start_step("Step 1");
1054 ctx.start_step("Step 1.1");
1055 ctx.finish_step(Status::Passed, None, None);
1056 ctx.finish_step(Status::Passed, None, None);
1057
1058 assert_eq!(ctx.result.steps.len(), 1);
1059 assert_eq!(ctx.result.steps[0].name, "Step 1");
1060 assert_eq!(ctx.result.steps[0].steps.len(), 1);
1061 assert_eq!(ctx.result.steps[0].steps[0].name, "Step 1.1");
1062 }
1063
1064 #[test]
1065 fn test_thread_local_context() {
1066 let ctx = TestContext::new("Test", "test::test");
1067 set_context(ctx);
1068
1069 with_context(|ctx| {
1070 ctx.add_label("custom", "value");
1071 });
1072
1073 let ctx = take_context().unwrap();
1074 assert!(ctx
1075 .result
1076 .labels
1077 .iter()
1078 .any(|l| l.name == "custom" && l.value == "value"));
1079 }
1080
1081 #[test]
1082 fn test_capture_trace_runs() {
1083 let _maybe_trace = capture_trace();
1085 }
1086
1087 #[test]
1088 fn test_run_test_writes_results_and_container_on_panic() {
1089 let desired_dir = PathBuf::from("target/allure-runtime-tests");
1090 let _ = std::fs::remove_dir_all(&desired_dir);
1091
1092 let config_ref = CONFIG.get_or_init(|| AllureConfig {
1093 results_dir: desired_dir.to_string_lossy().to_string(),
1094 clean_results: true,
1095 });
1096 let dir = PathBuf::from(&config_ref.results_dir);
1097 let _ = std::fs::remove_dir_all(&dir);
1098 std::fs::create_dir_all(&dir).unwrap();
1099
1100 let outcome = std::panic::catch_unwind(|| {
1101 run_test("panic_test", "runtime::panic_test", || {
1102 panic!("runtime boom");
1103 });
1104 });
1105 assert!(outcome.is_err());
1106
1107 let mut result_files = Vec::new();
1108 let mut container_files = Vec::new();
1109 for entry in std::fs::read_dir(&dir).unwrap() {
1110 let path = entry.unwrap().path();
1111 if path.extension().and_then(|e| e.to_str()) == Some("json") {
1112 let name = path.file_name().unwrap().to_string_lossy().to_string();
1113 if name.contains("-result.json") {
1114 result_files.push(path.clone());
1115 } else if name.contains("-container.json") {
1116 container_files.push(path.clone());
1117 }
1118 }
1119 }
1120
1121 assert!(!result_files.is_empty());
1122 assert!(!container_files.is_empty());
1123
1124 let result_json: Value =
1125 serde_json::from_str(&std::fs::read_to_string(&result_files[0]).unwrap()).unwrap();
1126 assert_eq!(result_json["status"], "failed");
1127 assert!(result_json["statusDetails"]["message"]
1128 .as_str()
1129 .unwrap()
1130 .contains("runtime boom"));
1131 }
1132
1133 #[cfg(feature = "tokio")]
1134 #[tokio::test(flavor = "current_thread")]
1135 async fn test_take_context_reads_tokio_task_local() {
1136 let ctx = TestContext::new("tokio_ctx", "module::tokio_ctx");
1137 let taken = with_async_context(ctx, async {
1138 let inner = take_context();
1139 assert!(inner.is_some());
1140 inner.unwrap().result.name
1141 })
1142 .await;
1143 assert_eq!(taken, "tokio_ctx");
1144 }
1145
1146 #[cfg(feature = "tokio")]
1147 #[tokio::test(flavor = "current_thread")]
1148 async fn test_with_context_uses_tokio_task_local() {
1149 let ctx = TestContext::new("tokio_ctx", "module::tokio_ctx");
1150 with_async_context(ctx, async {
1151 let mut seen = None;
1152 with_context(|c| {
1153 seen = Some(c.result.name.clone());
1154 });
1155 assert_eq!(seen.as_deref(), Some("tokio_ctx"));
1156 })
1157 .await;
1158 }
1159
1160 #[test]
1161 fn test_with_test_context_clears_after_use() {
1162 with_test_context(|| {
1163 label("temp", "value");
1164 });
1165 assert!(take_context().is_none());
1166 }
1167
1168 #[test]
1169 fn test_tags_and_metadata_helpers() {
1170 let ctx = TestContext::new("meta", "module::meta");
1171 set_context(ctx);
1172
1173 label("env", "staging");
1174 tags(&["smoke", "api"]);
1175 title("Custom Title");
1176 description("Markdown");
1177 description_html("<p>HTML</p>");
1178 test_case_id("TC-1");
1179
1180 let ctx = take_context().unwrap();
1181 assert_eq!(ctx.result.name, "Custom Title");
1182 assert_eq!(ctx.result.description.as_deref(), Some("Markdown"));
1183 assert_eq!(ctx.result.description_html.as_deref(), Some("<p>HTML</p>"));
1184 assert_eq!(ctx.result.test_case_id.as_deref(), Some("TC-1"));
1185 assert!(ctx.result.labels.iter().any(|l| l.value == "staging"));
1186 assert!(ctx.result.labels.iter().any(|l| l.value == "smoke"));
1187 assert!(ctx.result.labels.iter().any(|l| l.value == "api"));
1188 }
1189
1190 #[test]
1191 fn test_step_failure_records_message_and_rethrows() {
1192 let ctx = TestContext::new("step_fail", "module::step_fail");
1193 set_context(ctx);
1194
1195 let result = std::panic::catch_unwind(|| {
1196 step("will panic", || panic!("boom step"));
1197 });
1198 assert!(result.is_err());
1199
1200 let ctx = take_context().unwrap();
1201 assert_eq!(ctx.result.steps.len(), 1);
1202 let step = &ctx.result.steps[0];
1203 assert_eq!(step.status, Status::Failed);
1204 assert!(step
1205 .status_details
1206 .as_ref()
1207 .unwrap()
1208 .message
1209 .as_ref()
1210 .unwrap()
1211 .contains("boom step"));
1212 }
1213
1214 #[test]
1215 fn test_finish_step_skipped_branch() {
1216 let mut ctx = TestContext::new("skip_step", "module::skip_step");
1217 ctx.start_step("inner");
1218 ctx.finish_step(
1219 Status::Skipped,
1220 Some("not run".into()),
1221 Some("trace".into()),
1222 );
1223 assert_eq!(ctx.result.steps[0].status, Status::Skipped);
1224 assert_eq!(ctx.result.steps[0].stage, crate::enums::Stage::Finished);
1225 }
1226
1227 #[test]
1228 fn test_finish_step_broken_and_unknown_branches() {
1229 let mut ctx = TestContext::new("broken_step", "module::broken_step");
1230 ctx.start_step("broken");
1231 ctx.finish_step(Status::Broken, Some("oops".into()), None);
1232 assert_eq!(ctx.result.steps[0].status, Status::Broken);
1233 assert!(ctx.result.steps[0]
1234 .status_details
1235 .as_ref()
1236 .unwrap()
1237 .message
1238 .as_ref()
1239 .unwrap()
1240 .contains("oops"));
1241
1242 ctx.start_step("unknown");
1243 ctx.finish_step(Status::Unknown, None, None);
1244 assert_eq!(ctx.result.steps[1].status, Status::Unknown);
1245 assert_eq!(ctx.result.steps[1].stage, crate::enums::Stage::Finished);
1246 }
1247
1248 #[test]
1249 fn test_muted_sets_flag() {
1250 let ctx = TestContext::new("muted_test", "module::muted_test");
1251 set_context(ctx);
1252 muted();
1253 let ctx = take_context().unwrap();
1254 let details = ctx.result.status_details.unwrap();
1255 assert_eq!(details.muted, Some(true));
1256 }
1257
1258 #[test]
1259 fn test_host_env_override_used_in_context_creation() {
1260 std::env::set_var("HOSTNAME", "test-host");
1261 let ctx = TestContext::new("hosted", "module::hosted");
1262 assert!(ctx
1263 .result
1264 .labels
1265 .iter()
1266 .any(|l| l.name == "host" && l.value == "test-host"));
1267 }
1268
1269 #[test]
1270 fn test_add_parameter_struct_applies_to_steps() {
1271 let mut ctx = TestContext::new("params", "module::params");
1272 ctx.start_step("outer");
1273 ctx.add_parameter_struct(crate::model::Parameter::excluded("k", "v"));
1274 assert_eq!(ctx.step_stack[0].parameters.len(), 1);
1275 assert_eq!(ctx.step_stack[0].parameters[0].excluded, Some(true));
1276 }
1277
1278 #[test]
1279 fn test_finish_writes_and_breaks_unfinished_steps() {
1280 let temp = tempfile::tempdir().unwrap();
1281 CONFIG.get_or_init(|| AllureConfig {
1282 results_dir: temp.path().to_string_lossy().to_string(),
1283 clean_results: true,
1284 });
1285 let mut ctx = TestContext::new("unclosed", "module::unclosed");
1286 ctx.start_step("still running");
1287 ctx.finish(Status::Passed, None, None);
1288 assert_eq!(ctx.result.steps[0].status, Status::Broken);
1289 assert!(ctx.result.steps[0]
1290 .status_details
1291 .as_ref()
1292 .unwrap()
1293 .message
1294 .as_ref()
1295 .unwrap()
1296 .contains("Step not completed"));
1297 }
1298
1299 #[test]
1300 fn test_finish_handles_broken_status_with_details() {
1301 let temp = tempfile::tempdir().unwrap();
1302 CONFIG.get_or_init(|| AllureConfig {
1303 results_dir: temp.path().to_string_lossy().to_string(),
1304 clean_results: true,
1305 });
1306 let mut ctx = TestContext::new("broken_test", "module::broken_test");
1307 ctx.finish(Status::Broken, Some("fail".into()), Some("trace".into()));
1308 assert_eq!(ctx.result.status, Status::Broken);
1309 let details = ctx.result.status_details.as_ref().unwrap();
1310 assert_eq!(details.message.as_deref(), Some("fail"));
1311 assert_eq!(details.trace.as_deref(), Some("trace"));
1312 }
1313
1314 #[test]
1315 fn test_context_creation_uses_hostname_when_env_missing() {
1316 let original = std::env::var("HOSTNAME").ok();
1318 std::env::remove_var("HOSTNAME");
1319 let ctx = TestContext::new("host", "module::host");
1320 if let Some(orig) = original {
1321 std::env::set_var("HOSTNAME", orig);
1322 }
1323 assert!(ctx.result.labels.iter().any(|l| l.name == "host"));
1324 }
1325}