Skip to main content

testx/plugin/reporters/
notify.rs

1//! Desktop notification reporter plugin.
2//!
3//! Sends OS-level notifications on test completion using
4//! `notify-send` (Linux), `osascript` (macOS), or
5//! `powershell` (Windows).
6
7#[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/// Notification reporter configuration.
16#[derive(Debug, Clone)]
17pub struct NotifyConfig {
18    /// Only notify on failure
19    pub on_failure_only: bool,
20    /// Custom notification title prefix
21    pub title_prefix: String,
22    /// Notification urgency on failure: "low", "normal", "critical"
23    pub urgency: String,
24    /// Timeout in milliseconds (0 = system default)
25    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
39/// Desktop notification reporter plugin.
40pub struct NotifyReporter {
41    config: NotifyConfig,
42    last_notification: Option<Notification>,
43}
44
45/// Captured notification for testing / inspection.
46#[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    /// Get the last notification that was built (for testing).
62    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        // Best-effort send — don't fire real notifications during tests
89        #[cfg(not(test))]
90        {
91            let _ = send_notification(&notification, &self.config);
92        }
93        Ok(())
94    }
95}
96
97/// Build a notification from test results.
98pub 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/// Send a notification using OS-specific tools.
130#[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/// Send notification via `notify-send` on Linux.
151#[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(&notification.urgency);
155
156    if config.timeout_ms > 0 {
157        cmd.arg("--expire-time").arg(config.timeout_ms.to_string());
158    }
159
160    cmd.arg(&notification.title).arg(&notification.body);
161
162    cmd.output()?;
163    Ok(())
164}
165
166/// Send notification via `osascript` on macOS.
167#[cfg(all(target_os = "macos", not(test)))]
168fn send_macos(notification: &Notification, _config: &NotifyConfig) -> std::io::Result<()> {
169    // Use quoted form to prevent AppleScript injection.
170    // Escape backslashes first, then double-quotes.
171    fn applescript_escape(s: &str) -> String {
172        s.replace('\\', "\\\\").replace('"', "\\\"")
173    }
174
175    let script = format!(
176        "display notification \"{}\" with title \"{}\"",
177        applescript_escape(&notification.body),
178        applescript_escape(&notification.title),
179    );
180
181    Command::new("osascript").arg("-e").arg(&script).output()?;
182    Ok(())
183}
184
185/// Send notification via PowerShell on Windows.
186#[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('&', "&amp;")
190            .replace('<', "&lt;")
191            .replace('>', "&gt;")
192            .replace('"', "&quot;")
193            .replace('\'', "&apos;")
194    }
195
196    let title = xml_escape(&notification.title);
197    let body = xml_escape(&notification.body);
198
199    // Write XML to a temp file to avoid PowerShell injection via here-string
200    // terminators ("'@") in test names or output.
201    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    // ─── Edge Case Tests ────────────────────────────────────────────────
378
379    #[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        // Pass always gets "low" regardless of config
432        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        // shutdown is a default trait method — ensure it returns Ok
476        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}