Skip to main content

ferridriver_expect/
throw.rs

1//! Synchronous `toThrow` matcher (Jest's `expect(fn).toThrow(...)`).
2//!
3//! The QuickJS binding invokes the user function in try/catch and
4//! constructs the [`ThrownError`] (or `None`) before delegating here —
5//! this module does not own any JS invocation, only the matching logic.
6
7use std::panic::Location;
8
9use regex::Regex;
10use serde_json::Value;
11
12use crate::asymmetric::json_short;
13use crate::{AssertionFailure, CallerLocation};
14
15/// Captured outcome of invoking a function expected to throw.
16#[derive(Debug, Clone, Default)]
17pub struct ThrownError {
18  /// The error message (`error.message` on a JS `Error`, or the
19  /// stringification of a thrown primitive).
20  pub message: String,
21  /// `error.constructor.name` from the JS side.
22  pub class_name: Option<String>,
23}
24
25#[derive(Debug, Clone)]
26pub enum ThrowMatcher {
27  Any,
28  Substring(String),
29  Regex(Regex),
30  ClassName(String),
31  /// Match against `{ message?, name? }`.
32  Object(Value),
33}
34
35pub struct ExpectFn {
36  caught: Option<ThrownError>,
37  is_not: bool,
38  is_soft: bool,
39  message: Option<String>,
40}
41
42#[must_use]
43pub fn expect_fn(caught: Option<ThrownError>) -> ExpectFn {
44  ExpectFn {
45    caught,
46    is_not: false,
47    is_soft: false,
48    message: None,
49  }
50}
51
52impl ExpectFn {
53  #[must_use]
54  pub fn not(mut self) -> Self {
55    self.is_not = !self.is_not;
56    self
57  }
58
59  #[must_use]
60  pub fn soft(mut self) -> Self {
61    self.is_soft = true;
62    self
63  }
64
65  #[must_use]
66  pub fn with_message(mut self, message: impl Into<String>) -> Self {
67    self.message = Some(message.into());
68    self
69  }
70
71  pub fn is_soft(&self) -> bool {
72    self.is_soft
73  }
74
75  #[track_caller]
76  pub fn to_throw(&self, matcher: Option<&ThrowMatcher>) -> Result<(), AssertionFailure> {
77    let location = Location::caller();
78    let pass = match (&self.caught, matcher) {
79      (None, _) => false,
80      (Some(_), None) => true,
81      (Some(err), Some(ThrowMatcher::Any)) => !err.message.is_empty() || err.class_name.is_some(),
82      (Some(err), Some(ThrowMatcher::Substring(s))) => err.message.contains(s.as_str()),
83      (Some(err), Some(ThrowMatcher::Regex(re))) => re.is_match(&err.message),
84      (Some(err), Some(ThrowMatcher::ClassName(name))) => {
85        err.class_name.as_deref() == Some(name.as_str()) || err.message.contains(name.as_str())
86      },
87      (Some(err), Some(ThrowMatcher::Object(subset))) => {
88        if let Some(obj) = subset.as_object() {
89          let mut ok = true;
90          if let Some(Value::String(msg_expected)) = obj.get("message") {
91            ok &= err.message.contains(msg_expected.as_str());
92          }
93          if let Some(Value::String(name_expected)) = obj.get("name") {
94            ok &= err.class_name.as_deref() == Some(name_expected.as_str());
95          }
96          ok
97        } else {
98          false
99        }
100      },
101    };
102    let pass = if self.is_not { !pass } else { pass };
103    if pass {
104      return Ok(());
105    }
106    let expected_desc = match matcher {
107      None | Some(ThrowMatcher::Any) => "function to throw".to_string(),
108      Some(ThrowMatcher::Substring(s)) => format!("throw containing {s:?}"),
109      Some(ThrowMatcher::Regex(re)) => format!("throw matching /{}/", re.as_str()),
110      Some(ThrowMatcher::ClassName(n)) => format!("throw of {n}"),
111      Some(ThrowMatcher::Object(o)) => format!("throw matching {}", json_short(o)),
112    };
113    let received_desc = match &self.caught {
114      None => "function returned without throwing".to_string(),
115      Some(err) => match &err.class_name {
116        Some(n) => format!("{n}: {}", err.message),
117        None => err.message.clone(),
118      },
119    };
120    let not = if self.is_not { ".not" } else { "" };
121    let prefix = self.message.as_ref().map(|m| format!("{m}: ")).unwrap_or_default();
122    let title = format!("{prefix}expect(fn){not}.toThrow() failed");
123    let body = format!("Expected: {expected_desc}\nReceived: {received_desc}");
124    Err(AssertionFailure::new(title, Some(body)).with_location(CallerLocation::from_std(location)))
125  }
126}
127
128#[cfg(test)]
129mod tests {
130  use super::*;
131
132  fn err(r: Result<(), AssertionFailure>) {
133    assert!(r.is_err(), "expected err");
134  }
135
136  #[test]
137  fn to_throw_substring_and_classname() {
138    let caught = Some(ThrownError {
139      message: "boom: out of range".into(),
140      class_name: Some("RangeError".into()),
141    });
142    expect_fn(caught.clone())
143      .to_throw(Some(&ThrowMatcher::Substring("out of range".into())))
144      .unwrap();
145    err(expect_fn(caught.clone()).to_throw(Some(&ThrowMatcher::Substring("nope".into()))));
146    expect_fn(caught.clone())
147      .to_throw(Some(&ThrowMatcher::ClassName("RangeError".into())))
148      .unwrap();
149    expect_fn(caught).to_throw(None).unwrap();
150    err(expect_fn(None).to_throw(None));
151  }
152
153  #[test]
154  fn not_inverts_throw() {
155    err(
156      expect_fn(Some(ThrownError {
157        message: "boom".into(),
158        class_name: None,
159      }))
160      .not()
161      .to_throw(None),
162    );
163    expect_fn(None).not().to_throw(None).unwrap();
164  }
165}