Skip to main content

eoka/
error.rs

1//! Error types for eoka
2
3use thiserror::Error;
4
5/// Result type for eoka operations
6pub type Result<T> = std::result::Result<T, Error>;
7
8/// Error type for eoka.
9///
10/// `#[non_exhaustive]`: match with a `_ =>` arm so new variants are additive.
11#[derive(Debug, Error)]
12#[non_exhaustive]
13pub enum Error {
14    /// Failed to launch Chrome
15    #[error("Failed to launch Chrome: {0}")]
16    Launch(String),
17
18    /// Transport error
19    #[error(fmt = transport_fmt)]
20    Transport {
21        /// Description of what failed.
22        context: String,
23        /// Underlying IO error, if any.
24        #[source]
25        source: Option<std::io::Error>,
26    },
27
28    /// CDP protocol error. `method`/`code` carry command context when the error
29    /// came from a command response, and are `None` for context-free failures.
30    #[error(fmt = cdp_fmt)]
31    Cdp {
32        /// CDP command method that failed, if known.
33        method: Option<String>,
34        /// CDP error code, if known.
35        code: Option<i64>,
36        /// Error message.
37        message: String,
38    },
39
40    /// Navigation error
41    #[error("Navigation error: {0}")]
42    Navigation(String),
43
44    /// Element not found in DOM
45    #[error("Element not found: {0}")]
46    ElementNotFound(String),
47
48    /// Element exists in DOM but is not visible/rendered
49    #[error("Element not visible: '{selector}' exists in DOM but is not rendered (hidden, display:none, or off-screen)")]
50    ElementNotVisible {
51        /// Selector that matched the hidden element.
52        selector: String,
53    },
54
55    /// Timeout
56    #[error("Timeout: {0}")]
57    Timeout(String),
58
59    /// Input action could not be completed because its held-input state was invalid.
60    #[error("Input state error: {0}")]
61    InputState(String),
62
63    /// A form control did not contain the requested value after filling.
64    #[error("Value mismatch for '{selector}': expected '{expected}', got '{actual}'")]
65    ValueMismatch {
66        /// Selector supplied to the fill operation.
67        selector: String,
68        /// Value requested by the caller.
69        expected: String,
70        /// Value read back from the DOM.
71        actual: String,
72    },
73
74    /// Serialization error
75    #[error("Serialization error: {0}")]
76    Serialization(#[from] serde_json::Error),
77
78    /// Decode error (e.g., base64)
79    #[error("Decode error: {0}")]
80    Decode(String),
81
82    /// IO error
83    #[error("IO error: {0}")]
84    Io(#[from] std::io::Error),
85
86    /// Chrome not found
87    #[error("Chrome not found")]
88    ChromeNotFound,
89
90    /// Binary patching error
91    #[error("Patching error in {operation}: {message}")]
92    Patching {
93        /// Patching operation that failed.
94        operation: String,
95        /// Error message.
96        message: String,
97    },
98
99    /// Retry exhausted
100    #[error("Retry exhausted after {attempts} attempts: {last_error}")]
101    RetryExhausted {
102        /// Number of attempts made before giving up.
103        attempts: u32,
104        /// The last error encountered.
105        last_error: String,
106    },
107}
108
109// `std::io::Error` and `serde_json::Error` aren't `Clone`, so we can't derive it.
110// Reconstruct both best-effort (kind + message) so `Error` — and thus the
111// broadcast `CdpMessage` — is cloneable.
112impl Clone for Error {
113    fn clone(&self) -> Self {
114        use serde::de::Error as _;
115        match self {
116            Self::Launch(s) => Self::Launch(s.clone()),
117            Self::Transport { context, source } => Self::Transport {
118                context: context.clone(),
119                source: source
120                    .as_ref()
121                    .map(|e| std::io::Error::new(e.kind(), e.to_string())),
122            },
123            Self::Cdp {
124                method,
125                code,
126                message,
127            } => Self::Cdp {
128                method: method.clone(),
129                code: *code,
130                message: message.clone(),
131            },
132            Self::Navigation(s) => Self::Navigation(s.clone()),
133            Self::ElementNotFound(s) => Self::ElementNotFound(s.clone()),
134            Self::ElementNotVisible { selector } => Self::ElementNotVisible {
135                selector: selector.clone(),
136            },
137            Self::Timeout(s) => Self::Timeout(s.clone()),
138            Self::InputState(s) => Self::InputState(s.clone()),
139            Self::ValueMismatch {
140                selector,
141                expected,
142                actual,
143            } => Self::ValueMismatch {
144                selector: selector.clone(),
145                expected: expected.clone(),
146                actual: actual.clone(),
147            },
148            Self::Serialization(e) => Self::Serialization(serde_json::Error::custom(e.to_string())),
149            Self::Decode(s) => Self::Decode(s.clone()),
150            Self::Io(e) => Self::Io(std::io::Error::new(e.kind(), e.to_string())),
151            Self::ChromeNotFound => Self::ChromeNotFound,
152            Self::Patching { operation, message } => Self::Patching {
153                operation: operation.clone(),
154                message: message.clone(),
155            },
156            Self::RetryExhausted {
157                attempts,
158                last_error,
159            } => Self::RetryExhausted {
160                attempts: *attempts,
161                last_error: last_error.clone(),
162            },
163        }
164    }
165}
166
167/// Display for the `Cdp` variant, including method/code context when present.
168fn cdp_fmt(
169    method: &Option<String>,
170    code: &Option<i64>,
171    message: &str,
172    f: &mut std::fmt::Formatter<'_>,
173) -> std::fmt::Result {
174    match (method, code) {
175        (Some(m), Some(c)) => write!(f, "CDP error in {}: {} (code {})", m, message, c),
176        (Some(m), None) => write!(f, "CDP error in {}: {}", m, message),
177        _ => write!(f, "CDP error: {}", message),
178    }
179}
180
181/// Display for the `Transport` variant, including the io error cause when present.
182fn transport_fmt(
183    context: &str,
184    source: &Option<std::io::Error>,
185    f: &mut std::fmt::Formatter<'_>,
186) -> std::fmt::Result {
187    match source {
188        Some(err) => write!(f, "Transport error: {}: {}", context, err),
189        None => write!(f, "Transport error: {}", context),
190    }
191}
192
193impl Error {
194    /// Create a transport error with context
195    pub fn transport(context: impl Into<String>) -> Self {
196        Self::Transport {
197            context: context.into(),
198            source: None,
199        }
200    }
201
202    /// Create a transport error with IO source
203    pub fn transport_io(context: impl Into<String>, source: std::io::Error) -> Self {
204        Self::Transport {
205            context: context.into(),
206            source: Some(source),
207        }
208    }
209
210    /// Create a CDP error with full command context.
211    pub fn cdp(method: impl Into<String>, code: i64, message: impl Into<String>) -> Self {
212        Self::Cdp {
213            method: Some(method.into()),
214            code: Some(code),
215            message: message.into(),
216        }
217    }
218
219    /// Create a CDP error without command context.
220    pub fn cdp_msg(message: impl Into<String>) -> Self {
221        Self::Cdp {
222            method: None,
223            code: None,
224            message: message.into(),
225        }
226    }
227
228    /// Create a patching error
229    pub fn patching(operation: impl Into<String>, message: impl Into<String>) -> Self {
230        Self::Patching {
231            operation: operation.into(),
232            message: message.into(),
233        }
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use std::error::Error as StdError;
241
242    #[test]
243    fn test_transport_io_display_includes_context_and_cause() {
244        let err = Error::transport_io(
245            "Failed to connect",
246            std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "boom"),
247        );
248        let msg = format!("{}", err);
249        // Both the context and the underlying io cause must appear.
250        assert!(
251            msg.contains("Failed to connect"),
252            "missing context in: {msg}"
253        );
254        assert!(msg.contains("boom"), "missing io cause in: {msg}");
255    }
256
257    #[test]
258    fn test_transport_io_exposes_source() {
259        let err = Error::transport_io(
260            "Failed to connect",
261            std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "boom"),
262        );
263        assert!(
264            err.source().is_some(),
265            "transport_io error should expose its io source"
266        );
267    }
268
269    #[test]
270    fn test_cdp_display_with_and_without_context() {
271        let full = Error::cdp("DOM.resolveNode", -1, "boom");
272        assert_eq!(
273            format!("{full}"),
274            "CDP error in DOM.resolveNode: boom (code -1)"
275        );
276        let simple = Error::cdp_msg("no value");
277        assert_eq!(format!("{simple}"), "CDP error: no value");
278    }
279
280    #[test]
281    fn test_transport_display_plain_message() {
282        let err = Error::transport("plain msg");
283        let msg = format!("{}", err);
284        assert!(msg.contains("plain msg"), "missing message in: {msg}");
285        // No source for the context-only constructor.
286        assert!(err.source().is_none());
287    }
288}