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 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#[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('&', "&")
190 .replace('<', "<")
191 .replace('>', ">"),
192 notification
193 .body
194 .replace('&', "&")
195 .replace('<', "<")
196 .replace('>', ">"),
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}