Skip to main content

ferrijs_std/node/
assert.rs

1//! `node:assert`.
2//!
3//! Written here rather than vendored: upstream `llrt_assert` is a single
4//! `ok`. Structural comparisons run through
5//! [`deep_equal`](super::deep_equal), the same function `util.isDeepStrictEqual`
6//! uses, and failure messages render through the one
7//! [`Inspector`](super::inspect::Inspector).
8
9use rquickjs::function::{Async, Func, Opt, Rest};
10use rquickjs::{Ctx, Function, Object, Promise, Result, Value};
11
12use super::deep_equal::{Mode, deep_equal, loose_equal, strict_equal};
13use super::inspect::Inspector;
14
15fn render(value: &Value<'_>) -> String {
16  let mut out = String::new();
17  if Inspector::new(false).quoted().value(&mut out, value, 0).is_err() {
18    out.push_str("<unrenderable>");
19  }
20  out
21}
22
23/// Throw an `AssertionError` carrying Node's diagnostic fields.
24fn fail_with<'js>(
25  ctx: &Ctx<'js>,
26  message: Opt<Value<'js>>,
27  generated: String,
28  actual: Value<'js>,
29  expected: Value<'js>,
30  operator: &str,
31) -> rquickjs::Error {
32  // A message that is itself an Error is thrown as-is, as Node does.
33  if let Some(value) = message.0.clone() {
34    if value.is_error() {
35      return ctx.throw(value);
36    }
37  }
38  let text = match message.0.as_ref().and_then(rquickjs::Value::as_string) {
39    Some(s) => s.to_string().unwrap_or(generated),
40    None => generated,
41  };
42
43  let build = |ctx: &Ctx<'js>| -> Result<Value<'js>> {
44    let error_ctor: rquickjs::function::Constructor<'js> = ctx.globals().get("Error")?;
45    let error: Object<'js> = error_ctor.construct((text.clone(),))?;
46    error.set("name", "AssertionError")?;
47    error.set("code", "ERR_ASSERTION")?;
48    error.set("actual", actual.clone())?;
49    error.set("expected", expected.clone())?;
50    error.set("operator", operator)?;
51    error.set("generatedMessage", message.0.is_none())?;
52    Ok(error.into_value())
53  };
54  match build(ctx) {
55    Ok(error) => ctx.throw(error),
56    Err(e) => e,
57  }
58}
59
60fn truthy(value: &Value<'_>) -> bool {
61  match value.type_of() {
62    rquickjs::Type::Undefined | rquickjs::Type::Null | rquickjs::Type::Uninitialized => false,
63    rquickjs::Type::Bool => value.as_bool().unwrap_or(false),
64    rquickjs::Type::Int | rquickjs::Type::Float => value.as_number().is_some_and(|n| n != 0.0 && !n.is_nan()),
65    rquickjs::Type::String => value
66      .as_string()
67      .and_then(|s| s.to_string().ok())
68      .is_some_and(|s| !s.is_empty()),
69    _ => true,
70  }
71}
72
73fn ok<'js>(ctx: Ctx<'js>, value: Value<'js>, message: Opt<Value<'js>>) -> Result<()> {
74  if truthy(&value) {
75    return Ok(());
76  }
77  let rendered = render(&value);
78  Err(fail_with(
79    &ctx,
80    message,
81    format!("The expression evaluated to a falsy value: {rendered}"),
82    value,
83    Value::new_bool(ctx.clone(), true),
84    "==",
85  ))
86}
87
88/// One comparison entry point: `passed` decides, `operator` and the
89/// wording come from the caller.
90fn compare<'js>(
91  ctx: &Ctx<'js>,
92  actual: Value<'js>,
93  expected: Value<'js>,
94  message: Opt<Value<'js>>,
95  passed: bool,
96  operator: &str,
97  wording: &str,
98) -> Result<()> {
99  if passed {
100    return Ok(());
101  }
102  let generated = format!("{wording}\n\n{} {operator} {}\n", render(&actual), render(&expected));
103  Err(fail_with(ctx, message, generated, actual, expected, operator))
104}
105
106fn equal<'js>(ctx: Ctx<'js>, actual: Value<'js>, expected: Value<'js>, message: Opt<Value<'js>>) -> Result<()> {
107  let passed = loose_equal(&actual, &expected);
108  compare(&ctx, actual, expected, message, passed, "==", "Expected values to be loosely equal:")
109}
110
111fn not_equal<'js>(ctx: Ctx<'js>, actual: Value<'js>, expected: Value<'js>, message: Opt<Value<'js>>) -> Result<()> {
112  let passed = !loose_equal(&actual, &expected);
113  compare(
114    &ctx,
115    actual,
116    expected,
117    message,
118    passed,
119    "!=",
120    "Expected values not to be loosely equal:",
121  )
122}
123
124fn strict_eq<'js>(ctx: Ctx<'js>, actual: Value<'js>, expected: Value<'js>, message: Opt<Value<'js>>) -> Result<()> {
125  let passed = strict_equal(&actual, &expected);
126  compare(
127    &ctx,
128    actual,
129    expected,
130    message,
131    passed,
132    "strictEqual",
133    "Expected values to be strictly equal:",
134  )
135}
136
137fn not_strict_eq<'js>(ctx: Ctx<'js>, actual: Value<'js>, expected: Value<'js>, message: Opt<Value<'js>>) -> Result<()> {
138  let passed = !strict_equal(&actual, &expected);
139  compare(
140    &ctx,
141    actual,
142    expected,
143    message,
144    passed,
145    "notStrictEqual",
146    "Expected values not to be strictly equal:",
147  )
148}
149
150fn deep_eq<'js>(ctx: Ctx<'js>, actual: Value<'js>, expected: Value<'js>, message: Opt<Value<'js>>) -> Result<()> {
151  let passed = deep_equal(&actual, &expected, Mode::Loose)?;
152  compare(
153    &ctx,
154    actual,
155    expected,
156    message,
157    passed,
158    "deepEqual",
159    "Expected values to be loosely deep-equal:",
160  )
161}
162
163fn not_deep_eq<'js>(ctx: Ctx<'js>, actual: Value<'js>, expected: Value<'js>, message: Opt<Value<'js>>) -> Result<()> {
164  let passed = !deep_equal(&actual, &expected, Mode::Loose)?;
165  compare(
166    &ctx,
167    actual,
168    expected,
169    message,
170    passed,
171    "notDeepEqual",
172    "Expected values not to be loosely deep-equal:",
173  )
174}
175
176fn deep_strict_eq<'js>(ctx: Ctx<'js>, actual: Value<'js>, expected: Value<'js>, message: Opt<Value<'js>>) -> Result<()> {
177  let passed = deep_equal(&actual, &expected, Mode::Strict)?;
178  compare(
179    &ctx,
180    actual,
181    expected,
182    message,
183    passed,
184    "deepStrictEqual",
185    "Expected values to be strictly deep-equal:",
186  )
187}
188
189fn not_deep_strict_eq<'js>(
190  ctx: Ctx<'js>,
191  actual: Value<'js>,
192  expected: Value<'js>,
193  message: Opt<Value<'js>>,
194) -> Result<()> {
195  let passed = !deep_equal(&actual, &expected, Mode::Strict)?;
196  compare(
197    &ctx,
198    actual,
199    expected,
200    message,
201    passed,
202    "notDeepStrictEqual",
203    "Expected values not to be strictly deep-equal:",
204  )
205}
206
207fn regexp_test<'js>(regexp: &Object<'js>, subject: &Value<'js>) -> Result<bool> {
208  let test: Function<'js> = regexp.get("test")?;
209  test.call((rquickjs::function::This(regexp.clone()), subject.clone()))
210}
211
212fn matches<'js>(ctx: Ctx<'js>, subject: Value<'js>, regexp: Value<'js>, message: Opt<Value<'js>>) -> Result<()> {
213  let Some(re) = regexp.as_object() else {
214    return Err(rquickjs::Exception::throw_type(
215      &ctx,
216      "The \"regexp\" argument must be an instance of RegExp",
217    ));
218  };
219  let passed = regexp_test(re, &subject)?;
220  compare(
221    &ctx,
222    subject,
223    regexp.clone(),
224    message,
225    passed,
226    "match",
227    "The input did not match the regular expression:",
228  )
229}
230
231fn does_not_match<'js>(ctx: Ctx<'js>, subject: Value<'js>, regexp: Value<'js>, message: Opt<Value<'js>>) -> Result<()> {
232  let Some(re) = regexp.as_object() else {
233    return Err(rquickjs::Exception::throw_type(
234      &ctx,
235      "The \"regexp\" argument must be an instance of RegExp",
236    ));
237  };
238  let passed = !regexp_test(re, &subject)?;
239  compare(
240    &ctx,
241    subject,
242    regexp.clone(),
243    message,
244    passed,
245    "doesNotMatch",
246    "The input was expected to not match the regular expression:",
247  )
248}
249
250/// Does a thrown value satisfy the expectation Node accepts: a RegExp
251/// against its message, a constructor via `instanceof`, or an object whose
252/// listed properties must deep-strict-match.
253fn thrown_matches<'js>(ctx: &Ctx<'js>, error: &Value<'js>, expected: &Value<'js>) -> Result<bool> {
254  let Some(expected_obj) = expected.as_object() else {
255    return Ok(true);
256  };
257
258  if expected.is_function() {
259    let instance_of: Function<'js> = ctx.eval("(e, C) => e instanceof C")?;
260    return instance_of.call((error.clone(), expected.clone()));
261  }
262
263  if regexp_source_present(expected_obj)? {
264    let message: Value<'js> = error
265      .as_object()
266      .map_or_else(|| Ok(error.clone()), |o| o.get::<_, Value<'js>>("message"))?;
267    return regexp_test(expected_obj, &message);
268  }
269
270  // A plain object: every listed property must deep-strict-match.
271  let Some(error_obj) = error.as_object() else {
272    return Ok(false);
273  };
274  for key in expected_obj.keys::<String>() {
275    let key = key?;
276    let want: Value<'js> = expected_obj.get(key.as_str())?;
277    let got: Value<'js> = error_obj.get(key.as_str())?;
278    if !deep_equal(&got, &want, Mode::Strict)? {
279      return Ok(false);
280    }
281  }
282  Ok(true)
283}
284
285fn regexp_source_present(object: &Object<'_>) -> Result<bool> {
286  Ok(object.get::<_, Value<'_>>("source").is_ok_and(|v| v.is_string()) && object.get::<_, Value<'_>>("test").is_ok())
287}
288
289fn throws<'js>(ctx: Ctx<'js>, body: Function<'js>, rest: Rest<Value<'js>>) -> Result<()> {
290  let (expected, message) = split_expectation(&rest.0);
291  match body.call::<_, Value<'js>>(()) {
292    Err(_) => {
293      let caught = ctx.catch();
294      if let Some(expected) = expected {
295        if !thrown_matches(&ctx, &caught, &expected)? {
296          return Err(fail_with(
297            &ctx,
298            message,
299            format!("The error did not match the expectation: {}", render(&caught)),
300            caught,
301            expected,
302            "throws",
303          ));
304        }
305      }
306      Ok(())
307    },
308    Ok(_) => Err(fail_with(
309      &ctx,
310      message,
311      "Missing expected exception.".to_string(),
312      Value::new_undefined(ctx.clone()),
313      expected.unwrap_or_else(|| Value::new_undefined(ctx.clone())),
314      "throws",
315    )),
316  }
317}
318
319fn does_not_throw<'js>(ctx: Ctx<'js>, body: Function<'js>, rest: Rest<Value<'js>>) -> Result<()> {
320  let (_, message) = split_expectation(&rest.0);
321  match body.call::<_, Value<'js>>(()) {
322    Ok(_) => Ok(()),
323    Err(_) => {
324      let caught = ctx.catch();
325      Err(fail_with(
326        &ctx,
327        message,
328        format!("Got unwanted exception: {}", render(&caught)),
329        caught,
330        Value::new_undefined(ctx.clone()),
331        "doesNotThrow",
332      ))
333    },
334  }
335}
336
337/// `(expected?, message?)`: a string in the first slot is the message,
338/// anything else is the expectation.
339fn split_expectation<'js>(rest: &[Value<'js>]) -> (Option<Value<'js>>, Opt<Value<'js>>) {
340  match rest {
341    [] => (None, Opt(None)),
342    [only] if only.is_string() => (None, Opt(Some(only.clone()))),
343    [only] => (Some(only.clone()), Opt(None)),
344    [first, second, ..] => (Some(first.clone()), Opt(Some(second.clone()))),
345  }
346}
347
348async fn rejects<'js>(ctx: Ctx<'js>, subject: Value<'js>, rest: Rest<Value<'js>>) -> Result<()> {
349  let (expected, message) = split_expectation(&rest.0);
350  match await_subject(&ctx, subject).await {
351    Err(_) => {
352      let caught = ctx.catch();
353      if let Some(expected) = expected {
354        if !thrown_matches(&ctx, &caught, &expected)? {
355          return Err(fail_with(
356            &ctx,
357            message,
358            format!("The rejection did not match the expectation: {}", render(&caught)),
359            caught,
360            expected,
361            "rejects",
362          ));
363        }
364      }
365      Ok(())
366    },
367    Ok(()) => Err(fail_with(
368      &ctx,
369      message,
370      "Missing expected rejection.".to_string(),
371      Value::new_undefined(ctx.clone()),
372      expected.unwrap_or_else(|| Value::new_undefined(ctx.clone())),
373      "rejects",
374    )),
375  }
376}
377
378async fn does_not_reject<'js>(ctx: Ctx<'js>, subject: Value<'js>, rest: Rest<Value<'js>>) -> Result<()> {
379  let (_, message) = split_expectation(&rest.0);
380  match await_subject(&ctx, subject).await {
381    Ok(()) => Ok(()),
382    Err(_) => {
383      let caught = ctx.catch();
384      Err(fail_with(
385        &ctx,
386        message,
387        format!("Got unwanted rejection: {}", render(&caught)),
388        caught,
389        Value::new_undefined(ctx.clone()),
390        "doesNotReject",
391      ))
392    },
393  }
394}
395
396/// Both async assertions accept a promise or a function returning one.
397async fn await_subject<'js>(ctx: &Ctx<'js>, subject: Value<'js>) -> Result<()> {
398  let promise: Value<'js> = if let Some(f) = subject.as_function() {
399    f.call(())?
400  } else {
401    subject
402  };
403  match promise.into_promise() {
404    Some(p) => p.into_future::<Value<'js>>().await.map(|_| ()),
405    None => Err(rquickjs::Exception::throw_type(
406      ctx,
407      "The \"promiseFn\" argument must be a function or a Promise",
408    )),
409  }
410}
411
412fn fail<'js>(ctx: Ctx<'js>, message: Opt<Value<'js>>) -> Result<()> {
413  Err(fail_with(
414    &ctx,
415    message,
416    "Failed".to_string(),
417    Value::new_undefined(ctx.clone()),
418    Value::new_undefined(ctx.clone()),
419    "fail",
420  ))
421}
422
423fn if_error<'js>(ctx: Ctx<'js>, value: Value<'js>) -> Result<()> {
424  if value.is_null() || value.is_undefined() {
425    return Ok(());
426  }
427  let rendered = render(&value);
428  Err(fail_with(
429    &ctx,
430    Opt(None),
431    format!("ifError got unwanted exception: {rendered}"),
432    value,
433    Value::new_null(ctx.clone()),
434    "ifError",
435  ))
436}
437
438/// The names [`assert_object`] sets, for a module's export list.
439pub const ASSERT_MEMBERS: &[&str] = &[
440  "deepEqual",
441  "deepStrictEqual",
442  "doesNotMatch",
443  "doesNotReject",
444  "doesNotThrow",
445  "equal",
446  "fail",
447  "ifError",
448  "match",
449  "notDeepEqual",
450  "notDeepStrictEqual",
451  "notEqual",
452  "notStrictEqual",
453  "ok",
454  "rejects",
455  "strict",
456  "strictEqual",
457  "throws",
458];
459
460fn install_members<'js>(target: &Object<'js>, strict_mode: bool) -> Result<()> {
461  target.set("ok", Func::from(ok))?;
462  target.set("fail", Func::from(fail))?;
463  target.set("ifError", Func::from(if_error))?;
464  target.set("match", Func::from(matches))?;
465  target.set("doesNotMatch", Func::from(does_not_match))?;
466  target.set("throws", Func::from(throws))?;
467  target.set("doesNotThrow", Func::from(does_not_throw))?;
468  target.set("rejects", Func::from(Async(rejects)))?;
469  target.set("doesNotReject", Func::from(Async(does_not_reject)))?;
470  target.set("strictEqual", Func::from(strict_eq))?;
471  target.set("notStrictEqual", Func::from(not_strict_eq))?;
472  target.set("deepStrictEqual", Func::from(deep_strict_eq))?;
473  target.set("notDeepStrictEqual", Func::from(not_deep_strict_eq))?;
474
475  // In strict mode the loose entry points ARE the strict ones, which is
476  // the whole difference between `assert` and `assert/strict`.
477  if strict_mode {
478    target.set("equal", Func::from(strict_eq))?;
479    target.set("notEqual", Func::from(not_strict_eq))?;
480    target.set("deepEqual", Func::from(deep_strict_eq))?;
481    target.set("notDeepEqual", Func::from(not_deep_strict_eq))?;
482  } else {
483    target.set("equal", Func::from(equal))?;
484    target.set("notEqual", Func::from(not_equal))?;
485    target.set("deepEqual", Func::from(deep_eq))?;
486    target.set("notDeepEqual", Func::from(not_deep_eq))?;
487  }
488  Ok(())
489}
490
491/// The `assert` module object: a callable that is `assert.ok`, carrying
492/// every assertion as a property, plus `assert.strict`.
493///
494/// # Errors
495///
496/// Propagates the property writes it makes.
497pub fn assert_object<'js>(ctx: &Ctx<'js>, strict_mode: bool) -> Result<Object<'js>> {
498  let callable = Function::new(ctx.clone(), ok)?.with_name("assert")?;
499  let object = callable
500    .as_object()
501    .cloned()
502    .ok_or_else(|| rquickjs::Error::new_loading("assert"))?;
503  install_members(&object, strict_mode)?;
504
505  if strict_mode {
506    object.set("strict", object.clone())?;
507  } else {
508    let strict = Function::new(ctx.clone(), ok)?.with_name("assert")?;
509    let strict_object = strict
510      .as_object()
511      .cloned()
512      .ok_or_else(|| rquickjs::Error::new_loading("assert"))?;
513    install_members(&strict_object, true)?;
514    strict_object.set("strict", strict_object.clone())?;
515    object.set("strict", strict_object)?;
516  }
517  Ok(object)
518}
519
520/// A promise-returning helper is only reachable from JS, so the module
521/// needs the async runtime marker type in scope.
522type _AsyncMarker<'js> = Promise<'js>;