Skip to main content

ferridriver_expect/
locator.rs

1//! Locator web-first matchers — single source of truth shared by the
2//! test runner (`ferridriver-test`) and the QuickJS binding
3//! (`ferridriver-script`). Screenshot / aria-snapshot / value-snapshot
4//! matchers stay in `ferridriver-test` because they pull in image and
5//! aria-YAML infrastructure that does not belong in this lightweight
6//! crate.
7
8use 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  // ── Visibility / State ──
45
46  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  // ── Text / Value ──
221
222  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  // ── Attributes ──
319
320  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  // ── Array text matchers ──
610
611  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  // ── Count ──
721
722  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}