1#[cfg(not(test))]
8use std::process::Command;
9
10use crate::adapters::TestRunResult;
11use crate::error;
12use crate::events::TestEvent;
13use crate::plugin::Plugin;
14
15#[derive(Debug, Clone)]
17pub struct NotifyConfig {
18 pub on_failure_only: bool,
20 pub title_prefix: String,
22 pub urgency: String,
24 pub timeout_ms: u32,
26}
27
28impl Default for NotifyConfig {
29 fn default() -> Self {
30 Self {
31 on_failure_only: false,
32 title_prefix: "testx".into(),
33 urgency: "normal".into(),
34 timeout_ms: 5000,
35 }
36 }
37}
38
39pub struct NotifyReporter {
41 config: NotifyConfig,
42 last_notification: Option<Notification>,
43}
44
45#[derive(Debug, Clone, PartialEq)]
47pub struct Notification {
48 pub title: String,
49 pub body: String,
50 pub urgency: String,
51}
52
53impl NotifyReporter {
54 pub fn new(config: NotifyConfig) -> Self {
55 Self {
56 config,
57 last_notification: None,
58 }
59 }
60
61 pub fn last_notification(&self) -> Option<&Notification> {
63 self.last_notification.as_ref()
64 }
65}
66
67impl Plugin for NotifyReporter {
68 fn name(&self) -> &str {
69 "notify"
70 }
71
72 fn version(&self) -> &str {
73 "1.0.0"
74 }
75
76 fn on_event(&mut self, _event: &TestEvent) -> error::Result<()> {
77 Ok(())
78 }
79
80 fn on_result(&mut self, result: &TestRunResult) -> error::Result<()> {
81 if self.config.on_failure_only && result.is_success() {
82 return Ok(());
83 }
84
85 let notification = build_notification(result, &self.config);
86 self.last_notification = Some(notification.clone());
87
88 #[cfg(not(test))]
90 {
91 let _ = send_notification(¬ification, &self.config);
92 }
93 Ok(())
94 }
95}
96
97pub fn build_notification(result: &TestRunResult, config: &NotifyConfig) -> Notification {
99 let status = if result.is_success() {
100 "PASSED"
101 } else {
102 "FAILED"
103 };
104
105 let title = format!("{} — {status}", config.title_prefix);
106
107 let body = format!(
108 "{} tests: {} passed, {} failed, {} skipped\nDuration: {:.2}s",
109 result.total_tests(),
110 result.total_passed(),
111 result.total_failed(),
112 result.total_skipped(),
113 result.duration.as_secs_f64(),
114 );
115
116 let urgency = if result.is_success() {
117 "low".to_string()
118 } else {
119 config.urgency.clone()
120 };
121
122 Notification {
123 title,
124 body,
125 urgency,
126 }
127}
128
129#[cfg(not(test))]
131fn send_notification(notification: &Notification, config: &NotifyConfig) -> std::io::Result<()> {
132 #[cfg(target_os = "linux")]
133 {
134 send_linux(notification, config)?;
135 }
136
137 #[cfg(target_os = "macos")]
138 {
139 send_macos(notification, config)?;
140 }
141
142 #[cfg(target_os = "windows")]
143 {
144 send_windows(notification, config)?;
145 }
146
147 Ok(())
148}
149
150#[cfg(all(target_os = "linux", not(test)))]
152fn send_linux(notification: &Notification, config: &NotifyConfig) -> std::io::Result<()> {
153 let mut cmd = Command::new("notify-send");
154 cmd.arg("--urgency").arg(¬ification.urgency);
155
156 if config.timeout_ms > 0 {
157 cmd.arg("--expire-time").arg(config.timeout_ms.to_string());
158 }
159
160 cmd.arg(¬ification.title).arg(¬ification.body);
161
162 cmd.output()?;
163 Ok(())
164}
165
166#[cfg(all(target_os = "macos", not(test)))]
168fn send_macos(notification: &Notification, _config: &NotifyConfig) -> std::io::Result<()> {
169 fn applescript_escape(s: &str) -> String {
172 s.replace('\\', "\\\\").replace('"', "\\\"")
173 }
174
175 let script = format!(
176 "display notification \"{}\" with title \"{}\"",
177 applescript_escape(¬ification.body),
178 applescript_escape(¬ification.title),
179 );
180
181 Command::new("osascript").arg("-e").arg(&script).output()?;
182 Ok(())
183}
184
185#[cfg(all(target_os = "windows", not(test)))]
187fn send_windows(notification: &Notification, _config: &NotifyConfig) -> std::io::Result<()> {
188 fn xml_escape(s: &str) -> String {
189 s.replace('&', "&")
190 .replace('<', "<")
191 .replace('>', ">")
192 .replace('"', """)
193 .replace('\'', "'")
194 }
195
196 let title = xml_escape(¬ification.title);
197 let body = xml_escape(¬ification.body);
198
199 let tmp = std::env::temp_dir().join("testx_toast.xml");
202 let xml_content = format!(
203 "<toast><visual><binding template=\"ToastText02\"><text id=\"1\">{}</text><text id=\"2\">{}</text></binding></visual></toast>",
204 title, body,
205 );
206 std::fs::write(&tmp, &xml_content)?;
207
208 let script = format!(
209 "[Windows.UI.Notifications.ToastNotificationManager,Windows.UI.Notifications,ContentType=WindowsRuntime] | Out-Null; \
210 $xml = [xml](Get-Content -Raw -LiteralPath '{}'); \
211 $toast = [Windows.UI.Notifications.ToastNotification]::new($xml); \
212 [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('testx').Show($toast)",
213 tmp.display(),
214 );
215
216 Command::new("powershell")
217 .arg("-Command")
218 .arg(&script)
219 .output()?;
220 Ok(())
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use crate::adapters::TestStatus;
227 use crate::adapters::{TestCase, TestSuite};
228 use std::time::Duration;
229
230 fn make_test(name: &str, status: TestStatus, ms: u64) -> TestCase {
231 TestCase {
232 name: name.into(),
233 status,
234 duration: Duration::from_millis(ms),
235 error: None,
236 }
237 }
238
239 fn passing_result() -> TestRunResult {
240 TestRunResult {
241 suites: vec![TestSuite {
242 name: "math".into(),
243 tests: vec![
244 make_test("add", TestStatus::Passed, 10),
245 make_test("sub", TestStatus::Passed, 20),
246 ],
247 }],
248 duration: Duration::from_millis(100),
249 raw_exit_code: 0,
250 }
251 }
252
253 fn failing_result() -> TestRunResult {
254 TestRunResult {
255 suites: vec![TestSuite {
256 name: "math".into(),
257 tests: vec![
258 make_test("add", TestStatus::Passed, 10),
259 make_test("div", TestStatus::Failed, 5),
260 ],
261 }],
262 duration: Duration::from_millis(100),
263 raw_exit_code: 1,
264 }
265 }
266
267 #[test]
268 fn notification_pass_title() {
269 let n = build_notification(&passing_result(), &NotifyConfig::default());
270 assert!(n.title.contains("PASSED"));
271 assert!(n.title.contains("testx"));
272 }
273
274 #[test]
275 fn notification_fail_title() {
276 let n = build_notification(&failing_result(), &NotifyConfig::default());
277 assert!(n.title.contains("FAILED"));
278 }
279
280 #[test]
281 fn notification_body_counts() {
282 let n = build_notification(&failing_result(), &NotifyConfig::default());
283 assert!(n.body.contains("2 tests"));
284 assert!(n.body.contains("1 passed"));
285 assert!(n.body.contains("1 failed"));
286 }
287
288 #[test]
289 fn notification_urgency_pass() {
290 let n = build_notification(&passing_result(), &NotifyConfig::default());
291 assert_eq!(n.urgency, "low");
292 }
293
294 #[test]
295 fn notification_urgency_fail() {
296 let n = build_notification(&failing_result(), &NotifyConfig::default());
297 assert_eq!(n.urgency, "normal");
298 }
299
300 #[test]
301 fn notification_custom_urgency() {
302 let config = NotifyConfig {
303 urgency: "critical".into(),
304 ..Default::default()
305 };
306 let n = build_notification(&failing_result(), &config);
307 assert_eq!(n.urgency, "critical");
308 }
309
310 #[test]
311 fn notification_custom_prefix() {
312 let config = NotifyConfig {
313 title_prefix: "mytest".into(),
314 ..Default::default()
315 };
316 let n = build_notification(&passing_result(), &config);
317 assert!(n.title.starts_with("mytest"));
318 }
319
320 #[test]
321 fn plugin_on_failure_only_skip_pass() {
322 let mut reporter = NotifyReporter::new(NotifyConfig {
323 on_failure_only: true,
324 ..Default::default()
325 });
326 reporter.on_result(&passing_result()).unwrap();
327 assert!(reporter.last_notification().is_none());
328 }
329
330 #[test]
331 fn plugin_on_failure_only_send_fail() {
332 let mut reporter = NotifyReporter::new(NotifyConfig {
333 on_failure_only: true,
334 ..Default::default()
335 });
336 reporter.on_result(&failing_result()).unwrap();
337 assert!(reporter.last_notification().is_some());
338 }
339
340 #[test]
341 fn plugin_always_notify() {
342 let mut reporter = NotifyReporter::new(NotifyConfig::default());
343 reporter.on_result(&passing_result()).unwrap();
344 assert!(reporter.last_notification().is_some());
345 }
346
347 #[test]
348 fn plugin_name_version() {
349 let reporter = NotifyReporter::new(NotifyConfig::default());
350 assert_eq!(reporter.name(), "notify");
351 assert_eq!(reporter.version(), "1.0.0");
352 }
353
354 #[test]
355 fn notification_body_duration() {
356 let n = build_notification(&passing_result(), &NotifyConfig::default());
357 assert!(n.body.contains("Duration:"));
358 }
359
360 #[test]
361 fn notification_skipped_count() {
362 let result = TestRunResult {
363 suites: vec![TestSuite {
364 name: "t".into(),
365 tests: vec![
366 make_test("t1", TestStatus::Passed, 1),
367 make_test("t2", TestStatus::Skipped, 0),
368 ],
369 }],
370 duration: Duration::from_millis(10),
371 raw_exit_code: 0,
372 };
373 let n = build_notification(&result, &NotifyConfig::default());
374 assert!(n.body.contains("1 skipped"));
375 }
376
377 #[test]
380 fn notification_empty_result() {
381 let result = TestRunResult {
382 suites: vec![],
383 duration: Duration::ZERO,
384 raw_exit_code: 0,
385 };
386 let n = build_notification(&result, &NotifyConfig::default());
387 assert!(n.title.contains("PASSED"));
388 assert!(n.body.contains("0 tests"));
389 assert!(n.body.contains("0 passed"));
390 assert!(n.body.contains("0 failed"));
391 assert!(n.body.contains("0 skipped"));
392 }
393
394 #[test]
395 fn notification_all_skipped() {
396 let result = TestRunResult {
397 suites: vec![TestSuite {
398 name: "s".into(),
399 tests: vec![
400 make_test("a", TestStatus::Skipped, 0),
401 make_test("b", TestStatus::Skipped, 0),
402 ],
403 }],
404 duration: Duration::from_millis(1),
405 raw_exit_code: 0,
406 };
407 let n = build_notification(&result, &NotifyConfig::default());
408 assert!(n.title.contains("PASSED"));
409 assert!(n.body.contains("2 skipped"));
410 assert_eq!(n.urgency, "low");
411 }
412
413 #[test]
414 fn notification_empty_prefix() {
415 let config = NotifyConfig {
416 title_prefix: "".into(),
417 ..Default::default()
418 };
419 let n = build_notification(&passing_result(), &config);
420 assert!(n.title.contains("PASSED"));
421 assert!(n.title.starts_with(" — PASSED"));
422 }
423
424 #[test]
425 fn notification_custom_urgency_ignored_on_pass() {
426 let config = NotifyConfig {
427 urgency: "critical".into(),
428 ..Default::default()
429 };
430 let n = build_notification(&passing_result(), &config);
431 assert_eq!(n.urgency, "low");
433 }
434
435 #[test]
436 fn notification_zero_duration() {
437 let result = TestRunResult {
438 suites: vec![],
439 duration: Duration::ZERO,
440 raw_exit_code: 0,
441 };
442 let n = build_notification(&result, &NotifyConfig::default());
443 assert!(n.body.contains("0.00s"));
444 }
445
446 #[test]
447 fn notification_long_duration() {
448 let result = TestRunResult {
449 suites: vec![TestSuite {
450 name: "s".into(),
451 tests: vec![make_test("t", TestStatus::Passed, 1)],
452 }],
453 duration: Duration::from_secs(600),
454 raw_exit_code: 0,
455 };
456 let n = build_notification(&result, &NotifyConfig::default());
457 assert!(n.body.contains("600.00s"));
458 }
459
460 #[test]
461 fn plugin_on_event_noop() {
462 let mut r = NotifyReporter::new(NotifyConfig::default());
463 assert!(
464 r.on_event(&crate::events::TestEvent::Warning {
465 message: "x".into()
466 })
467 .is_ok()
468 );
469 assert!(r.last_notification().is_none());
470 }
471
472 #[test]
473 fn plugin_shutdown_ok() {
474 let r = NotifyReporter::new(NotifyConfig::default());
475 let mut r = r;
477 assert!(Plugin::shutdown(&mut r).is_ok());
478 }
479
480 #[test]
481 fn plugin_multiple_on_result_keeps_last() {
482 let mut r = NotifyReporter::new(NotifyConfig::default());
483 r.on_result(&passing_result()).unwrap();
484 assert!(r.last_notification().unwrap().title.contains("PASSED"));
485
486 r.on_result(&failing_result()).unwrap();
487 assert!(r.last_notification().unwrap().title.contains("FAILED"));
488 }
489
490 #[test]
491 fn plugin_on_failure_only_false_sends_on_pass() {
492 let mut r = NotifyReporter::new(NotifyConfig {
493 on_failure_only: false,
494 ..Default::default()
495 });
496 r.on_result(&passing_result()).unwrap();
497 assert!(r.last_notification().is_some());
498 }
499
500 #[test]
501 fn notification_config_default_values() {
502 let c = NotifyConfig::default();
503 assert!(!c.on_failure_only);
504 assert_eq!(c.title_prefix, "testx");
505 assert_eq!(c.urgency, "normal");
506 assert_eq!(c.timeout_ms, 5000);
507 }
508
509 #[test]
510 fn notification_config_clone() {
511 let c = NotifyConfig {
512 on_failure_only: true,
513 title_prefix: "custom".into(),
514 urgency: "critical".into(),
515 timeout_ms: 0,
516 };
517 let c2 = c.clone();
518 assert_eq!(c2.title_prefix, "custom");
519 assert_eq!(c2.timeout_ms, 0);
520 assert!(c2.on_failure_only);
521 }
522
523 #[test]
524 fn notification_struct_equality() {
525 let n1 = Notification {
526 title: "t".into(),
527 body: "b".into(),
528 urgency: "low".into(),
529 };
530 let n2 = n1.clone();
531 assert_eq!(n1, n2);
532 }
533
534 #[test]
535 fn notification_struct_debug() {
536 let n = Notification {
537 title: "t".into(),
538 body: "b".into(),
539 urgency: "low".into(),
540 };
541 let dbg = format!("{n:?}");
542 assert!(dbg.contains("Notification"));
543 }
544
545 #[test]
546 fn notification_many_suites() {
547 let suites: Vec<TestSuite> = (0..5)
548 .map(|i| TestSuite {
549 name: format!("suite_{i}"),
550 tests: vec![
551 make_test(&format!("p_{i}"), TestStatus::Passed, 1),
552 make_test(&format!("f_{i}"), TestStatus::Failed, 1),
553 ],
554 })
555 .collect();
556 let result = TestRunResult {
557 suites,
558 duration: Duration::from_millis(50),
559 raw_exit_code: 1,
560 };
561 let n = build_notification(&result, &NotifyConfig::default());
562 assert!(n.body.contains("10 tests"));
563 assert!(n.body.contains("5 passed"));
564 assert!(n.body.contains("5 failed"));
565 assert!(n.title.contains("FAILED"));
566 }
567}