1use 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#[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 ctor_name: Option<String>,
50 },
51 Locator(Locator),
52 Page(Arc<Page>),
53 ApiResponse(HttpResponse),
54 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 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 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 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 #[qjs(rename = "_notInner")]
248 pub fn not_inner(&self) -> ExpectJs {
249 self.clone_with(|e| e.is_not = !e.is_not)
250 }
251
252 #[qjs(rename = "soft")]
254 pub fn soft(&self) -> ExpectJs {
255 self.clone_with(|e| e.is_soft = true)
256 }
257
258 #[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 #[qjs(rename = "withMessage")]
267 pub fn with_message(&self, msg: String) -> ExpectJs {
268 self.clone_with(|e| e.message = Some(msg))
269 }
270
271 #[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 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 #[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 #[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 #[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 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#[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 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
827fn 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
867pub fn install_expect<'js>(ctx: &Ctx<'js>) -> rquickjs::Result<()> {
875 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 {
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 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 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 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 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, ¬_obj, "any", any_fn_n)?;
1032 install_not_asym(ctx, ¬_obj, "anything", anything_fn_n)?;
1033 install_not_asym(ctx, ¬_obj, "arrayContaining", array_containing_fn_n)?;
1034 install_not_asym(ctx, ¬_obj, "objectContaining", object_containing_fn_n)?;
1035 install_not_asym(ctx, ¬_obj, "stringContaining", string_containing_fn_n)?;
1036 install_not_asym(ctx, ¬_obj, "stringMatching", string_matching_fn_n)?;
1037 install_not_asym(ctx, ¬_obj, "closeTo", close_to_fn_n)?;
1038
1039 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
1077fn 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