1use ferridriver::Locator;
9
10use crate::AssertionFailure;
11use crate::builder::{Expect, HaveCssOptions, InViewportOptions};
12use crate::poll::{ExpectContext, MatchError, poll_until};
13use crate::value::StringOrRegex;
14
15fn locator_ctx(locator: &Locator, method: &'static str, is_not: bool) -> ExpectContext {
16 ExpectContext {
17 method,
18 subject: format!("locator('{}')", locator.selector()),
19 is_not,
20 }
21}
22
23pub fn check_bool(actual: bool, is_not: bool, expected_state: &str) -> Result<(), MatchError> {
24 if actual == is_not {
25 let expected = format!("{}{expected_state}", if is_not { "not " } else { "" });
26 let received = format!("{}{expected_state}", if actual { "" } else { "not " });
27 Err(MatchError::new(expected, received))
28 } else {
29 Ok(())
30 }
31}
32
33pub fn check_text_match(expected: &StringOrRegex, actual: &str, is_not: bool, _label: &str) -> Result<(), MatchError> {
34 let matches = expected.matches(actual);
35 if matches == is_not {
36 let exp = format!("{}{}", if is_not { "not " } else { "" }, expected.description());
37 Err(MatchError::new(exp, format!("\"{actual}\"")))
38 } else {
39 Ok(())
40 }
41}
42
43impl Expect<'_, Locator> {
44 pub async fn to_be_visible(&self) -> Result<(), AssertionFailure> {
47 let locator = self.subject;
48 let is_not = self.is_not;
49 poll_until(
50 self.timeout,
51 locator_ctx(locator, "toBeVisible", is_not),
52 || async move {
53 let visible = locator.is_visible().await.unwrap_or(false);
54 check_bool(visible, is_not, "visible")
55 },
56 )
57 .await
58 }
59
60 pub async fn to_be_hidden(&self) -> Result<(), AssertionFailure> {
61 let locator = self.subject;
62 let is_not = self.is_not;
63 poll_until(
64 self.timeout,
65 locator_ctx(locator, "toBeHidden", is_not),
66 || async move {
67 let hidden = locator.is_hidden().await.unwrap_or(true);
68 check_bool(hidden, is_not, "to be hidden")
69 },
70 )
71 .await
72 }
73
74 pub async fn to_be_enabled(&self) -> Result<(), AssertionFailure> {
75 let locator = self.subject;
76 let is_not = self.is_not;
77 poll_until(
78 self.timeout,
79 locator_ctx(locator, "toBeEnabled", is_not),
80 || async move {
81 let enabled = locator.is_enabled().await.unwrap_or(false);
82 check_bool(enabled, is_not, "to be enabled")
83 },
84 )
85 .await
86 }
87
88 pub async fn to_be_disabled(&self) -> Result<(), AssertionFailure> {
89 let locator = self.subject;
90 let is_not = self.is_not;
91 poll_until(
92 self.timeout,
93 locator_ctx(locator, "toBeDisabled", is_not),
94 || async move {
95 let disabled = locator.is_disabled().await.unwrap_or(false);
96 check_bool(disabled, is_not, "to be disabled")
97 },
98 )
99 .await
100 }
101
102 pub async fn to_be_checked(&self) -> Result<(), AssertionFailure> {
103 let locator = self.subject;
104 let is_not = self.is_not;
105 poll_until(
106 self.timeout,
107 locator_ctx(locator, "toBeChecked", is_not),
108 || async move {
109 let checked = locator.is_checked().await.unwrap_or(false);
110 check_bool(checked, is_not, "to be checked")
111 },
112 )
113 .await
114 }
115
116 pub async fn to_be_editable(&self) -> Result<(), AssertionFailure> {
117 let locator = self.subject;
118 let is_not = self.is_not;
119 poll_until(
120 self.timeout,
121 locator_ctx(locator, "toBeEditable", is_not),
122 || async move {
123 let editable = locator.is_editable().await.unwrap_or(false);
124 check_bool(editable, is_not, "to be editable")
125 },
126 )
127 .await
128 }
129
130 pub async fn to_be_attached(&self) -> Result<(), AssertionFailure> {
131 let locator = self.subject;
132 let is_not = self.is_not;
133 poll_until(
134 self.timeout,
135 locator_ctx(locator, "toBeAttached", is_not),
136 || async move {
137 let attached = locator.is_attached().await.unwrap_or(false);
138 check_bool(attached, is_not, "to be attached")
139 },
140 )
141 .await
142 }
143
144 pub async fn to_be_empty(&self) -> Result<(), AssertionFailure> {
145 let locator = self.subject;
146 let is_not = self.is_not;
147 poll_until(self.timeout, locator_ctx(locator, "toBeEmpty", is_not), || async move {
148 let text = locator.text_content().await.unwrap_or(None).unwrap_or_default();
149 let empty = text.trim().is_empty();
150 if empty == is_not {
151 Err(MatchError::new(
152 format!("{}empty", if is_not { "not " } else { "" }),
153 format!("\"{}\"", text.trim()),
154 ))
155 } else {
156 Ok(())
157 }
158 })
159 .await
160 }
161
162 pub async fn to_be_focused(&self) -> Result<(), AssertionFailure> {
163 let locator = self.subject;
164 let is_not = self.is_not;
165 poll_until(
166 self.timeout,
167 locator_ctx(locator, "toBeFocused", is_not),
168 || async move {
169 let focused = locator
170 .evaluate(
171 "el => document.activeElement === el",
172 ferridriver::protocol::SerializedArgument::default(),
173 None,
174 None,
175 )
176 .await
177 .ok()
178 .and_then(|v| v.as_bool())
179 .unwrap_or(false);
180 check_bool(focused, is_not, "to be focused")
181 },
182 )
183 .await
184 }
185
186 pub async fn to_be_in_viewport(&self) -> Result<(), AssertionFailure> {
187 self.to_be_in_viewport_with(InViewportOptions::default()).await
188 }
189
190 pub async fn to_be_in_viewport_with(&self, options: InViewportOptions) -> Result<(), AssertionFailure> {
191 let locator = self.subject;
192 let is_not = self.is_not;
193 let ratio = options.ratio.unwrap_or(0.0).clamp(0.0, 1.0);
194 poll_until(
195 self.timeout,
196 locator_ctx(locator, "toBeInViewport", is_not),
197 || async move {
198 let js = format!(
199 "el => {{ var r = el.getBoundingClientRect(); \
200 if (r.width === 0 || r.height === 0) return false; \
201 var iw = window.innerWidth, ih = window.innerHeight; \
202 var visW = Math.max(0, Math.min(r.right, iw) - Math.max(r.left, 0)); \
203 var visH = Math.max(0, Math.min(r.bottom, ih) - Math.max(r.top, 0)); \
204 var inter = visW * visH; var area = r.width * r.height; \
205 if (inter <= 0) return false; \
206 return inter / area >= {ratio:.6}; }}"
207 );
208 let in_viewport = locator
209 .evaluate(&js, ferridriver::protocol::SerializedArgument::default(), None, None)
210 .await
211 .ok()
212 .and_then(|v| v.as_bool())
213 .unwrap_or(false);
214 check_bool(in_viewport, is_not, "to be in viewport")
215 },
216 )
217 .await
218 }
219
220 pub async fn to_have_text(&self, expected: impl Into<StringOrRegex>) -> Result<(), AssertionFailure> {
223 let expected = expected.into();
224 let locator = self.subject;
225 let is_not = self.is_not;
226 poll_until(self.timeout, locator_ctx(locator, "toHaveText", is_not), || {
227 let expected = expected.clone();
228 async move {
229 let actual = locator.text_content().await.unwrap_or(None).unwrap_or_default();
230 check_text_match(&expected, actual.trim(), is_not, "text")
231 }
232 })
233 .await
234 }
235
236 pub async fn to_contain_text(&self, expected: impl Into<StringOrRegex>) -> Result<(), AssertionFailure> {
237 let expected = expected.into();
238 let locator = self.subject;
239 let is_not = self.is_not;
240 poll_until(self.timeout, locator_ctx(locator, "toContainText", is_not), || {
241 let expected = expected.clone();
242 async move {
243 let actual = locator.text_content().await.unwrap_or(None).unwrap_or_default();
244 let matches = match &expected {
245 StringOrRegex::String(s) => actual.contains(s.as_str()),
246 StringOrRegex::Regex(re) => re.is_match(&actual),
247 };
248 if matches == is_not {
249 Err(MatchError::new(
250 format!(
251 "{}containing {}",
252 if is_not { "not " } else { "" },
253 expected.description()
254 ),
255 format!("\"{actual}\""),
256 ))
257 } else {
258 Ok(())
259 }
260 }
261 })
262 .await
263 }
264
265 pub async fn to_have_value(&self, expected: impl Into<StringOrRegex>) -> Result<(), AssertionFailure> {
266 let expected = expected.into();
267 let locator = self.subject;
268 let is_not = self.is_not;
269 poll_until(self.timeout, locator_ctx(locator, "toHaveValue", is_not), || {
270 let expected = expected.clone();
271 async move {
272 let actual = locator.input_value().await.unwrap_or_default();
273 check_text_match(&expected, &actual, is_not, "value")
274 }
275 })
276 .await
277 }
278
279 pub async fn to_have_values(&self, expected: &[impl AsRef<str>]) -> Result<(), AssertionFailure> {
280 let expected: Vec<String> = expected.iter().map(|s| s.as_ref().to_string()).collect();
281 let locator = self.subject;
282 let is_not = self.is_not;
283 poll_until(self.timeout, locator_ctx(locator, "toHaveValues", is_not), || {
284 let expected = expected.clone();
285 async move {
286 let actual = locator
287 .evaluate(
288 "el => Array.from(el.selectedOptions).map(function(o) { return o.value; })",
289 ferridriver::protocol::SerializedArgument::default(),
290 None,
291 None,
292 )
293 .await
294 .ok()
295 .and_then(|v| {
296 v.as_array().map(|arr| {
297 arr
298 .iter()
299 .filter_map(|v| v.as_str().map(String::from))
300 .collect::<Vec<_>>()
301 })
302 })
303 .unwrap_or_default();
304 let matches = actual == expected;
305 if matches == is_not {
306 Err(MatchError::new(
307 format!("{}{expected:?}", if is_not { "not " } else { "" }),
308 format!("{actual:?}"),
309 ))
310 } else {
311 Ok(())
312 }
313 }
314 })
315 .await
316 }
317
318 pub async fn to_have_attribute(&self, name: &str, value: impl Into<StringOrRegex>) -> Result<(), AssertionFailure> {
321 let expected = value.into();
322 let locator = self.subject;
323 let is_not = self.is_not;
324 let attr_name = name.to_string();
325 poll_until(self.timeout, locator_ctx(locator, "toHaveAttribute", is_not), || {
326 let expected = expected.clone();
327 let attr_name = attr_name.clone();
328 async move {
329 let actual = locator
330 .get_attribute(&attr_name)
331 .await
332 .unwrap_or(None)
333 .unwrap_or_default();
334 check_text_match(&expected, &actual, is_not, &format!("attribute \"{attr_name}\""))
335 }
336 })
337 .await
338 }
339
340 pub async fn to_have_attribute_exists(&self, name: &str) -> Result<(), AssertionFailure> {
341 let locator = self.subject;
342 let is_not = self.is_not;
343 let attr_name = name.to_string();
344 poll_until(self.timeout, locator_ctx(locator, "toHaveAttribute", is_not), || {
345 let attr_name = attr_name.clone();
346 async move {
347 let present = locator.get_attribute(&attr_name).await.unwrap_or(None).is_some();
348 if present == is_not {
349 Err(MatchError::new(
350 format!(
351 "{}attribute \"{attr_name}\" to be present",
352 if is_not { "not " } else { "" }
353 ),
354 (if present { "present" } else { "missing" }).to_string(),
355 ))
356 } else {
357 Ok(())
358 }
359 }
360 })
361 .await
362 }
363
364 pub async fn to_have_class(&self, expected: impl Into<StringOrRegex>) -> Result<(), AssertionFailure> {
365 let expected = expected.into();
366 let locator = self.subject;
367 let is_not = self.is_not;
368 poll_until(self.timeout, locator_ctx(locator, "toHaveClass", is_not), || {
369 let expected = expected.clone();
370 async move {
371 let actual = locator.get_attribute("class").await.unwrap_or(None).unwrap_or_default();
372 check_text_match(&expected, &actual, is_not, "class")
373 }
374 })
375 .await
376 }
377
378 pub async fn to_contain_class(&self, expected: &str) -> Result<(), AssertionFailure> {
379 let expected = expected.to_string();
380 let locator = self.subject;
381 let is_not = self.is_not;
382 poll_until(self.timeout, locator_ctx(locator, "toContainClass", is_not), || {
383 let expected = expected.clone();
384 async move {
385 let class_attr = locator.get_attribute("class").await.unwrap_or(None).unwrap_or_default();
386 let classes: Vec<&str> = class_attr.split_whitespace().collect();
387 let contains = classes.iter().any(|c| *c == expected);
388 if contains == is_not {
389 Err(MatchError::new(
390 format!("{}containing class \"{expected}\"", if is_not { "not " } else { "" }),
391 format!("\"{class_attr}\""),
392 ))
393 } else {
394 Ok(())
395 }
396 }
397 })
398 .await
399 }
400
401 pub async fn to_have_css(&self, property: &str, value: impl Into<StringOrRegex>) -> Result<(), AssertionFailure> {
402 self.to_have_css_with(property, value, HaveCssOptions::default()).await
403 }
404
405 pub async fn to_have_css_with(
406 &self,
407 property: &str,
408 value: impl Into<StringOrRegex>,
409 options: HaveCssOptions,
410 ) -> Result<(), AssertionFailure> {
411 let expected = value.into();
412 let locator = self.subject;
413 let is_not = self.is_not;
414 let prop = property.to_string();
415 let pseudo = options.pseudo.clone();
416 poll_until(self.timeout, locator_ctx(locator, "toHaveCSS", is_not), || {
417 let expected = expected.clone();
418 let prop = prop.clone();
419 let pseudo = pseudo.clone();
420 async move {
421 let pseudo_arg = pseudo
422 .as_deref()
423 .map(|p| format!(", '{}'", p.replace('\'', "\\'")))
424 .unwrap_or_default();
425 let js = format!(
426 "el => window.getComputedStyle(el{pseudo_arg}).getPropertyValue('{}')",
427 prop.replace('\'', "\\'")
428 );
429 let actual = locator
430 .evaluate(&js, ferridriver::protocol::SerializedArgument::default(), None, None)
431 .await
432 .ok()
433 .and_then(|v| v.as_str().map(String::from))
434 .unwrap_or_default();
435 check_text_match(&expected, &actual, is_not, &format!("CSS \"{prop}\""))
436 }
437 })
438 .await
439 }
440
441 pub async fn to_have_id(&self, expected: impl Into<StringOrRegex>) -> Result<(), AssertionFailure> {
442 self.to_have_attribute("id", expected).await
443 }
444
445 pub async fn to_have_role(&self, expected: impl Into<StringOrRegex>) -> Result<(), AssertionFailure> {
446 let expected = expected.into();
447 let locator = self.subject;
448 let is_not = self.is_not;
449 poll_until(self.timeout, locator_ctx(locator, "toHaveRole", is_not), || {
450 let expected = expected.clone();
451 async move {
452 let actual = locator
453 .evaluate(
454 "el => el.getAttribute('role') || el.tagName.toLowerCase()",
455 ferridriver::protocol::SerializedArgument::default(),
456 None,
457 None,
458 )
459 .await
460 .ok()
461 .and_then(|v| v.as_str().map(String::from))
462 .unwrap_or_default();
463 check_text_match(&expected, &actual, is_not, "role")
464 }
465 })
466 .await
467 }
468
469 pub async fn to_have_accessible_name(&self, expected: impl Into<StringOrRegex>) -> Result<(), AssertionFailure> {
470 let expected = expected.into();
471 let locator = self.subject;
472 let is_not = self.is_not;
473 poll_until(
474 self.timeout,
475 locator_ctx(locator, "toHaveAccessibleName", is_not),
476 || {
477 let expected = expected.clone();
478 async move {
479 let actual = locator
480 .evaluate(
481 "el => { \
482 var label = el.getAttribute('aria-label') || \
483 (el.getAttribute('aria-labelledby') ? \
484 (document.getElementById(el.getAttribute('aria-labelledby')) || {}).textContent : null) || \
485 (el.labels && el.labels[0] ? el.labels[0].textContent : null) || ''; \
486 return label.trim(); \
487 }",
488 ferridriver::protocol::SerializedArgument::default(),
489 None,
490 None,
491 )
492 .await
493 .ok()
494 .and_then(|v| v.as_str().map(String::from))
495 .unwrap_or_default();
496 check_text_match(&expected, &actual, is_not, "accessible name")
497 }
498 },
499 )
500 .await
501 }
502
503 pub async fn to_have_accessible_description(
504 &self,
505 expected: impl Into<StringOrRegex>,
506 ) -> Result<(), AssertionFailure> {
507 let expected = expected.into();
508 let locator = self.subject;
509 let is_not = self.is_not;
510 poll_until(
511 self.timeout,
512 locator_ctx(locator, "toHaveAccessibleDescription", is_not),
513 || {
514 let expected = expected.clone();
515 async move {
516 let actual = locator
517 .evaluate(
518 "el => { \
519 var desc = el.getAttribute('aria-description') || \
520 (el.getAttribute('aria-describedby') ? \
521 (document.getElementById(el.getAttribute('aria-describedby')) || {}).textContent : null) || ''; \
522 return desc.trim(); \
523 }",
524 ferridriver::protocol::SerializedArgument::default(),
525 None,
526 None,
527 )
528 .await
529 .ok()
530 .and_then(|v| v.as_str().map(String::from))
531 .unwrap_or_default();
532 check_text_match(&expected, &actual, is_not, "accessible description")
533 }
534 },
535 )
536 .await
537 }
538
539 pub async fn to_have_accessible_error_message(
540 &self,
541 expected: impl Into<StringOrRegex>,
542 ) -> Result<(), AssertionFailure> {
543 let expected = expected.into();
544 let locator = self.subject;
545 let is_not = self.is_not;
546 poll_until(
547 self.timeout,
548 locator_ctx(locator, "toHaveAccessibleErrorMessage", is_not),
549 || {
550 let expected = expected.clone();
551 async move {
552 let actual = locator
553 .evaluate(
554 "el => { \
555 var errId = el.getAttribute('aria-errormessage'); \
556 if (errId) { \
557 var errEl = document.getElementById(errId); \
558 return errEl ? errEl.textContent.trim() : ''; \
559 } \
560 return el.validationMessage || ''; \
561 }",
562 ferridriver::protocol::SerializedArgument::default(),
563 None,
564 None,
565 )
566 .await
567 .ok()
568 .and_then(|v| v.as_str().map(String::from))
569 .unwrap_or_default();
570 check_text_match(&expected, &actual, is_not, "accessible error message")
571 }
572 },
573 )
574 .await
575 }
576
577 pub async fn to_have_js_property(&self, name: &str, value: serde_json::Value) -> Result<(), AssertionFailure> {
578 let locator = self.subject;
579 let is_not = self.is_not;
580 let prop_name = name.to_string();
581 poll_until(self.timeout, locator_ctx(locator, "toHaveJSProperty", is_not), || {
582 let prop_name = prop_name.clone();
583 let expected = value.clone();
584 async move {
585 let js = format!("el => JSON.stringify(el['{}'])", prop_name.replace('\'', "\\'"));
586 let actual = locator
587 .evaluate(&js, ferridriver::protocol::SerializedArgument::default(), None, None)
588 .await
589 .ok()
590 .and_then(|v| {
591 v.as_str()
592 .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
593 })
594 .unwrap_or(serde_json::Value::Null);
595 let matches = actual == expected;
596 if matches == is_not {
597 Err(MatchError::new(
598 format!("{}{expected}", if is_not { "not " } else { "" }),
599 format!("{actual}"),
600 ))
601 } else {
602 Ok(())
603 }
604 }
605 })
606 .await
607 }
608
609 pub async fn to_have_texts(&self, expected: &[impl Into<StringOrRegex> + Clone]) -> Result<(), AssertionFailure> {
612 let expected: Vec<StringOrRegex> = expected.iter().map(|e| e.clone().into()).collect();
613 let locator = self.subject;
614 let is_not = self.is_not;
615 poll_until(self.timeout, locator_ctx(locator, "toHaveTexts", is_not), || {
616 let expected = expected.clone();
617 async move {
618 let count = locator.count().await.unwrap_or(0);
619 let mut actuals = Vec::with_capacity(count);
620 for i in 0..count {
621 let text = locator
622 .evaluate(
623 &format!(
624 "() => document.querySelectorAll('{}')[{i}]?.textContent?.trim() || ''",
625 locator.selector().replace('\'', "\\'")
626 ),
627 ferridriver::protocol::SerializedArgument::default(),
628 None,
629 None,
630 )
631 .await
632 .ok()
633 .and_then(|v| v.as_str().map(String::from))
634 .unwrap_or_default();
635 actuals.push(text);
636 }
637
638 if actuals.len() != expected.len() {
639 let matches = false;
640 if matches == is_not {
641 return Ok(());
642 }
643 return Err(MatchError::new(
644 format!(
645 "{} texts: {:?}",
646 expected.len(),
647 expected.iter().map(|e| e.description()).collect::<Vec<_>>()
648 ),
649 format!("{} texts: {actuals:?}", actuals.len()),
650 ));
651 }
652
653 for (i, (exp, act)) in expected.iter().zip(actuals.iter()).enumerate() {
654 let matches = exp.matches(act);
655 if matches == is_not {
656 return Err(MatchError::new(
657 format!("{}[{i}] = {}", if is_not { "not " } else { "" }, exp.description()),
658 format!("[{i}] = \"{act}\""),
659 ));
660 }
661 }
662 Ok(())
663 }
664 })
665 .await
666 }
667
668 pub async fn to_contain_texts(&self, expected: &[impl AsRef<str>]) -> Result<(), AssertionFailure> {
669 let expected: Vec<String> = expected.iter().map(|s| s.as_ref().to_string()).collect();
670 let locator = self.subject;
671 let is_not = self.is_not;
672 poll_until(self.timeout, locator_ctx(locator, "toContainTexts", is_not), || {
673 let expected = expected.clone();
674 async move {
675 let count = locator.count().await.unwrap_or(0);
676 let mut actuals = Vec::with_capacity(count);
677 for i in 0..count {
678 let text = locator
679 .evaluate(
680 &format!(
681 "() => document.querySelectorAll('{}')[{i}]?.textContent?.trim() || ''",
682 locator.selector().replace('\'', "\\'")
683 ),
684 ferridriver::protocol::SerializedArgument::default(),
685 None,
686 None,
687 )
688 .await
689 .ok()
690 .and_then(|v| v.as_str().map(String::from))
691 .unwrap_or_default();
692 actuals.push(text);
693 }
694
695 if actuals.len() != expected.len() {
696 if is_not {
697 return Ok(());
698 }
699 return Err(MatchError::new(
700 format!("{} texts", expected.len()),
701 format!("{} texts", actuals.len()),
702 ));
703 }
704
705 for (i, (exp, act)) in expected.iter().zip(actuals.iter()).enumerate() {
706 let contains = act.contains(exp.as_str());
707 if contains == is_not {
708 return Err(MatchError::new(
709 format!("{}[{i}] containing \"{exp}\"", if is_not { "not " } else { "" }),
710 format!("[{i}] = \"{act}\""),
711 ));
712 }
713 }
714 Ok(())
715 }
716 })
717 .await
718 }
719
720 pub async fn to_have_count(&self, expected: usize) -> Result<(), AssertionFailure> {
723 let locator = self.subject;
724 let is_not = self.is_not;
725 poll_until(
726 self.timeout,
727 locator_ctx(locator, "toHaveCount", is_not),
728 || async move {
729 let actual = locator.count().await.unwrap_or(0);
730 let matches = actual == expected;
731 if matches == is_not {
732 Err(MatchError::new(
733 format!("{}{expected}", if is_not { "not " } else { "" }),
734 format!("{actual}"),
735 ))
736 } else {
737 Ok(())
738 }
739 },
740 )
741 .await
742 }
743}