Skip to main content

rpytest_core/protocol/
events.rs

1//! Streaming event types for test execution.
2
3use serde::{Deserialize, Serialize};
4
5/// Outcome of a single test execution.
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7#[serde(tag = "status", rename_all = "snake_case")]
8pub enum Outcome {
9    /// Test passed.
10    Passed,
11    /// Test failed with assertion or other failure.
12    Failed {
13        /// Failure message or traceback.
14        message: String,
15    },
16    /// Test was skipped.
17    Skipped {
18        /// Reason for skipping, if provided.
19        reason: Option<String>,
20    },
21    /// Test encountered an error (not a test failure).
22    Error {
23        /// Error message or traceback.
24        message: String,
25    },
26    /// Test was expected to fail and did fail.
27    XFail {
28        /// Reason for expected failure.
29        reason: Option<String>,
30    },
31    /// Test was expected to fail but passed.
32    XPass,
33}
34
35impl Outcome {
36    /// Returns true if this outcome represents a successful test.
37    pub fn is_success(&self) -> bool {
38        matches!(
39            self,
40            Outcome::Passed | Outcome::Skipped { .. } | Outcome::XFail { .. }
41        )
42    }
43
44    /// Returns true if this outcome represents a failure that should be counted.
45    pub fn is_failure(&self) -> bool {
46        matches!(self, Outcome::Failed { .. } | Outcome::XPass)
47    }
48
49    /// Returns true if this outcome represents an error.
50    pub fn is_error(&self) -> bool {
51        matches!(self, Outcome::Error { .. })
52    }
53}
54
55/// Event emitted during test execution.
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57#[serde(tag = "event", rename_all = "snake_case")]
58pub enum TestEvent {
59    /// A test is about to start.
60    TestStart {
61        /// Test node ID.
62        node_id: String,
63    },
64
65    /// A test has completed.
66    TestFinish {
67        /// Test node ID.
68        node_id: String,
69        /// Test outcome.
70        outcome: Outcome,
71        /// Duration in milliseconds.
72        duration_ms: u64,
73        /// Captured stdout, if any.
74        stdout: Option<String>,
75        /// Captured stderr, if any.
76        stderr: Option<String>,
77    },
78
79    /// Collection is starting.
80    CollectionStart,
81
82    /// A test file was collected.
83    ItemCollected {
84        /// Test node ID.
85        node_id: String,
86        /// File path.
87        file_path: String,
88        /// Line number.
89        line_number: Option<u32>,
90        /// Markers applied to this test.
91        markers: Vec<String>,
92    },
93
94    /// Collection has finished.
95    CollectionFinish {
96        /// Total items collected.
97        count: usize,
98    },
99
100    /// Session is starting.
101    SessionStart {
102        /// Total tests to run.
103        test_count: usize,
104    },
105
106    /// Session has finished.
107    SessionFinish {
108        /// Exit code (0 = all passed).
109        exit_code: i32,
110    },
111
112    /// A warning was emitted.
113    Warning {
114        /// Warning message.
115        message: String,
116        /// Source location, if known.
117        location: Option<String>,
118    },
119}
120
121/// Log event for daemon logging.
122#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
123pub struct LogEvent {
124    /// Log level.
125    pub level: LogLevel,
126    /// Log message.
127    pub message: String,
128    /// Optional source module.
129    pub module: Option<String>,
130    /// Timestamp in milliseconds since epoch.
131    pub timestamp_ms: u64,
132}
133
134/// Log levels matching Python's logging module.
135#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
136#[serde(rename_all = "lowercase")]
137pub enum LogLevel {
138    Debug,
139    Info,
140    Warning,
141    Error,
142    Critical,
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn outcome_classification() {
151        assert!(Outcome::Passed.is_success());
152        assert!(Outcome::Skipped { reason: None }.is_success());
153        assert!(Outcome::XFail { reason: None }.is_success());
154
155        assert!(Outcome::Failed {
156            message: "".to_string()
157        }
158        .is_failure());
159        assert!(Outcome::XPass.is_failure());
160
161        assert!(Outcome::Error {
162            message: "".to_string()
163        }
164        .is_error());
165    }
166
167    #[test]
168    fn test_event_roundtrip() {
169        let events = vec![
170            TestEvent::TestStart {
171                node_id: "test_foo.py::test_bar".to_string(),
172            },
173            TestEvent::TestFinish {
174                node_id: "test_foo.py::test_bar".to_string(),
175                outcome: Outcome::Passed,
176                duration_ms: 42,
177                stdout: Some("output".to_string()),
178                stderr: None,
179            },
180            TestEvent::CollectionStart,
181            TestEvent::ItemCollected {
182                node_id: "test_foo.py::test_bar".to_string(),
183                file_path: "test_foo.py".to_string(),
184                line_number: Some(10),
185                markers: vec!["slow".to_string()],
186            },
187            TestEvent::CollectionFinish { count: 100 },
188            TestEvent::SessionStart { test_count: 50 },
189            TestEvent::SessionFinish { exit_code: 0 },
190            TestEvent::Warning {
191                message: "Deprecated API".to_string(),
192                location: Some("test_foo.py:20".to_string()),
193            },
194        ];
195
196        for event in events {
197            let encoded = rmp_serde::to_vec(&event).unwrap();
198            let decoded: TestEvent = rmp_serde::from_slice(&encoded).unwrap();
199            assert_eq!(event, decoded);
200        }
201    }
202}