Skip to main content

ferridriver_script/bindings/
expect.rs

1//! QuickJS bindings for the `expect` API — Jest-style value matchers,
2//! Playwright web-first matchers, asymmetric matchers, and polling.
3//!
4//! All matcher logic delegates to [`ferridriver_expect`] so the Rust
5//! tests and the script layer share one source of truth (per Rule 1 in
6//! `CLAUDE.md` — Rust is the source of truth; bindings are thin
7//! mirrors). Web-first matchers wrap `ferridriver::Locator` / `Page` /
8//! `HttpResponse` directly and reuse [`ferridriver_expect::poll_until`]
9//! for retry semantics that match Playwright.
10
11use std::sync::Arc;
12use std::time::Duration;
13
14use ferridriver::Page;
15use ferridriver::http_client::HttpResponse;
16use ferridriver::locator::Locator;
17use ferridriver_expect::{
18  AssertionFailure, DEFAULT_EXPECT_TIMEOUT, ExpectValue, POLL_INTERVALS, StringOrRegex, ThrowMatcher, ThrownError,
19  deep_equal, expect_fn, expect_value,
20};
21use rquickjs::{Array, Class, Ctx, Function, JsLifetime, Object, Persistent, Value, class::Trace, function::Opt};
22use serde_json::Value as JsonValue;
23
24use crate::bindings::convert::{json_to_js, serde_from_js};
25use crate::bindings::http_client::HttpResponseJs;
26use crate::bindings::locator::LocatorJs;
27use crate::bindings::page::PageJs;
28
29// ── ExpectJs ─────────────────────────────────────────────────────────
30
31#[derive(JsLifetime, Trace)]
32#[rquickjs::class(rename = "Expect")]
33pub struct ExpectJs {
34  #[qjs(skip_trace)]
35  target: ExpectTarget,
36  is_not: bool,
37  is_soft: bool,
38  #[qjs(skip_trace)]
39  timeout: Duration,
40  message: Option<String>,
41}
42
43#[derive(Clone)]
44enum ExpectTarget {
45  Value {
46    value: JsonValue,
47    /// `value.constructor.name` captured at `expect(...)` call time,
48    /// used by `toBeInstanceOf` to compare custom constructors.
49    ctor_name: Option<String>,
50  },
51  Locator(Locator),
52  Page(Arc<Page>),
53  ApiResponse(HttpResponse),
54  /// Persistent JS function — kept across `expect(fn).toThrow(...)`
55  /// because `toThrow` invokes it lazily.
56  Fn(Persistent<Function<'static>>),
57}
58
59impl ExpectJs {
60  fn new(target: ExpectTarget) -> Self {
61    Self {
62      target,
63      is_not: false,
64      is_soft: false,
65      timeout: DEFAULT_EXPECT_TIMEOUT,
66      message: None,
67    }
68  }
69
70  fn clone_with<F: FnOnce(&mut Self)>(&self, mutate: F) -> Self {
71    let mut out = Self {
72      target: self.target.clone(),
73      is_not: self.is_not,
74      is_soft: self.is_soft,
75      timeout: self.timeout,
76      message: self.message.clone(),
77    };
78    mutate(&mut out);
79    out
80  }
81
82  fn value_target(&self) -> Result<(&JsonValue, Option<&str>), rquickjs::Error> {
83    match &self.target {
84      ExpectTarget::Value { value, ctor_name } => Ok((value, ctor_name.as_deref())),
85      _ => Err(rquickjs::Error::new_from_js_message(
86        "expect",
87        "matcher",
88        "this matcher requires a value subject (got Locator/Page/Response/Function)",
89      )),
90    }
91  }
92
93  fn locator_target(&self) -> Result<&Locator, rquickjs::Error> {
94    match &self.target {
95      ExpectTarget::Locator(loc) => Ok(loc),
96      _ => Err(rquickjs::Error::new_from_js_message(
97        "expect",
98        "matcher",
99        "this matcher requires a Locator subject",
100      )),
101    }
102  }
103
104  fn page_target(&self) -> Result<&Arc<Page>, rquickjs::Error> {
105    match &self.target {
106      ExpectTarget::Page(p) => Ok(p),
107      _ => Err(rquickjs::Error::new_from_js_message(
108        "expect",
109        "matcher",
110        "this matcher requires a Page subject",
111      )),
112    }
113  }
114
115  fn api_response_target(&self) -> Result<&HttpResponse, rquickjs::Error> {
116    match &self.target {
117      ExpectTarget::ApiResponse(r) => Ok(r),
118      _ => Err(rquickjs::Error::new_from_js_message(
119        "expect",
120        "matcher",
121        "this matcher requires an APIResponse subject",
122      )),
123    }
124  }
125
126  fn fn_target(&self) -> Result<&Persistent<Function<'static>>, rquickjs::Error> {
127    match &self.target {
128      ExpectTarget::Fn(f) => Ok(f),
129      _ => Err(rquickjs::Error::new_from_js_message(
130        "expect",
131        "matcher",
132        "this matcher requires a function subject",
133      )),
134    }
135  }
136
137  fn build_value_expect(&self) -> Result<ExpectValue, rquickjs::Error> {
138    let (val, _) = self.value_target()?;
139    let mut ev = expect_value(val.clone());
140    if self.is_not {
141      ev = ev.not();
142    }
143    if self.is_soft {
144      ev = ev.soft();
145    }
146    if let Some(m) = &self.message {
147      ev = ev.with_message(m.clone());
148    }
149    Ok(ev)
150  }
151
152  /// Build a configured `ferridriver_expect::Expect<'_, Locator>` so
153  /// every web-first locator matcher delegates to the shared Rust
154  /// impl in `ferridriver-expect` (single source of truth). Matcher
155  /// state (timeout, `.not`, `.soft`, message) is copied over once
156  /// per call.
157  fn build_locator_expect(&self) -> Result<ferridriver_expect::Expect<'_, Locator>, rquickjs::Error> {
158    let loc = self.locator_target()?;
159    let mut e = ferridriver_expect::expect(loc).with_timeout(self.timeout);
160    if self.is_not {
161      e = e.not();
162    }
163    if self.is_soft {
164      e = e.soft();
165    }
166    if let Some(m) = &self.message {
167      e = e.with_message(m.clone());
168    }
169    Ok(e)
170  }
171
172  fn build_page_expect(&self) -> Result<ferridriver_expect::Expect<'_, std::sync::Arc<Page>>, rquickjs::Error> {
173    let p = self.page_target()?;
174    let mut e = ferridriver_expect::expect(p).with_timeout(self.timeout);
175    if self.is_not {
176      e = e.not();
177    }
178    if self.is_soft {
179      e = e.soft();
180    }
181    if let Some(m) = &self.message {
182      e = e.with_message(m.clone());
183    }
184    Ok(e)
185  }
186
187  fn build_api_response_expect(&self) -> Result<ferridriver_expect::Expect<'_, HttpResponse>, rquickjs::Error> {
188    let r = self.api_response_target()?;
189    let mut e = ferridriver_expect::expect(r);
190    if self.is_not {
191      e = e.not();
192    }
193    if self.is_soft {
194      e = e.soft();
195    }
196    if let Some(m) = &self.message {
197      e = e.with_message(m.clone());
198    }
199    Ok(e)
200  }
201}
202
203fn assertion_to_rq(err: AssertionFailure) -> rquickjs::Error {
204  // Concatenate title + body for the JS-thrown message so the JS stack
205  // shows the full failure on one Error. The JS stack itself comes from
206  // QuickJS and is added to the Error automatically.
207  let full = match err.diff.as_deref() {
208    Some(body) if !body.is_empty() => format!("{}\n\n{body}", err.message),
209    _ => err.message,
210  };
211  rquickjs::Error::new_from_js_message("expect", "AssertionError", full)
212}
213
214fn parse_string_or_regex<'js>(_ctx: &Ctx<'js>, value: &Value<'js>) -> rquickjs::Result<StringOrRegex> {
215  if let Some(s) = value.as_string() {
216    return Ok(StringOrRegex::String(s.to_string()?));
217  }
218  // RegExp instance: read `.source` and `.flags`.
219  if let Some(obj) = value.as_object() {
220    let source: rquickjs::Result<rquickjs::Value<'js>> = obj.get("source");
221    let flags: rquickjs::Result<rquickjs::Value<'js>> = obj.get("flags");
222    if let (Ok(s), Ok(f)) = (source, flags)
223      && let (Some(s), Some(f)) = (s.as_string(), f.as_string())
224    {
225      let pat = s.to_string()?;
226      let flg = f.to_string()?;
227      let re = ferridriver_expect::asymmetric::compile_js_regex(&pat, &flg)
228        .map_err(|e| rquickjs::Error::new_from_js_message("expect", "RegExp", e.to_string()))?;
229      return Ok(StringOrRegex::Regex(re));
230    }
231  }
232  Err(rquickjs::Error::new_from_js_message(
233    "expect",
234    "argument",
235    "expected a string or RegExp",
236  ))
237}
238
239#[rquickjs::methods]
240impl ExpectJs {
241  // ── modifiers ────────────────────────────────────────────────────
242
243  /// `.not` getter — returns a new ExpectJs with the negation flag
244  /// toggled. Implemented as a method so `expect(x).not.toBe(y)` reads
245  /// naturally; the JS-side `Object.defineProperty` shim in
246  /// `install_expect` adapts it into a getter on the class prototype.
247  #[qjs(rename = "_notInner")]
248  pub fn not_inner(&self) -> ExpectJs {
249    self.clone_with(|e| e.is_not = !e.is_not)
250  }
251
252  /// `.soft` modifier.
253  #[qjs(rename = "soft")]
254  pub fn soft(&self) -> ExpectJs {
255    self.clone_with(|e| e.is_soft = true)
256  }
257
258  /// Override the timeout for web-first matchers on this assertion
259  /// (milliseconds).
260  #[qjs(rename = "withTimeout")]
261  pub fn with_timeout(&self, timeout_ms: u32) -> ExpectJs {
262    self.clone_with(|e| e.timeout = Duration::from_millis(u64::from(timeout_ms)))
263  }
264
265  /// Attach a custom failure-message prefix.
266  #[qjs(rename = "withMessage")]
267  pub fn with_message(&self, msg: String) -> ExpectJs {
268    self.clone_with(|e| e.message = Some(msg))
269  }
270
271  // ── value matchers ───────────────────────────────────────────────
272
273  #[qjs(rename = "toBe")]
274  pub fn to_be<'js>(&self, ctx: Ctx<'js>, expected: Value<'js>) -> rquickjs::Result<()> {
275    let exp: JsonValue = serde_from_js(&ctx, expected)?;
276    self.build_value_expect()?.to_be(&exp).map_err(assertion_to_rq)
277  }
278
279  #[qjs(rename = "toEqual")]
280  pub fn to_equal<'js>(&self, ctx: Ctx<'js>, expected: Value<'js>) -> rquickjs::Result<()> {
281    let exp: JsonValue = serde_from_js(&ctx, expected)?;
282    self.build_value_expect()?.to_equal(&exp).map_err(assertion_to_rq)
283  }
284
285  #[qjs(rename = "toStrictEqual")]
286  pub fn to_strict_equal<'js>(&self, ctx: Ctx<'js>, expected: Value<'js>) -> rquickjs::Result<()> {
287    let exp: JsonValue = serde_from_js(&ctx, expected)?;
288    self
289      .build_value_expect()?
290      .to_strict_equal(&exp)
291      .map_err(assertion_to_rq)
292  }
293
294  #[qjs(rename = "toBeNull")]
295  pub fn to_be_null(&self) -> rquickjs::Result<()> {
296    self.build_value_expect()?.to_be_null().map_err(assertion_to_rq)
297  }
298
299  #[qjs(rename = "toBeUndefined")]
300  pub fn to_be_undefined(&self) -> rquickjs::Result<()> {
301    self.build_value_expect()?.to_be_undefined().map_err(assertion_to_rq)
302  }
303
304  #[qjs(rename = "toBeDefined")]
305  pub fn to_be_defined(&self) -> rquickjs::Result<()> {
306    self.build_value_expect()?.to_be_defined().map_err(assertion_to_rq)
307  }
308
309  #[qjs(rename = "toBeTruthy")]
310  pub fn to_be_truthy(&self) -> rquickjs::Result<()> {
311    self.build_value_expect()?.to_be_truthy().map_err(assertion_to_rq)
312  }
313
314  #[qjs(rename = "toBeFalsy")]
315  pub fn to_be_falsy(&self) -> rquickjs::Result<()> {
316    self.build_value_expect()?.to_be_falsy().map_err(assertion_to_rq)
317  }
318
319  #[qjs(rename = "toBeNaN")]
320  pub fn to_be_nan(&self) -> rquickjs::Result<()> {
321    self.build_value_expect()?.to_be_nan().map_err(assertion_to_rq)
322  }
323
324  #[qjs(rename = "toBeCloseTo")]
325  pub fn to_be_close_to(&self, expected: f64, digits: Opt<u8>) -> rquickjs::Result<()> {
326    self
327      .build_value_expect()?
328      .to_be_close_to(expected, digits.0)
329      .map_err(assertion_to_rq)
330  }
331
332  #[qjs(rename = "toBeGreaterThan")]
333  pub fn to_be_greater_than(&self, expected: f64) -> rquickjs::Result<()> {
334    self
335      .build_value_expect()?
336      .to_be_greater_than(expected)
337      .map_err(assertion_to_rq)
338  }
339
340  #[qjs(rename = "toBeGreaterThanOrEqual")]
341  pub fn to_be_greater_than_or_equal(&self, expected: f64) -> rquickjs::Result<()> {
342    self
343      .build_value_expect()?
344      .to_be_greater_than_or_equal(expected)
345      .map_err(assertion_to_rq)
346  }
347
348  #[qjs(rename = "toBeLessThan")]
349  pub fn to_be_less_than(&self, expected: f64) -> rquickjs::Result<()> {
350    self
351      .build_value_expect()?
352      .to_be_less_than(expected)
353      .map_err(assertion_to_rq)
354  }
355
356  #[qjs(rename = "toBeLessThanOrEqual")]
357  pub fn to_be_less_than_or_equal(&self, expected: f64) -> rquickjs::Result<()> {
358    self
359      .build_value_expect()?
360      .to_be_less_than_or_equal(expected)
361      .map_err(assertion_to_rq)
362  }
363
364  #[qjs(rename = "toContain")]
365  pub fn to_contain<'js>(&self, ctx: Ctx<'js>, expected: Value<'js>) -> rquickjs::Result<()> {
366    let exp: JsonValue = serde_from_js(&ctx, expected)?;
367    self.build_value_expect()?.to_contain(&exp).map_err(assertion_to_rq)
368  }
369
370  #[qjs(rename = "toContainEqual")]
371  pub fn to_contain_equal<'js>(&self, ctx: Ctx<'js>, expected: Value<'js>) -> rquickjs::Result<()> {
372    self
373      .build_value_expect()?
374      .to_contain_equal(&serde_from_js(&ctx, expected)?)
375      .map_err(assertion_to_rq)
376  }
377
378  #[qjs(rename = "toHaveLength")]
379  pub fn to_have_length(&self, expected: u32) -> rquickjs::Result<()> {
380    self
381      .build_value_expect()?
382      .to_have_length(expected as usize)
383      .map_err(assertion_to_rq)
384  }
385
386  #[qjs(rename = "toHaveProperty")]
387  pub fn to_have_property<'js>(
388    &self,
389    ctx: Ctx<'js>,
390    path: Value<'js>,
391    expected: Opt<Value<'js>>,
392  ) -> rquickjs::Result<()> {
393    let path_v: JsonValue = serde_from_js(&ctx, path)?;
394    let exp = match expected.0 {
395      Some(v) if !v.is_undefined() => Some(serde_from_js::<JsonValue>(&ctx, v)?),
396      _ => None,
397    };
398    self
399      .build_value_expect()?
400      .to_have_property(&path_v, exp.as_ref())
401      .map_err(assertion_to_rq)
402  }
403
404  #[qjs(rename = "toMatch")]
405  pub fn to_match<'js>(&self, ctx: Ctx<'js>, pattern: Value<'js>) -> rquickjs::Result<()> {
406    let pat = parse_string_or_regex(&ctx, &pattern)?;
407    self.build_value_expect()?.to_match(&pat).map_err(assertion_to_rq)
408  }
409
410  #[qjs(rename = "toMatchObject")]
411  pub fn to_match_object<'js>(&self, ctx: Ctx<'js>, subset: Value<'js>) -> rquickjs::Result<()> {
412    let sub: JsonValue = serde_from_js(&ctx, subset)?;
413    self
414      .build_value_expect()?
415      .to_match_object(&sub)
416      .map_err(assertion_to_rq)
417  }
418
419  #[qjs(rename = "toBeInstanceOf")]
420  pub fn to_be_instance_of<'js>(&self, _ctx: Ctx<'js>, ctor: Value<'js>) -> rquickjs::Result<()> {
421    let ctor_name = ctor
422      .as_function()
423      .and_then(|f| f.get::<_, rquickjs::Value<'js>>("name").ok())
424      .and_then(|v| v.as_string().and_then(|s| s.to_string().ok()))
425      .unwrap_or_else(|| "(unknown)".into());
426    let (val, target_ctor) = self.value_target()?;
427    let mut ev = expect_value(val.clone());
428    if self.is_not {
429      ev = ev.not();
430    }
431    ev.to_be_instance_of(&ctor_name, target_ctor).map_err(assertion_to_rq)
432  }
433
434  #[qjs(rename = "toThrow")]
435  pub async fn to_throw<'js>(&self, ctx: Ctx<'js>, matcher: Opt<Value<'js>>) -> rquickjs::Result<()> {
436    let f = self.fn_target()?.clone().restore(&ctx)?;
437    let call_outcome: rquickjs::Result<rquickjs::Value<'js>> = f.call(());
438    // If the function returned a Promise (async fn), await it so a
439    // post-microtask throw is captured.
440    let final_outcome = match call_outcome {
441      Ok(v) => match v.as_promise() {
442        Some(p) => p.clone().into_future::<rquickjs::Value<'js>>().await,
443        None => Ok(v),
444      },
445      Err(e) => Err(e),
446    };
447    let caught = match final_outcome {
448      Ok(_) => None,
449      Err(rquickjs::Error::Exception) => {
450        let exc = ctx.catch();
451        let (msg, name) = extract_error(&exc);
452        Some(ThrownError {
453          message: msg,
454          class_name: name,
455        })
456      },
457      Err(other) => Some(ThrownError {
458        message: other.to_string(),
459        class_name: None,
460      }),
461    };
462    let matcher = match matcher.0 {
463      Some(v) if !v.is_undefined() => Some(parse_throw_matcher(&ctx, v)?),
464      _ => None,
465    };
466    let mut ef = expect_fn(caught);
467    if self.is_not {
468      ef = ef.not();
469    }
470    if let Some(m) = &self.message {
471      ef = ef.with_message(m.clone());
472    }
473    ef.to_throw(matcher.as_ref()).map_err(assertion_to_rq)
474  }
475
476  // ── Locator web-first matchers (delegated to ferridriver-expect) ──
477
478  #[qjs(rename = "toBeVisible")]
479  pub async fn to_be_visible(&self) -> rquickjs::Result<()> {
480    self
481      .build_locator_expect()?
482      .to_be_visible()
483      .await
484      .map_err(assertion_to_rq)
485  }
486
487  #[qjs(rename = "toBeHidden")]
488  pub async fn to_be_hidden(&self) -> rquickjs::Result<()> {
489    self
490      .build_locator_expect()?
491      .to_be_hidden()
492      .await
493      .map_err(assertion_to_rq)
494  }
495
496  #[qjs(rename = "toBeEnabled")]
497  pub async fn to_be_enabled(&self) -> rquickjs::Result<()> {
498    self
499      .build_locator_expect()?
500      .to_be_enabled()
501      .await
502      .map_err(assertion_to_rq)
503  }
504
505  #[qjs(rename = "toBeDisabled")]
506  pub async fn to_be_disabled(&self) -> rquickjs::Result<()> {
507    self
508      .build_locator_expect()?
509      .to_be_disabled()
510      .await
511      .map_err(assertion_to_rq)
512  }
513
514  #[qjs(rename = "toBeChecked")]
515  pub async fn to_be_checked(&self) -> rquickjs::Result<()> {
516    self
517      .build_locator_expect()?
518      .to_be_checked()
519      .await
520      .map_err(assertion_to_rq)
521  }
522
523  #[qjs(rename = "toBeEditable")]
524  pub async fn to_be_editable(&self) -> rquickjs::Result<()> {
525    self
526      .build_locator_expect()?
527      .to_be_editable()
528      .await
529      .map_err(assertion_to_rq)
530  }
531
532  #[qjs(rename = "toBeAttached")]
533  pub async fn to_be_attached(&self) -> rquickjs::Result<()> {
534    self
535      .build_locator_expect()?
536      .to_be_attached()
537      .await
538      .map_err(assertion_to_rq)
539  }
540
541  #[qjs(rename = "toBeEmpty")]
542  pub async fn to_be_empty(&self) -> rquickjs::Result<()> {
543    self
544      .build_locator_expect()?
545      .to_be_empty()
546      .await
547      .map_err(assertion_to_rq)
548  }
549
550  #[qjs(rename = "toHaveText")]
551  pub async fn to_have_text<'js>(&self, ctx: Ctx<'js>, expected: Value<'js>) -> rquickjs::Result<()> {
552    let exp = parse_string_or_regex(&ctx, &expected)?;
553    self
554      .build_locator_expect()?
555      .to_have_text(exp)
556      .await
557      .map_err(assertion_to_rq)
558  }
559
560  #[qjs(rename = "toContainText")]
561  pub async fn to_contain_text(&self, expected: String) -> rquickjs::Result<()> {
562    self
563      .build_locator_expect()?
564      .to_contain_text(StringOrRegex::String(expected))
565      .await
566      .map_err(assertion_to_rq)
567  }
568
569  #[qjs(rename = "toHaveValue")]
570  pub async fn to_have_value<'js>(&self, ctx: Ctx<'js>, expected: Value<'js>) -> rquickjs::Result<()> {
571    let exp = parse_string_or_regex(&ctx, &expected)?;
572    self
573      .build_locator_expect()?
574      .to_have_value(exp)
575      .await
576      .map_err(assertion_to_rq)
577  }
578
579  #[qjs(rename = "toHaveCount")]
580  pub async fn to_have_count(&self, expected: u32) -> rquickjs::Result<()> {
581    self
582      .build_locator_expect()?
583      .to_have_count(expected as usize)
584      .await
585      .map_err(assertion_to_rq)
586  }
587
588  #[qjs(rename = "toHaveAttribute")]
589  pub async fn to_have_attribute<'js>(
590    &self,
591    ctx: Ctx<'js>,
592    name: String,
593    value: Opt<Value<'js>>,
594  ) -> rquickjs::Result<()> {
595    let e = self.build_locator_expect()?;
596    match value.0 {
597      Some(v) if !v.is_undefined() => {
598        let exp = parse_string_or_regex(&ctx, &v)?;
599        e.to_have_attribute(&name, exp).await
600      },
601      _ => e.to_have_attribute_exists(&name).await,
602    }
603    .map_err(assertion_to_rq)
604  }
605
606  // ── Page web-first matchers (delegated) ───────────────────────────
607
608  #[qjs(rename = "toHaveTitle")]
609  pub async fn to_have_title<'js>(&self, ctx: Ctx<'js>, expected: Value<'js>) -> rquickjs::Result<()> {
610    let exp = parse_string_or_regex(&ctx, &expected)?;
611    self
612      .build_page_expect()?
613      .to_have_title(exp)
614      .await
615      .map_err(assertion_to_rq)
616  }
617
618  #[qjs(rename = "toHaveURL")]
619  pub async fn to_have_url<'js>(&self, ctx: Ctx<'js>, expected: Value<'js>) -> rquickjs::Result<()> {
620    let exp = parse_string_or_regex(&ctx, &expected)?;
621    self
622      .build_page_expect()?
623      .to_have_url(exp)
624      .await
625      .map_err(assertion_to_rq)
626  }
627
628  // ── APIResponse matcher (delegated) ──────────────────────────────
629
630  #[qjs(rename = "toBeOK")]
631  pub fn to_be_ok(&self) -> rquickjs::Result<()> {
632    self.build_api_response_expect()?.to_be_ok().map_err(assertion_to_rq)
633  }
634}
635
636fn parse_throw_matcher<'js>(ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<ThrowMatcher> {
637  if let Some(s) = value.as_string() {
638    return Ok(ThrowMatcher::Substring(s.to_string()?));
639  }
640  if let Some(obj) = value.as_object() {
641    if let Ok(source) = obj.get::<_, rquickjs::Value<'js>>("source")
642      && let Some(s) = source.as_string()
643    {
644      let flags = obj
645        .get::<_, rquickjs::Value<'js>>("flags")
646        .ok()
647        .and_then(|v| v.as_string().and_then(|s| s.to_string().ok()))
648        .unwrap_or_default();
649      let pat = s.to_string()?;
650      let re = ferridriver_expect::asymmetric::compile_js_regex(&pat, &flags)
651        .map_err(|e| rquickjs::Error::new_from_js_message("expect", "RegExp", e.to_string()))?;
652      return Ok(ThrowMatcher::Regex(re));
653    }
654    // Plain object → treat as match-against-{message,name}
655    let json: JsonValue = serde_from_js(ctx, value)?;
656    return Ok(ThrowMatcher::Object(json));
657  }
658  if let Some(func) = value.as_function() {
659    let name: String = func
660      .get::<_, rquickjs::Value<'js>>("name")
661      .ok()
662      .and_then(|v| v.as_string().and_then(|s| s.to_string().ok()))
663      .unwrap_or_default();
664    if !name.is_empty() {
665      return Ok(ThrowMatcher::ClassName(name));
666    }
667  }
668  Ok(ThrowMatcher::Any)
669}
670
671fn extract_error<'js>(v: &Value<'js>) -> (String, Option<String>) {
672  if let Some(obj) = v.as_object() {
673    let msg = obj
674      .get::<_, rquickjs::Value<'js>>("message")
675      .ok()
676      .and_then(|v| v.as_string().and_then(|s| s.to_string().ok()))
677      .unwrap_or_default();
678    let name = obj
679      .get::<_, rquickjs::Value<'js>>("name")
680      .ok()
681      .and_then(|v| v.as_string().and_then(|s| s.to_string().ok()))
682      .filter(|s| !s.is_empty());
683    return (msg, name);
684  }
685  if let Some(s) = v.as_string() {
686    return (s.to_string().unwrap_or_default(), None);
687  }
688  (String::new(), None)
689}
690
691// ── ExpectPollJs ─────────────────────────────────────────────────────
692
693#[derive(JsLifetime, Trace)]
694#[rquickjs::class(rename = "ExpectPoll")]
695pub struct ExpectPollJs {
696  #[qjs(skip_trace)]
697  generator: Persistent<Function<'static>>,
698  #[qjs(skip_trace)]
699  timeout: Duration,
700  is_not: bool,
701}
702
703#[rquickjs::methods]
704impl ExpectPollJs {
705  #[qjs(rename = "withTimeout")]
706  pub fn with_timeout(&self, timeout_ms: u32) -> ExpectPollJs {
707    ExpectPollJs {
708      generator: self.generator.clone(),
709      timeout: Duration::from_millis(u64::from(timeout_ms)),
710      is_not: self.is_not,
711    }
712  }
713
714  #[qjs(rename = "_notInner")]
715  pub fn not_inner(&self) -> ExpectPollJs {
716    ExpectPollJs {
717      generator: self.generator.clone(),
718      timeout: self.timeout,
719      is_not: !self.is_not,
720    }
721  }
722
723  #[qjs(rename = "toBe")]
724  pub async fn to_be<'js>(&self, ctx: Ctx<'js>, expected: Value<'js>) -> rquickjs::Result<()> {
725    let exp: JsonValue = serde_from_js(&ctx, expected)?;
726    self.poll_value(&ctx, "toBe", &exp).await
727  }
728
729  #[qjs(rename = "toEqual")]
730  pub async fn to_equal<'js>(&self, ctx: Ctx<'js>, expected: Value<'js>) -> rquickjs::Result<()> {
731    let exp: JsonValue = serde_from_js(&ctx, expected)?;
732    self.poll_value(&ctx, "toEqual", &exp).await
733  }
734
735  #[qjs(rename = "toSatisfy")]
736  pub async fn to_satisfy<'js>(&self, ctx: Ctx<'js>, predicate: Function<'js>) -> rquickjs::Result<()> {
737    let saved_pred = Persistent::save(&ctx, predicate);
738    let generator_fn = self.generator.clone();
739    let deadline = tokio::time::Instant::now() + self.timeout;
740    let mut interval_idx = 0;
741    let is_not = self.is_not;
742    let final_dbg: String = loop {
743      let actual: rquickjs::Result<JsonValue> = call_generator(&ctx, &generator_fn).await;
744      let actual = actual?;
745      let dbg = ferridriver_expect::asymmetric::json_short(&actual);
746      let pred = saved_pred.clone().restore(&ctx)?;
747      let actual_js = json_to_js(&ctx, &actual)?;
748      let result: rquickjs::Value<'_> = pred.call((actual_js,))?;
749      let passes = result.as_bool().unwrap_or(false);
750      let passes = if is_not { !passes } else { passes };
751      if passes {
752        return Ok(());
753      }
754      let interval_ms = POLL_INTERVALS
755        .get(interval_idx)
756        .copied()
757        .unwrap_or_else(|| POLL_INTERVALS.last().copied().unwrap_or(1000));
758      interval_idx += 1;
759      let sleep_dur = Duration::from_millis(interval_ms);
760      if tokio::time::Instant::now() + sleep_dur > deadline {
761        break dbg;
762      }
763      tokio::time::sleep(sleep_dur).await;
764    };
765    let last = final_dbg.as_str();
766    Err(assertion_to_rq(AssertionFailure::new(
767      format!(
768        "expect.poll().toSatisfy() timed out after {}ms; last value was {last}",
769        self.timeout.as_millis()
770      ),
771      None,
772    )))
773  }
774}
775
776impl ExpectPollJs {
777  async fn poll_value(&self, ctx: &Ctx<'_>, method: &str, expected: &JsonValue) -> rquickjs::Result<()> {
778    let generator_fn = self.generator.clone();
779    let deadline = tokio::time::Instant::now() + self.timeout;
780    let mut interval_idx = 0;
781    let is_not = self.is_not;
782    let last: JsonValue = loop {
783      let actual: JsonValue = call_generator(ctx, &generator_fn).await?;
784      let pass_raw = deep_equal(&actual, expected);
785      let pass = if is_not { !pass_raw } else { pass_raw };
786      if pass {
787        return Ok(());
788      }
789      let interval_ms = POLL_INTERVALS
790        .get(interval_idx)
791        .copied()
792        .unwrap_or_else(|| POLL_INTERVALS.last().copied().unwrap_or(1000));
793      interval_idx += 1;
794      let sleep_dur = Duration::from_millis(interval_ms);
795      if tokio::time::Instant::now() + sleep_dur > deadline {
796        break actual;
797      }
798      tokio::time::sleep(sleep_dur).await;
799    };
800    Err(assertion_to_rq(AssertionFailure::new(
801      format!(
802        "expect.poll().{method}() timed out after {}ms\n\nExpected: {}\nReceived: {}",
803        self.timeout.as_millis(),
804        ferridriver_expect::asymmetric::json_short(expected),
805        ferridriver_expect::asymmetric::json_short(&last)
806      ),
807      None,
808    )))
809  }
810}
811
812async fn call_generator<'js>(
813  ctx: &Ctx<'js>,
814  generator_fn: &Persistent<Function<'static>>,
815) -> rquickjs::Result<JsonValue> {
816  let f = generator_fn.clone().restore(ctx)?;
817  let result: rquickjs::Value<'js> = f.call(())?;
818  // Await the result if it's a thenable.
819  let result = if let Some(promise) = result.as_promise() {
820    promise.clone().into_future::<rquickjs::Value<'js>>().await?
821  } else {
822    result
823  };
824  serde_from_js(ctx, result)
825}
826
827// ── factory + asymmetric helpers ─────────────────────────────────────
828
829/// Construct an [`ExpectJs`] from any JS value, dispatching on the
830/// runtime type to the appropriate target.
831fn build_expect<'js>(ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<ExpectJs> {
832  if let Ok(class) = Class::<LocatorJs>::from_value(&value) {
833    let loc = class.borrow().inner_ref().clone();
834    return Ok(ExpectJs::new(ExpectTarget::Locator(loc)));
835  }
836  if let Ok(class) = Class::<PageJs>::from_value(&value) {
837    return Ok(ExpectJs::new(ExpectTarget::Page(class.borrow().page_arc())));
838  }
839  if let Ok(class) = Class::<HttpResponseJs>::from_value(&value) {
840    return Ok(ExpectJs::new(ExpectTarget::ApiResponse(class.borrow().inner_clone())));
841  }
842  if value.is_function()
843    && let Some(func) = value.as_function()
844  {
845    let saved = Persistent::save(ctx, func.clone());
846    return Ok(ExpectJs::new(ExpectTarget::Fn(saved)));
847  }
848  let ctor_name = value
849    .as_object()
850    .and_then(|o| o.get::<_, rquickjs::Value<'js>>("constructor").ok())
851    .and_then(|c| {
852      c.as_object()
853        .and_then(|o| o.get::<_, rquickjs::Value<'js>>("name").ok())
854    })
855    .and_then(|n| n.as_string().and_then(|s| s.to_string().ok()))
856    .filter(|s| !s.is_empty());
857  let json: JsonValue = serde_from_js(ctx, value)?;
858  Ok(ExpectJs::new(ExpectTarget::Value { value: json, ctor_name }))
859}
860
861fn make_asymmetric<'js>(ctx: &Ctx<'js>, tag: &str, payload: Object<'js>) -> rquickjs::Result<Object<'js>> {
862  payload.set(ferridriver_expect::ASYM_TAG_KEY, tag)?;
863  let _ = ctx;
864  Ok(payload)
865}
866
867/// Install the `expect` global. Exposes:
868/// - `expect(value | locator | page | apiResponse | fn) -> Expect`
869/// - `expect.poll(fn, opts?) -> ExpectPoll`
870/// - `expect.soft(target) -> Expect` (with `.is_soft` set)
871/// - Asymmetric matchers: `any`, `anything`, `arrayContaining`,
872///   `objectContaining`, `stringContaining`, `stringMatching`,
873///   `closeTo`, plus the `expect.not.*` shorthand.
874pub fn install_expect<'js>(ctx: &Ctx<'js>) -> rquickjs::Result<()> {
875  // Define the class prototype once so `expect(x)` can return
876  // `ExpectJs` instances JS can call methods on.
877  Class::<ExpectJs>::define(&ctx.globals())?;
878  Class::<ExpectPollJs>::define(&ctx.globals())?;
879
880  let expect_fn = Function::new(
881    ctx.clone(),
882    |ctx: Ctx<'js>, value: Value<'js>| -> rquickjs::Result<Value<'js>> {
883      let inst = build_expect(&ctx, value)?;
884      let class = Class::instance(ctx.clone(), inst)?;
885      // Wrap in the JS proxy that translates `.not` (a getter) to
886      // `_notInner()` (the method-bound clone).
887      {
888        let val = class.into_value();
889        install_not_getter(&ctx, &val)?;
890        Ok(val)
891      }
892    },
893  )?;
894  expect_fn.set_name("expect")?;
895
896  // expect.poll(fn, opts?)
897  let poll_fn = Function::new(
898    ctx.clone(),
899    |ctx: Ctx<'js>, generator: Function<'js>, opts: Opt<Value<'js>>| -> rquickjs::Result<Value<'js>> {
900      let timeout_ms = opts
901        .0
902        .as_ref()
903        .and_then(|v| {
904          v.as_object()
905            .and_then(|o| o.get::<_, rquickjs::Value<'js>>("timeout").ok())
906        })
907        .and_then(|v| {
908          v.as_int()
909            .map(|i| u64::try_from(i).unwrap_or(0))
910            .or_else(|| v.as_number().map(|n| n as u64))
911        })
912        .unwrap_or_else(|| DEFAULT_EXPECT_TIMEOUT.as_millis() as u64);
913      let saved = Persistent::save(&ctx, generator);
914      let inst = ExpectPollJs {
915        generator: saved,
916        timeout: Duration::from_millis(timeout_ms),
917        is_not: false,
918      };
919      let class = Class::instance(ctx.clone(), inst)?;
920      {
921        let val = class.into_value();
922        install_poll_not_getter(&ctx, &val)?;
923        Ok(val)
924      }
925    },
926  )?;
927
928  // expect.soft(target) – marks the resulting Expect as soft.
929  let soft_fn = Function::new(
930    ctx.clone(),
931    |ctx: Ctx<'js>, value: Value<'js>| -> rquickjs::Result<Value<'js>> {
932      let mut inst = build_expect(&ctx, value)?;
933      inst.is_soft = true;
934      let class = Class::instance(ctx.clone(), inst)?;
935      {
936        let val = class.into_value();
937        install_not_getter(&ctx, &val)?;
938        Ok(val)
939      }
940    },
941  )?;
942
943  // Asymmetric matcher factories.
944  let any_fn = Function::new(
945    ctx.clone(),
946    |ctx: Ctx<'js>, ctor: Value<'js>| -> rquickjs::Result<Object<'js>> {
947      let name = ctor
948        .as_function()
949        .and_then(|f| f.get::<_, rquickjs::Value<'js>>("name").ok())
950        .and_then(|v| v.as_string().and_then(|s| s.to_string().ok()))
951        .unwrap_or_else(|| "Object".into());
952      let obj = Object::new(ctx.clone())?;
953      obj.set("name", name)?;
954      make_asymmetric(&ctx, "any", obj)
955    },
956  )?;
957  let anything_fn = Function::new(ctx.clone(), |ctx: Ctx<'js>| -> rquickjs::Result<Object<'js>> {
958    make_asymmetric(&ctx, "anything", Object::new(ctx.clone())?)
959  })?;
960  let array_containing_fn = Function::new(
961    ctx.clone(),
962    |ctx: Ctx<'js>, items: Array<'js>| -> rquickjs::Result<Object<'js>> {
963      let obj = Object::new(ctx.clone())?;
964      obj.set("items", items)?;
965      make_asymmetric(&ctx, "arrayContaining", obj)
966    },
967  )?;
968  let object_containing_fn = Function::new(
969    ctx.clone(),
970    |ctx: Ctx<'js>, subset: Object<'js>| -> rquickjs::Result<Object<'js>> {
971      let obj = Object::new(ctx.clone())?;
972      obj.set("subset", subset)?;
973      make_asymmetric(&ctx, "objectContaining", obj)
974    },
975  )?;
976  let string_containing_fn = Function::new(
977    ctx.clone(),
978    |ctx: Ctx<'js>, s: String| -> rquickjs::Result<Object<'js>> {
979      let obj = Object::new(ctx.clone())?;
980      obj.set("substring", s)?;
981      make_asymmetric(&ctx, "stringContaining", obj)
982    },
983  )?;
984  let string_matching_fn = Function::new(
985    ctx.clone(),
986    |ctx: Ctx<'js>, pat: Value<'js>| -> rquickjs::Result<Object<'js>> {
987      let obj = Object::new(ctx.clone())?;
988      if let Some(s) = pat.as_string() {
989        obj.set("substring", s.to_string()?)?;
990      } else if let Some(re_obj) = pat.as_object() {
991        let source = re_obj.get::<_, rquickjs::Value<'js>>("source")?;
992        let flags = re_obj
993          .get::<_, rquickjs::Value<'js>>("flags")
994          .unwrap_or(Value::new_undefined(ctx.clone()));
995        if let Some(s) = source.as_string() {
996          obj.set("regex", s.to_string()?)?;
997        }
998        if let Some(f) = flags.as_string() {
999          obj.set("flags", f.to_string()?)?;
1000        }
1001      } else {
1002        return Err(rquickjs::Error::new_from_js_message(
1003          "expect",
1004          "argument",
1005          "expect.stringMatching expects a string or RegExp",
1006        ));
1007      }
1008      make_asymmetric(&ctx, "stringMatching", obj)
1009    },
1010  )?;
1011  let close_to_fn = Function::new(
1012    ctx.clone(),
1013    |ctx: Ctx<'js>, value: f64, digits: Opt<u8>| -> rquickjs::Result<Object<'js>> {
1014      let obj = Object::new(ctx.clone())?;
1015      obj.set("value", value)?;
1016      obj.set("digits", digits.0.unwrap_or(2))?;
1017      make_asymmetric(&ctx, "closeTo", obj)
1018    },
1019  )?;
1020
1021  // expect.not.<asym>(...) — wraps an asymmetric matcher in a NOT
1022  // tag. Mirrors Jest's `expect.not.objectContaining` etc.
1023  let not_obj = Object::new(ctx.clone())?;
1024  let any_fn_n = any_fn.clone();
1025  let anything_fn_n = anything_fn.clone();
1026  let array_containing_fn_n = array_containing_fn.clone();
1027  let object_containing_fn_n = object_containing_fn.clone();
1028  let string_containing_fn_n = string_containing_fn.clone();
1029  let string_matching_fn_n = string_matching_fn.clone();
1030  let close_to_fn_n = close_to_fn.clone();
1031  install_not_asym(ctx, &not_obj, "any", any_fn_n)?;
1032  install_not_asym(ctx, &not_obj, "anything", anything_fn_n)?;
1033  install_not_asym(ctx, &not_obj, "arrayContaining", array_containing_fn_n)?;
1034  install_not_asym(ctx, &not_obj, "objectContaining", object_containing_fn_n)?;
1035  install_not_asym(ctx, &not_obj, "stringContaining", string_containing_fn_n)?;
1036  install_not_asym(ctx, &not_obj, "stringMatching", string_matching_fn_n)?;
1037  install_not_asym(ctx, &not_obj, "closeTo", close_to_fn_n)?;
1038
1039  // Attach the helpers to expect()'s own properties.
1040  let expect_obj = expect_fn.as_object().ok_or_else(|| {
1041    rquickjs::Error::new_from_js_message("expect", "install", "expect Function has no object representation")
1042  })?;
1043  expect_obj.set("poll", poll_fn)?;
1044  expect_obj.set("soft", soft_fn)?;
1045  expect_obj.set("any", any_fn)?;
1046  expect_obj.set("anything", anything_fn)?;
1047  expect_obj.set("arrayContaining", array_containing_fn)?;
1048  expect_obj.set("objectContaining", object_containing_fn)?;
1049  expect_obj.set("stringContaining", string_containing_fn)?;
1050  expect_obj.set("stringMatching", string_matching_fn)?;
1051  expect_obj.set("closeTo", close_to_fn)?;
1052  expect_obj.set("not", not_obj)?;
1053
1054  ctx.globals().set("expect", expect_fn)?;
1055  Ok(())
1056}
1057
1058fn install_not_asym<'js>(
1059  ctx: &Ctx<'js>,
1060  not_obj: &Object<'js>,
1061  name: &str,
1062  inner: Function<'js>,
1063) -> rquickjs::Result<()> {
1064  let wrapped = Function::new(
1065    ctx.clone(),
1066    move |ctx: Ctx<'js>, args: rquickjs::function::Rest<Value<'js>>| -> rquickjs::Result<Object<'js>> {
1067      let inner_obj: Object<'js> = inner.call((rquickjs::function::Rest(args.0),))?;
1068      let wrapper = Object::new(ctx.clone())?;
1069      wrapper.set("inner", inner_obj)?;
1070      make_asymmetric(&ctx, "not", wrapper)
1071    },
1072  )?;
1073  not_obj.set(name, wrapped)?;
1074  Ok(())
1075}
1076
1077/// Install a `.not` getter directly on the class instance via
1078/// `Object.defineProperty` — avoids a JS `Proxy` wrapper (which would
1079/// break the `#[qjs] fn (&self, ...)` receiver translation when the
1080/// matcher is called) and matches Jest's `.not.toBe(...)` chain shape.
1081fn install_not_getter<'js>(ctx: &Ctx<'js>, instance: &Value<'js>) -> rquickjs::Result<()> {
1082  let object_global: Object<'js> = ctx.globals().get("Object")?;
1083  let define_property: Function<'js> = object_global.get("defineProperty")?;
1084  let inst_clone = instance.clone();
1085  let getter = Function::new(ctx.clone(), move |ctx: Ctx<'js>| -> rquickjs::Result<Value<'js>> {
1086    let class = Class::<ExpectJs>::from_value(&inst_clone)?;
1087    let inverted = class.borrow().not_inner();
1088    let new_class = Class::instance(ctx.clone(), inverted)?;
1089    let new_val = new_class.into_value();
1090    install_not_getter(&ctx, &new_val)?;
1091    Ok(new_val)
1092  })?;
1093  let descriptor = Object::new(ctx.clone())?;
1094  descriptor.set("get", getter)?;
1095  descriptor.set("configurable", true)?;
1096  let _: rquickjs::Value<'js> = define_property.call((instance.clone(), "not", descriptor))?;
1097  Ok(())
1098}
1099
1100fn install_poll_not_getter<'js>(ctx: &Ctx<'js>, instance: &Value<'js>) -> rquickjs::Result<()> {
1101  let object_global: Object<'js> = ctx.globals().get("Object")?;
1102  let define_property: Function<'js> = object_global.get("defineProperty")?;
1103  let inst_clone = instance.clone();
1104  let getter = Function::new(ctx.clone(), move |ctx: Ctx<'js>| -> rquickjs::Result<Value<'js>> {
1105    let class = Class::<ExpectPollJs>::from_value(&inst_clone)?;
1106    let inverted = class.borrow().not_inner();
1107    let new_class = Class::instance(ctx.clone(), inverted)?;
1108    let new_val = new_class.into_value();
1109    install_poll_not_getter(&ctx, &new_val)?;
1110    Ok(new_val)
1111  })?;
1112  let descriptor = Object::new(ctx.clone())?;
1113  descriptor.set("get", getter)?;
1114  descriptor.set("configurable", true)?;
1115  let _: rquickjs::Value<'js> = define_property.call((instance.clone(), "not", descriptor))?;
1116  Ok(())
1117}
1118
1119// Accessor methods used by `build_expect` are defined in each binding
1120// module (`locator.rs::inner_ref`, `page.rs::page_arc`,
1121// `http_client.rs::inner_clone`) so they stay co-located with the
1122// private field they expose.