Skip to main content

testx/plugin/
mod.rs

1//! Plugin system for testx.
2//!
3//! Plugins receive events during test execution and can produce custom output,
4//! send notifications, or perform post-processing on test results.
5
6pub mod reporters;
7pub mod script_adapter;
8
9use crate::adapters::TestRunResult;
10use crate::error;
11use crate::events::TestEvent;
12
13/// A plugin that hooks into the test execution lifecycle.
14///
15/// Plugins receive events as they occur and can act on the final result.
16/// They are registered with a PluginManager before the test run starts.
17pub trait Plugin: Send {
18    /// Unique name identifying this plugin.
19    fn name(&self) -> &str;
20
21    /// Plugin version string.
22    fn version(&self) -> &str;
23
24    /// Called for each event during test execution.
25    fn on_event(&mut self, event: &TestEvent) -> error::Result<()>;
26
27    /// Called once the test run is complete with the final result.
28    fn on_result(&mut self, result: &TestRunResult) -> error::Result<()>;
29
30    /// Called when the plugin is being shut down (cleanup).
31    fn shutdown(&mut self) -> error::Result<()> {
32        Ok(())
33    }
34}
35
36/// Manages a collection of plugins and dispatches events to them.
37pub struct PluginManager {
38    plugins: Vec<Box<dyn Plugin>>,
39    errors: Vec<PluginError>,
40}
41
42/// An error that occurred in a specific plugin.
43#[derive(Debug, Clone)]
44pub struct PluginError {
45    /// Name of the plugin that errored.
46    pub plugin_name: String,
47    /// Error message.
48    pub message: String,
49    /// Whether the error is fatal (should abort the run).
50    pub fatal: bool,
51}
52
53impl PluginError {
54    fn new(plugin_name: &str, message: String, fatal: bool) -> Self {
55        Self {
56            plugin_name: plugin_name.to_string(),
57            message,
58            fatal,
59        }
60    }
61}
62
63impl std::fmt::Display for PluginError {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        write!(
66            f,
67            "[plugin:{}] {}{}",
68            self.plugin_name,
69            self.message,
70            if self.fatal { " (fatal)" } else { "" }
71        )
72    }
73}
74
75impl PluginManager {
76    /// Create a new empty plugin manager.
77    pub fn new() -> Self {
78        Self {
79            plugins: Vec::new(),
80            errors: Vec::new(),
81        }
82    }
83
84    /// Register a plugin.
85    pub fn register(&mut self, plugin: Box<dyn Plugin>) {
86        self.plugins.push(plugin);
87    }
88
89    /// Number of registered plugins.
90    pub fn plugin_count(&self) -> usize {
91        self.plugins.len()
92    }
93
94    /// Get names of all registered plugins.
95    pub fn plugin_names(&self) -> Vec<&str> {
96        self.plugins.iter().map(|p| p.name()).collect()
97    }
98
99    /// Whether any plugin has reported a fatal error.
100    pub fn has_fatal_error(&self) -> bool {
101        self.errors.iter().any(|e| e.fatal)
102    }
103
104    /// Get all plugin errors that occurred.
105    pub fn errors(&self) -> &[PluginError] {
106        &self.errors
107    }
108
109    /// Clear collected errors.
110    pub fn clear_errors(&mut self) {
111        self.errors.clear();
112    }
113
114    /// Dispatch an event to all registered plugins.
115    ///
116    /// Errors are collected but do not stop dispatch to other plugins.
117    pub fn dispatch_event(&mut self, event: &TestEvent) {
118        for plugin in &mut self.plugins {
119            if let Err(e) = plugin.on_event(event) {
120                self.errors.push(PluginError::new(
121                    plugin.name(),
122                    format!("on_event error: {e}"),
123                    false,
124                ));
125            }
126        }
127    }
128
129    /// Dispatch the final result to all registered plugins.
130    pub fn dispatch_result(&mut self, result: &TestRunResult) {
131        for plugin in &mut self.plugins {
132            if let Err(e) = plugin.on_result(result) {
133                self.errors.push(PluginError::new(
134                    plugin.name(),
135                    format!("on_result error: {e}"),
136                    false,
137                ));
138            }
139        }
140    }
141
142    /// Shut down all plugins, collecting any errors.
143    pub fn shutdown_all(&mut self) {
144        for plugin in &mut self.plugins {
145            if let Err(e) = plugin.shutdown() {
146                self.errors.push(PluginError::new(
147                    plugin.name(),
148                    format!("shutdown error: {e}"),
149                    false,
150                ));
151            }
152        }
153    }
154
155    /// Remove a plugin by name. Returns true if found and removed.
156    pub fn remove(&mut self, name: &str) -> bool {
157        let len_before = self.plugins.len();
158        self.plugins.retain(|p| p.name() != name);
159        self.plugins.len() < len_before
160    }
161}
162
163impl Default for PluginManager {
164    fn default() -> Self {
165        Self::new()
166    }
167}
168
169/// Plugin metadata exposed for listing/discovery.
170#[derive(Debug, Clone)]
171pub struct PluginInfo {
172    pub name: String,
173    pub version: String,
174    pub description: String,
175}
176
177impl PluginInfo {
178    pub fn new(name: &str, version: &str, description: &str) -> Self {
179        Self {
180            name: name.to_string(),
181            version: version.to_string(),
182            description: description.to_string(),
183        }
184    }
185}
186
187/// Registry of available plugin types for discovery.
188pub struct PluginRegistry {
189    available: Vec<PluginInfo>,
190}
191
192impl PluginRegistry {
193    pub fn new() -> Self {
194        Self {
195            available: Vec::new(),
196        }
197    }
198
199    /// Register an available plugin type.
200    pub fn register_available(&mut self, info: PluginInfo) {
201        self.available.push(info);
202    }
203
204    /// Get all available plugin types.
205    pub fn list_available(&self) -> &[PluginInfo] {
206        &self.available
207    }
208
209    /// Build the default registry with all built-in plugins.
210    pub fn builtin() -> Self {
211        let mut registry = Self::new();
212        registry.register_available(PluginInfo::new(
213            "markdown",
214            "1.0.0",
215            "Generates a Markdown test report",
216        ));
217        registry.register_available(PluginInfo::new(
218            "github",
219            "1.0.0",
220            "Emits GitHub Actions annotations",
221        ));
222        registry.register_available(PluginInfo::new(
223            "html",
224            "1.0.0",
225            "Generates a self-contained HTML test report",
226        ));
227        registry.register_available(PluginInfo::new(
228            "notify",
229            "1.0.0",
230            "Sends desktop notifications on test completion",
231        ));
232        registry
233    }
234
235    /// Find a plugin by name.
236    pub fn find(&self, name: &str) -> Option<&PluginInfo> {
237        self.available.iter().find(|p| p.name == name)
238    }
239}
240
241impl Default for PluginRegistry {
242    fn default() -> Self {
243        Self::new()
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use crate::adapters::{TestCase, TestRunResult, TestStatus, TestSuite};
251    use std::time::Duration;
252
253    /// A test plugin for unit testing.
254    struct MockPlugin {
255        name: String,
256        events_received: Vec<String>,
257        result_received: bool,
258        shutdown_called: bool,
259        should_error: bool,
260    }
261
262    impl MockPlugin {
263        fn new(name: &str) -> Self {
264            Self {
265                name: name.to_string(),
266                events_received: Vec::new(),
267                result_received: false,
268                shutdown_called: false,
269                should_error: false,
270            }
271        }
272
273        fn failing(name: &str) -> Self {
274            Self {
275                name: name.to_string(),
276                events_received: Vec::new(),
277                result_received: false,
278                shutdown_called: false,
279                should_error: true,
280            }
281        }
282    }
283
284    impl Plugin for MockPlugin {
285        fn name(&self) -> &str {
286            &self.name
287        }
288
289        fn version(&self) -> &str {
290            "0.1.0"
291        }
292
293        fn on_event(&mut self, event: &TestEvent) -> error::Result<()> {
294            if self.should_error {
295                return Err(error::TestxError::PluginError {
296                    message: "mock error".into(),
297                });
298            }
299            self.events_received.push(format!("{event:?}"));
300            Ok(())
301        }
302
303        fn on_result(&mut self, _result: &TestRunResult) -> error::Result<()> {
304            if self.should_error {
305                return Err(error::TestxError::PluginError {
306                    message: "mock result error".into(),
307                });
308            }
309            self.result_received = true;
310            Ok(())
311        }
312
313        fn shutdown(&mut self) -> error::Result<()> {
314            self.shutdown_called = true;
315            Ok(())
316        }
317    }
318
319    fn make_result() -> TestRunResult {
320        TestRunResult {
321            suites: vec![TestSuite {
322                name: "test".into(),
323                tests: vec![TestCase {
324                    name: "test_a".into(),
325                    status: TestStatus::Passed,
326                    duration: Duration::from_millis(10),
327                    error: None,
328                }],
329            }],
330            duration: Duration::from_millis(100),
331            raw_exit_code: 0,
332        }
333    }
334
335    #[test]
336    fn manager_new_is_empty() {
337        let mgr = PluginManager::new();
338        assert_eq!(mgr.plugin_count(), 0);
339        assert!(mgr.plugin_names().is_empty());
340    }
341
342    #[test]
343    fn manager_register() {
344        let mut mgr = PluginManager::new();
345        mgr.register(Box::new(MockPlugin::new("test-plugin")));
346        assert_eq!(mgr.plugin_count(), 1);
347        assert_eq!(mgr.plugin_names(), vec!["test-plugin"]);
348    }
349
350    #[test]
351    fn manager_dispatch_event() {
352        let mut mgr = PluginManager::new();
353        mgr.register(Box::new(MockPlugin::new("p1")));
354        mgr.register(Box::new(MockPlugin::new("p2")));
355
356        mgr.dispatch_event(&TestEvent::Warning {
357            message: "test warning".into(),
358        });
359
360        assert!(mgr.errors().is_empty());
361    }
362
363    #[test]
364    fn manager_dispatch_result() {
365        let mut mgr = PluginManager::new();
366        mgr.register(Box::new(MockPlugin::new("p1")));
367
368        mgr.dispatch_result(&make_result());
369        assert!(mgr.errors().is_empty());
370    }
371
372    #[test]
373    fn manager_collects_errors() {
374        let mut mgr = PluginManager::new();
375        mgr.register(Box::new(MockPlugin::failing("bad-plugin")));
376        mgr.register(Box::new(MockPlugin::new("good-plugin")));
377
378        mgr.dispatch_event(&TestEvent::Warning {
379            message: "test".into(),
380        });
381
382        assert_eq!(mgr.errors().len(), 1);
383        assert_eq!(mgr.errors()[0].plugin_name, "bad-plugin");
384    }
385
386    #[test]
387    fn manager_shutdown() {
388        let mut mgr = PluginManager::new();
389        mgr.register(Box::new(MockPlugin::new("p1")));
390        mgr.shutdown_all();
391        // No errors from shutdown
392        assert!(mgr.errors().is_empty());
393    }
394
395    #[test]
396    fn manager_remove() {
397        let mut mgr = PluginManager::new();
398        mgr.register(Box::new(MockPlugin::new("p1")));
399        mgr.register(Box::new(MockPlugin::new("p2")));
400
401        assert!(mgr.remove("p1"));
402        assert_eq!(mgr.plugin_count(), 1);
403        assert_eq!(mgr.plugin_names(), vec!["p2"]);
404    }
405
406    #[test]
407    fn manager_remove_nonexistent() {
408        let mut mgr = PluginManager::new();
409        assert!(!mgr.remove("nope"));
410    }
411
412    #[test]
413    fn manager_clear_errors() {
414        let mut mgr = PluginManager::new();
415        mgr.register(Box::new(MockPlugin::failing("bad")));
416        mgr.dispatch_event(&TestEvent::Warning {
417            message: "x".into(),
418        });
419        assert_eq!(mgr.errors().len(), 1);
420        mgr.clear_errors();
421        assert!(mgr.errors().is_empty());
422    }
423
424    #[test]
425    fn manager_has_fatal_error() {
426        let mgr = PluginManager::new();
427        assert!(!mgr.has_fatal_error());
428    }
429
430    #[test]
431    fn plugin_error_display() {
432        let err = PluginError::new("test", "something broke".into(), false);
433        assert_eq!(format!("{err}"), "[plugin:test] something broke");
434
435        let fatal = PluginError::new("test", "critical".into(), true);
436        assert!(format!("{fatal}").contains("(fatal)"));
437    }
438
439    #[test]
440    fn registry_builtin() {
441        let registry = PluginRegistry::builtin();
442        assert_eq!(registry.list_available().len(), 4);
443        assert!(registry.find("markdown").is_some());
444        assert!(registry.find("github").is_some());
445        assert!(registry.find("html").is_some());
446        assert!(registry.find("notify").is_some());
447    }
448
449    #[test]
450    fn registry_find_missing() {
451        let registry = PluginRegistry::builtin();
452        assert!(registry.find("nonexistent").is_none());
453    }
454
455    #[test]
456    fn registry_custom() {
457        let mut registry = PluginRegistry::new();
458        registry.register_available(PluginInfo::new("custom", "0.1.0", "A custom plugin"));
459        assert_eq!(registry.list_available().len(), 1);
460        assert_eq!(registry.find("custom").unwrap().version, "0.1.0");
461    }
462
463    #[test]
464    fn plugin_info_new() {
465        let info = PluginInfo::new("test", "1.0.0", "Test plugin");
466        assert_eq!(info.name, "test");
467        assert_eq!(info.version, "1.0.0");
468        assert_eq!(info.description, "Test plugin");
469    }
470
471    #[test]
472    fn manager_multiple_events() {
473        let mut mgr = PluginManager::new();
474        mgr.register(Box::new(MockPlugin::new("p1")));
475
476        for i in 0..10 {
477            mgr.dispatch_event(&TestEvent::Progress {
478                message: format!("step {i}"),
479                current: i,
480                total: 10,
481            });
482        }
483
484        assert!(mgr.errors().is_empty());
485    }
486
487    #[test]
488    fn manager_error_on_result() {
489        let mut mgr = PluginManager::new();
490        mgr.register(Box::new(MockPlugin::failing("bad")));
491
492        mgr.dispatch_result(&make_result());
493        assert_eq!(mgr.errors().len(), 1);
494        assert!(mgr.errors()[0].message.contains("on_result"));
495    }
496}