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    let script = format!(
170        "display notification \"{}\" with title \"{}\"",
171        notification.body.replace('"', "\\\""),
172        notification.title.replace('"', "\\\""),
173    );
174
175    Command::new("osascript").arg("-e").arg(&script).output()?;
176    Ok(())
177}
178
179/// Send notification via PowerShell on Windows.
180#[cfg(all(target_os = "windows", not(test)))]
181fn send_windows(notification: &Notification, _config: &NotifyConfig) -> std::io::Result<()> {
182    let script = format!(
183        "[Windows.UI.Notifications.ToastNotificationManager,Windows.UI.Notifications,ContentType=WindowsRuntime] | Out-Null; \
184        $xml = '<toast><visual><binding template=\"ToastText02\"><text id=\"1\">{}</text><text id=\"2\">{}</text></binding></visual></toast>'; \
185        $toast = [Windows.UI.Notifications.ToastNotification]::new([xml]$xml); \
186        [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('testx').Show($toast)",
187        notification
188            .title
189            .replace('&', "&amp;")
190            .replace('<', "&lt;")
191            .replace('>', "&gt;"),
192        notification
193            .body
194            .replace('&', "&amp;")
195            .replace('<', "&lt;")
196            .replace('>', "&gt;"),
197    );
198
199    Command::new("powershell")
200        .arg("-Command")
201        .arg(&script)
202        .output()?;
203    Ok(())
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::adapters::TestStatus;
210    use crate::adapters::{TestCase, TestSuite};
211    use std::time::Duration;
212
213    fn make_test(name: &str, status: TestStatus, ms: u64) -> TestCase {
214        TestCase {
215            name: name.into(),
216            status,
217            duration: Duration::from_millis(ms),
218            error: None,
219        }
220    }
221
222    fn passing_result() -> TestRunResult {
223        TestRunResult {
224            suites: vec![TestSuite {
225                name: "math".into(),
226                tests: vec![
227                    make_test("add", TestStatus::Passed, 10),
228                    make_test("sub", TestStatus::Passed, 20),
229                ],
230            }],
231            duration: Duration::from_millis(100),
232            raw_exit_code: 0,
233        }
234    }
235
236    fn failing_result() -> TestRunResult {
237        TestRunResult {
238            suites: vec![TestSuite {
239                name: "math".into(),
240                tests: vec![
241                    make_test("add", TestStatus::Passed, 10),
242                    make_test("div", TestStatus::Failed, 5),
243                ],
244            }],
245            duration: Duration::from_millis(100),
246            raw_exit_code: 1,
247        }
248    }
249
250    #[test]
251    fn notification_pass_title() {
252        let n = build_notification(&passing_result(), &NotifyConfig::default());
253        assert!(n.title.contains("PASSED"));
254        assert!(n.title.contains("testx"));
255    }
256
257    #[test]
258    fn notification_fail_title() {
259        let n = build_notification(&failing_result(), &NotifyConfig::default());
260        assert!(n.title.contains("FAILED"));
261    }
262
263    #[test]
264    fn notification_body_counts() {
265        let n = build_notification(&failing_result(), &NotifyConfig::default());
266        assert!(n.body.contains("2 tests"));
267        assert!(n.body.contains("1 passed"));
268        assert!(n.body.contains("1 failed"));
269    }
270
271    #[test]
272    fn notification_urgency_pass() {
273        let n = build_notification(&passing_result(), &NotifyConfig::default());
274        assert_eq!(n.urgency, "low");
275    }
276
277    #[test]
278    fn notification_urgency_fail() {
279        let n = build_notification(&failing_result(), &NotifyConfig::default());
280        assert_eq!(n.urgency, "normal");
281    }
282
283    #[test]
284    fn notification_custom_urgency() {
285        let config = NotifyConfig {
286            urgency: "critical".into(),
287            ..Default::default()
288        };
289        let n = build_notification(&failing_result(), &config);
290        assert_eq!(n.urgency, "critical");
291    }
292
293    #[test]
294    fn notification_custom_prefix() {
295        let config = NotifyConfig {
296            title_prefix: "mytest".into(),
297            ..Default::default()
298        };
299        let n = build_notification(&passing_result(), &config);
300        assert!(n.title.starts_with("mytest"));
301    }
302
303    #[test]
304    fn plugin_on_failure_only_skip_pass() {
305        let mut reporter = NotifyReporter::new(NotifyConfig {
306            on_failure_only: true,
307            ..Default::default()
308        });
309        reporter.on_result(&passing_result()).unwrap();
310        assert!(reporter.last_notification().is_none());
311    }
312
313    #[test]
314    fn plugin_on_failure_only_send_fail() {
315        let mut reporter = NotifyReporter::new(NotifyConfig {
316            on_failure_only: true,
317            ..Default::default()
318        });
319        reporter.on_result(&failing_result()).unwrap();
320        assert!(reporter.last_notification().is_some());
321    }
322
323    #[test]
324    fn plugin_always_notify() {
325        let mut reporter = NotifyReporter::new(NotifyConfig::default());
326        reporter.on_result(&passing_result()).unwrap();
327        assert!(reporter.last_notification().is_some());
328    }
329
330    #[test]
331    fn plugin_name_version() {
332        let reporter = NotifyReporter::new(NotifyConfig::default());
333        assert_eq!(reporter.name(), "notify");
334        assert_eq!(reporter.version(), "1.0.0");
335    }
336
337    #[test]
338    fn notification_body_duration() {
339        let n = build_notification(&passing_result(), &NotifyConfig::default());
340        assert!(n.body.contains("Duration:"));
341    }
342
343    #[test]
344    fn notification_skipped_count() {
345        let result = TestRunResult {
346            suites: vec![TestSuite {
347                name: "t".into(),
348                tests: vec![
349                    make_test("t1", TestStatus::Passed, 1),
350                    make_test("t2", TestStatus::Skipped, 0),
351                ],
352            }],
353            duration: Duration::from_millis(10),
354            raw_exit_code: 0,
355        };
356        let n = build_notification(&result, &NotifyConfig::default());
357        assert!(n.body.contains("1 skipped"));
358    }
359}