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