use thiserror::Error;
#[derive(Debug, Error, Clone)]
pub enum FerriError {
#[error("Timeout {timeout_ms}ms exceeded{}", .operation.as_ref().map(|op| format!(" while {op}")).unwrap_or_default())]
Timeout {
operation: Option<String>,
timeout_ms: u64,
},
#[error("Target page, context or browser has been closed{}", .reason.as_ref().map(|r| format!(": {r}")).unwrap_or_default())]
TargetClosed { reason: Option<String> },
#[error("strict mode violation: selector {selector:?} resolved to {count} elements")]
StrictModeViolation { selector: String, count: usize },
#[error("navigation to {url} failed: {message}")]
Navigation { url: String, message: String },
#[error("protocol error ({method}): {message}")]
Protocol { method: String, message: String },
#[error("backend error: {0}")]
Backend(String),
#[error("invalid selector {selector:?}: {reason}")]
InvalidSelector { selector: String, reason: String },
#[error("not connected")]
NotConnected,
#[error("interrupted: {0}")]
Interrupted(String),
#[error("invalid argument {name:?}: {reason}")]
InvalidArgument { name: String, reason: String },
#[error("unsupported operation: {0}")]
Unsupported(String),
#[error("evaluation error: {0}")]
Evaluation(String),
#[error("snapshot error: {0}")]
Snapshot(String),
#[error("io error: {0}")]
Io(String),
#[error("json error: {0}")]
Json(String),
}
impl FerriError {
#[must_use]
pub fn is_timeout_error(&self) -> bool {
matches!(self, Self::Timeout { .. })
}
#[must_use]
pub fn is_target_closed_error(&self) -> bool {
matches!(self, Self::TargetClosed { .. })
}
#[must_use]
pub fn is_strict_mode_violation(&self) -> bool {
matches!(self, Self::StrictModeViolation { .. })
}
#[must_use]
pub fn name(&self) -> &'static str {
match self {
Self::Timeout { .. } => "TimeoutError",
Self::TargetClosed { .. } => "TargetClosedError",
_ => "FerriError",
}
}
#[must_use]
pub fn has_named_prefix(&self) -> bool {
matches!(self, Self::Timeout { .. } | Self::TargetClosed { .. })
}
#[must_use]
pub fn display_named(&self) -> String {
if self.has_named_prefix() {
format!("{}: {self}", self.name())
} else {
self.to_string()
}
}
#[must_use]
pub fn timeout(operation: impl Into<String>, timeout_ms: u64) -> Self {
Self::Timeout {
operation: Some(operation.into()),
timeout_ms,
}
}
#[must_use]
pub fn timeout_plain(timeout_ms: u64) -> Self {
Self::Timeout {
operation: None,
timeout_ms,
}
}
#[must_use]
pub fn strict(selector: impl Into<String>, count: usize) -> Self {
Self::StrictModeViolation {
selector: selector.into(),
count,
}
}
#[must_use]
pub fn target_closed(reason: Option<String>) -> Self {
Self::TargetClosed { reason }
}
#[must_use]
pub fn protocol(method: impl Into<String>, message: impl Into<String>) -> Self {
Self::Protocol {
method: method.into(),
message: message.into(),
}
}
#[must_use]
pub fn invalid_argument(name: impl Into<String>, reason: impl Into<String>) -> Self {
Self::InvalidArgument {
name: name.into(),
reason: reason.into(),
}
}
#[must_use]
pub fn invalid_selector(selector: impl Into<String>, reason: impl Into<String>) -> Self {
Self::InvalidSelector {
selector: selector.into(),
reason: reason.into(),
}
}
#[must_use]
pub fn evaluation(message: impl Into<String>) -> Self {
Self::Evaluation(message.into())
}
#[must_use]
pub fn backend(message: impl Into<String>) -> Self {
Self::Backend(message.into())
}
#[must_use]
pub fn unsupported(reason: impl Into<String>) -> Self {
Self::Unsupported(reason.into())
}
#[must_use]
pub fn interrupted(reason: impl Into<String>) -> Self {
Self::Interrupted(reason.into())
}
#[must_use]
pub fn navigation(url: impl Into<String>, message: impl Into<String>) -> Self {
Self::Navigation {
url: url.into(),
message: message.into(),
}
}
#[must_use]
pub fn snapshot(message: impl Into<String>) -> Self {
Self::Snapshot(message.into())
}
#[must_use]
pub fn is_unsupported(&self) -> bool {
matches!(self, Self::Unsupported(_))
}
}
impl From<std::io::Error> for FerriError {
fn from(e: std::io::Error) -> Self {
Self::Io(e.to_string())
}
}
impl From<serde_json::Error> for FerriError {
fn from(e: serde_json::Error) -> Self {
Self::Json(e.to_string())
}
}
impl From<String> for FerriError {
fn from(s: String) -> Self {
if let Some(reason) = s.strip_prefix("unsupported:") {
return Self::Unsupported(reason.trim().to_string());
}
Self::Backend(s)
}
}
impl From<&str> for FerriError {
fn from(s: &str) -> Self {
if let Some(reason) = s.strip_prefix("unsupported:") {
return Self::Unsupported(reason.trim().to_string());
}
Self::Backend(s.to_string())
}
}
pub type Result<T> = std::result::Result<T, FerriError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timeout_message_matches_playwright_shape() {
let err = FerriError::timeout("navigating to https://example.com", 30_000);
assert_eq!(
err.to_string(),
"Timeout 30000ms exceeded while navigating to https://example.com"
);
assert_eq!(err.name(), "TimeoutError");
assert!(err.is_timeout_error());
assert!(!err.is_target_closed_error());
}
#[test]
fn timeout_without_operation_omits_while_clause() {
let err = FerriError::timeout_plain(5_000);
assert_eq!(err.to_string(), "Timeout 5000ms exceeded");
}
#[test]
fn target_closed_with_reason() {
let err = FerriError::target_closed(Some("browser crashed".into()));
assert_eq!(
err.to_string(),
"Target page, context or browser has been closed: browser crashed"
);
assert_eq!(err.name(), "TargetClosedError");
assert!(err.is_target_closed_error());
}
#[test]
fn target_closed_without_reason() {
let err = FerriError::target_closed(None);
assert_eq!(err.to_string(), "Target page, context or browser has been closed");
}
#[test]
fn strict_mode_violation_reports_selector_and_count() {
let err = FerriError::strict("button.primary", 3);
assert_eq!(
err.to_string(),
r#"strict mode violation: selector "button.primary" resolved to 3 elements"#
);
assert_eq!(err.name(), "FerriError");
assert!(err.is_strict_mode_violation());
}
#[test]
fn name_dispatch_covers_all_named_variants() {
assert_eq!(FerriError::timeout_plain(1).name(), "TimeoutError");
assert_eq!(FerriError::target_closed(None).name(), "TargetClosedError");
assert_eq!(FerriError::Backend("x".into()).name(), "FerriError");
assert_eq!(FerriError::NotConnected.name(), "FerriError");
}
#[test]
fn from_string_routes_to_backend_variant() {
let from_string: FerriError = String::from("legacy").into();
let from_str: FerriError = "legacy".into();
assert!(matches!(from_string, FerriError::Backend(ref s) if s == "legacy"));
assert!(matches!(from_str, FerriError::Backend(ref s) if s == "legacy"));
}
#[test]
fn from_string_unsupported_prefix_routes_to_unsupported_variant() {
let err: FerriError = String::from("unsupported: pdf on webkit").into();
assert!(matches!(err, FerriError::Unsupported(ref s) if s == "pdf on webkit"));
}
#[test]
fn ferri_error_is_clone() {
let err = FerriError::backend("oops");
let cloned = err.clone();
assert_eq!(err.to_string(), cloned.to_string());
}
#[test]
fn io_and_json_errors_convert_via_question_mark() {
fn io_fail() -> Result<()> {
let _: std::fs::File = std::fs::File::open("/definitely/does/not/exist/ferri-test")?;
Ok(())
}
fn json_fail() -> Result<()> {
let _: serde_json::Value = serde_json::from_str("{")?;
Ok(())
}
assert!(matches!(io_fail().unwrap_err(), FerriError::Io(_)));
assert!(matches!(json_fail().unwrap_err(), FerriError::Json(_)));
}
#[test]
fn navigation_error_formats_url_and_message() {
let err = FerriError::Navigation {
url: "https://example.com".into(),
message: "net::ERR_NAME_NOT_RESOLVED".into(),
};
assert_eq!(
err.to_string(),
"navigation to https://example.com failed: net::ERR_NAME_NOT_RESOLVED"
);
}
#[test]
fn protocol_error_formats_method() {
let err = FerriError::protocol("Page.navigate", "session detached");
assert_eq!(err.to_string(), "protocol error (Page.navigate): session detached");
}
#[test]
fn invalid_argument_quotes_name() {
let err = FerriError::invalid_argument("timeout", "must be non-negative");
assert_eq!(err.to_string(), r#"invalid argument "timeout": must be non-negative"#);
}
#[test]
fn invalid_selector_quotes_selector() {
let err = FerriError::invalid_selector("???", "unknown engine");
assert_eq!(err.to_string(), r#"invalid selector "???": unknown engine"#);
}
#[test]
fn display_named_prepends_class_for_distinguishable_variants() {
assert_eq!(
FerriError::timeout("navigating", 30_000).display_named(),
"TimeoutError: Timeout 30000ms exceeded while navigating"
);
assert_eq!(
FerriError::target_closed(Some("crashed".into())).display_named(),
"TargetClosedError: Target page, context or browser has been closed: crashed"
);
}
#[test]
fn display_named_passes_unnamed_through_verbatim() {
assert_eq!(
FerriError::backend("launch failed").display_named(),
"backend error: launch failed"
);
assert_eq!(
FerriError::strict("button", 3).display_named(),
r#"strict mode violation: selector "button" resolved to 3 elements"#
);
}
#[test]
fn has_named_prefix_matches_name() {
assert!(FerriError::timeout_plain(1).has_named_prefix());
assert!(FerriError::target_closed(None).has_named_prefix());
assert!(!FerriError::backend("x").has_named_prefix());
assert!(!FerriError::strict("s", 2).has_named_prefix());
}
}