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::{deep_equal, loose_equal, strict_equal, Mode};
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(
109    &ctx,
110    actual,
111    expected,
112    message,
113    passed,
114    "==",
115    "Expected values to be loosely equal:",
116  )
117}
118
119fn not_equal<'js>(ctx: Ctx<'js>, actual: Value<'js>, expected: Value<'js>, message: Opt<Value<'js>>) -> Result<()> {
120  let passed = !loose_equal(&actual, &expected);
121  compare(
122    &ctx,
123    actual,
124    expected,
125    message,
126    passed,
127    "!=",
128    "Expected values not to be loosely equal:",
129  )
130}
131
132fn strict_eq<'js>(ctx: Ctx<'js>, actual: Value<'js>, expected: Value<'js>, message: Opt<Value<'js>>) -> Result<()> {
133  let passed = strict_equal(&actual, &expected);
134  compare(
135    &ctx,
136    actual,
137    expected,
138    message,
139    passed,
140    "strictEqual",
141    "Expected values to be strictly equal:",
142  )
143}
144
145fn not_strict_eq<'js>(ctx: Ctx<'js>, actual: Value<'js>, expected: Value<'js>, message: Opt<Value<'js>>) -> Result<()> {
146  let passed = !strict_equal(&actual, &expected);
147  compare(
148    &ctx,
149    actual,
150    expected,
151    message,
152    passed,
153    "notStrictEqual",
154    "Expected values not to be strictly equal:",
155  )
156}
157
158fn deep_eq<'js>(ctx: Ctx<'js>, actual: Value<'js>, expected: Value<'js>, message: Opt<Value<'js>>) -> Result<()> {
159  let passed = deep_equal(&actual, &expected, Mode::Loose)?;
160  compare(
161    &ctx,
162    actual,
163    expected,
164    message,
165    passed,
166    "deepEqual",
167    "Expected values to be loosely deep-equal:",
168  )
169}
170
171fn not_deep_eq<'js>(ctx: Ctx<'js>, actual: Value<'js>, expected: Value<'js>, message: Opt<Value<'js>>) -> Result<()> {
172  let passed = !deep_equal(&actual, &expected, Mode::Loose)?;
173  compare(
174    &ctx,
175    actual,
176    expected,
177    message,
178    passed,
179    "notDeepEqual",
180    "Expected values not to be loosely deep-equal:",
181  )
182}
183
184fn deep_strict_eq<'js>(
185  ctx: Ctx<'js>,
186  actual: Value<'js>,
187  expected: Value<'js>,
188  message: Opt<Value<'js>>,
189) -> Result<()> {
190  let passed = deep_equal(&actual, &expected, Mode::Strict)?;
191  compare(
192    &ctx,
193    actual,
194    expected,
195    message,
196    passed,
197    "deepStrictEqual",
198    "Expected values to be strictly deep-equal:",
199  )
200}
201
202fn not_deep_strict_eq<'js>(
203  ctx: Ctx<'js>,
204  actual: Value<'js>,
205  expected: Value<'js>,
206  message: Opt<Value<'js>>,
207) -> Result<()> {
208  let passed = !deep_equal(&actual, &expected, Mode::Strict)?;
209  compare(
210    &ctx,
211    actual,
212    expected,
213    message,
214    passed,
215    "notDeepStrictEqual",
216    "Expected values not to be strictly deep-equal:",
217  )
218}
219
220fn regexp_test<'js>(regexp: &Object<'js>, subject: &Value<'js>) -> Result<bool> {
221  let test: Function<'js> = regexp.get("test")?;
222  test.call((rquickjs::function::This(regexp.clone()), subject.clone()))
223}
224
225fn matches<'js>(ctx: Ctx<'js>, subject: Value<'js>, regexp: Value<'js>, message: Opt<Value<'js>>) -> Result<()> {
226  let Some(re) = regexp.as_object() else {
227    return Err(rquickjs::Exception::throw_type(
228      &ctx,
229      "The \"regexp\" argument must be an instance of RegExp",
230    ));
231  };
232  let passed = regexp_test(re, &subject)?;
233  compare(
234    &ctx,
235    subject,
236    regexp.clone(),
237    message,
238    passed,
239    "match",
240    "The input did not match the regular expression:",
241  )
242}
243
244fn does_not_match<'js>(ctx: Ctx<'js>, subject: Value<'js>, regexp: Value<'js>, message: Opt<Value<'js>>) -> Result<()> {
245  let Some(re) = regexp.as_object() else {
246    return Err(rquickjs::Exception::throw_type(
247      &ctx,
248      "The \"regexp\" argument must be an instance of RegExp",
249    ));
250  };
251  let passed = !regexp_test(re, &subject)?;
252  compare(
253    &ctx,
254    subject,
255    regexp.clone(),
256    message,
257    passed,
258    "doesNotMatch",
259    "The input was expected to not match the regular expression:",
260  )
261}
262
263/// Does a thrown value satisfy the expectation Node accepts: a RegExp
264/// against its message, a constructor via `instanceof`, or an object whose
265/// listed properties must deep-strict-match.
266fn thrown_matches<'js>(ctx: &Ctx<'js>, error: &Value<'js>, expected: &Value<'js>) -> Result<bool> {
267  let Some(expected_obj) = expected.as_object() else {
268    return Ok(true);
269  };
270
271  if expected.is_function() {
272    let matches: Function<'js> = ctx.eval(
273      "(error, expected) => {
274        if (expected.prototype !== undefined && error instanceof expected) return true;
275        if (Object.prototype.isPrototypeOf.call(Error, expected)) return false;
276        return expected.call({}, error) === true;
277      }",
278    )?;
279    return matches.call((error.clone(), expected.clone()));
280  }
281
282  if regexp_source_present(expected_obj)? {
283    let message: Value<'js> = error
284      .as_object()
285      .map_or_else(|| Ok(error.clone()), |o| o.get::<_, Value<'js>>("message"))?;
286    return regexp_test(expected_obj, &message);
287  }
288
289  // A plain object: every listed property must deep-strict-match.
290  let Some(error_obj) = error.as_object() else {
291    return Ok(false);
292  };
293  for key in expected_obj.keys::<String>() {
294    let key = key?;
295    let want: Value<'js> = expected_obj.get(key.as_str())?;
296    let got: Value<'js> = error_obj.get(key.as_str())?;
297    if !deep_equal(&got, &want, Mode::Strict)? {
298      return Ok(false);
299    }
300  }
301  Ok(true)
302}
303
304fn regexp_source_present(object: &Object<'_>) -> Result<bool> {
305  Ok(object.get::<_, Value<'_>>("source").is_ok_and(|v| v.is_string()) && object.get::<_, Value<'_>>("test").is_ok())
306}
307
308fn throws<'js>(ctx: Ctx<'js>, body: Function<'js>, rest: Rest<Value<'js>>) -> Result<()> {
309  let (expected, message) = split_expectation(&rest.0);
310  match body.call::<_, Value<'js>>(()) {
311    Err(_) => {
312      let caught = ctx.catch();
313      if let Some(expected) = expected {
314        if !thrown_matches(&ctx, &caught, &expected)? {
315          return Err(fail_with(
316            &ctx,
317            message,
318            format!("The error did not match the expectation: {}", render(&caught)),
319            caught,
320            expected,
321            "throws",
322          ));
323        }
324      }
325      Ok(())
326    }
327    Ok(_) => Err(fail_with(
328      &ctx,
329      message,
330      "Missing expected exception.".to_string(),
331      Value::new_undefined(ctx.clone()),
332      expected.unwrap_or_else(|| Value::new_undefined(ctx.clone())),
333      "throws",
334    )),
335  }
336}
337
338fn does_not_throw<'js>(ctx: Ctx<'js>, body: Function<'js>, rest: Rest<Value<'js>>) -> Result<()> {
339  let (_, message) = split_expectation(&rest.0);
340  match body.call::<_, Value<'js>>(()) {
341    Ok(_) => Ok(()),
342    Err(_) => {
343      let caught = ctx.catch();
344      Err(fail_with(
345        &ctx,
346        message,
347        format!("Got unwanted exception: {}", render(&caught)),
348        caught,
349        Value::new_undefined(ctx.clone()),
350        "doesNotThrow",
351      ))
352    }
353  }
354}
355
356/// `(expected?, message?)`: a string in the first slot is the message,
357/// anything else is the expectation.
358fn split_expectation<'js>(rest: &[Value<'js>]) -> (Option<Value<'js>>, Opt<Value<'js>>) {
359  match rest {
360    [] => (None, Opt(None)),
361    [only] if only.is_string() => (None, Opt(Some(only.clone()))),
362    [only] => (Some(only.clone()), Opt(None)),
363    [first, second, ..] => (Some(first.clone()), Opt(Some(second.clone()))),
364  }
365}
366
367async fn rejects<'js>(ctx: Ctx<'js>, subject: Value<'js>, rest: Rest<Value<'js>>) -> Result<()> {
368  let (expected, message) = split_expectation(&rest.0);
369  match await_subject(&ctx, subject).await {
370    Err(_) => {
371      let caught = ctx.catch();
372      if let Some(expected) = expected {
373        if !thrown_matches(&ctx, &caught, &expected)? {
374          return Err(fail_with(
375            &ctx,
376            message,
377            format!("The rejection did not match the expectation: {}", render(&caught)),
378            caught,
379            expected,
380            "rejects",
381          ));
382        }
383      }
384      Ok(())
385    }
386    Ok(()) => Err(fail_with(
387      &ctx,
388      message,
389      "Missing expected rejection.".to_string(),
390      Value::new_undefined(ctx.clone()),
391      expected.unwrap_or_else(|| Value::new_undefined(ctx.clone())),
392      "rejects",
393    )),
394  }
395}
396
397async fn does_not_reject<'js>(ctx: Ctx<'js>, subject: Value<'js>, rest: Rest<Value<'js>>) -> Result<()> {
398  let (_, message) = split_expectation(&rest.0);
399  match await_subject(&ctx, subject).await {
400    Ok(()) => Ok(()),
401    Err(_) => {
402      let caught = ctx.catch();
403      Err(fail_with(
404        &ctx,
405        message,
406        format!("Got unwanted rejection: {}", render(&caught)),
407        caught,
408        Value::new_undefined(ctx.clone()),
409        "doesNotReject",
410      ))
411    }
412  }
413}
414
415/// Both async assertions accept a promise or a function returning one.
416async fn await_subject<'js>(ctx: &Ctx<'js>, subject: Value<'js>) -> Result<()> {
417  let promise: Value<'js> = if let Some(f) = subject.as_function() {
418    f.call(())?
419  } else {
420    subject
421  };
422  match promise.into_promise() {
423    Some(p) => p.into_future::<Value<'js>>().await.map(|_| ()),
424    None => Err(rquickjs::Exception::throw_type(
425      ctx,
426      "The \"promiseFn\" argument must be a function or a Promise",
427    )),
428  }
429}
430
431fn fail<'js>(ctx: Ctx<'js>, message: Opt<Value<'js>>) -> Result<()> {
432  Err(fail_with(
433    &ctx,
434    message,
435    "Failed".to_string(),
436    Value::new_undefined(ctx.clone()),
437    Value::new_undefined(ctx.clone()),
438    "fail",
439  ))
440}
441
442fn if_error<'js>(ctx: Ctx<'js>, value: Value<'js>) -> Result<()> {
443  if value.is_null() || value.is_undefined() {
444    return Ok(());
445  }
446  let rendered = render(&value);
447  Err(fail_with(
448    &ctx,
449    Opt(None),
450    format!("ifError got unwanted exception: {rendered}"),
451    value,
452    Value::new_null(ctx.clone()),
453    "ifError",
454  ))
455}
456
457/// The names [`assert_object`] sets, for a module's export list.
458pub const ASSERT_MEMBERS: &[&str] = &[
459  "deepEqual",
460  "deepStrictEqual",
461  "doesNotMatch",
462  "doesNotReject",
463  "doesNotThrow",
464  "equal",
465  "fail",
466  "ifError",
467  "match",
468  "notDeepEqual",
469  "notDeepStrictEqual",
470  "notEqual",
471  "notStrictEqual",
472  "ok",
473  "rejects",
474  "strict",
475  "strictEqual",
476  "throws",
477];
478
479fn install_members<'js>(target: &Object<'js>, strict_mode: bool) -> Result<()> {
480  target.set("ok", Func::from(ok))?;
481  target.set("fail", Func::from(fail))?;
482  target.set("ifError", Func::from(if_error))?;
483  target.set("match", Func::from(matches))?;
484  target.set("doesNotMatch", Func::from(does_not_match))?;
485  target.set("throws", Func::from(throws))?;
486  target.set("doesNotThrow", Func::from(does_not_throw))?;
487  target.set("rejects", Func::from(Async(rejects)))?;
488  target.set("doesNotReject", Func::from(Async(does_not_reject)))?;
489  target.set("strictEqual", Func::from(strict_eq))?;
490  target.set("notStrictEqual", Func::from(not_strict_eq))?;
491  target.set("deepStrictEqual", Func::from(deep_strict_eq))?;
492  target.set("notDeepStrictEqual", Func::from(not_deep_strict_eq))?;
493
494  // In strict mode the loose entry points ARE the strict ones, which is
495  // the whole difference between `assert` and `assert/strict`.
496  if strict_mode {
497    target.set("equal", Func::from(strict_eq))?;
498    target.set("notEqual", Func::from(not_strict_eq))?;
499    target.set("deepEqual", Func::from(deep_strict_eq))?;
500    target.set("notDeepEqual", Func::from(not_deep_strict_eq))?;
501  } else {
502    target.set("equal", Func::from(equal))?;
503    target.set("notEqual", Func::from(not_equal))?;
504    target.set("deepEqual", Func::from(deep_eq))?;
505    target.set("notDeepEqual", Func::from(not_deep_eq))?;
506  }
507  Ok(())
508}
509
510/// The `assert` module object: a callable that is `assert.ok`, carrying
511/// every assertion as a property, plus `assert.strict`.
512///
513/// # Errors
514///
515/// Propagates the property writes it makes.
516pub fn assert_object<'js>(ctx: &Ctx<'js>, strict_mode: bool) -> Result<Object<'js>> {
517  let callable = Function::new(ctx.clone(), ok)?.with_name("assert")?;
518  let object = callable
519    .as_object()
520    .cloned()
521    .ok_or_else(|| rquickjs::Error::new_loading("assert"))?;
522  install_members(&object, strict_mode)?;
523
524  if strict_mode {
525    object.set("strict", object.clone())?;
526  } else {
527    let strict = Function::new(ctx.clone(), ok)?.with_name("assert")?;
528    let strict_object = strict
529      .as_object()
530      .cloned()
531      .ok_or_else(|| rquickjs::Error::new_loading("assert"))?;
532    install_members(&strict_object, true)?;
533    strict_object.set("strict", strict_object.clone())?;
534    object.set("strict", strict_object)?;
535  }
536  Ok(object)
537}
538
539/// A promise-returning helper is only reachable from JS, so the module
540/// needs the async runtime marker type in scope.
541type _AsyncMarker<'js> = Promise<'js>;