#![cfg(wasm)]
use wasm_bindgen::JsCast;
use web_sys::{Document, Element, NodeList, window};
pub(super) use reinhardt_core::security::escape_css_selector;
#[derive(Debug, Clone)]
pub struct QueryResult {
elements: Vec<Element>,
query_description: String,
}
impl QueryResult {
pub fn new(elements: Vec<Element>, description: impl Into<String>) -> Self {
Self {
elements,
query_description: description.into(),
}
}
pub fn empty(description: impl Into<String>) -> Self {
Self {
elements: Vec::new(),
query_description: description.into(),
}
}
pub fn get(&self) -> Element {
self.elements
.first()
.cloned()
.unwrap_or_else(|| panic!("No element found for query: {}", self.query_description))
}
pub fn query(&self) -> Option<Element> {
self.elements.first().cloned()
}
pub fn get_all(&self) -> Vec<Element> {
self.elements.clone()
}
pub fn count(&self) -> usize {
self.elements.len()
}
pub fn exists(&self) -> bool {
!self.elements.is_empty()
}
pub fn get_only(&self) -> Element {
match self.elements.len() {
0 => panic!("No element found for query: {}", self.query_description),
1 => self.elements[0].clone(),
n => panic!(
"Expected exactly one element for query '{}', but found {}",
self.query_description, n
),
}
}
pub fn should_exist(&self) {
if self.elements.is_empty() {
panic!(
"Expected element to exist for query: {}",
self.query_description
);
}
}
pub fn should_not_exist(&self) {
if !self.elements.is_empty() {
panic!(
"Expected no elements for query '{}', but found {}",
self.query_description,
self.elements.len()
);
}
}
pub fn description(&self) -> &str {
&self.query_description
}
}
#[derive(Debug, Clone)]
pub struct Screen {
root: Option<Element>,
}
impl Default for Screen {
fn default() -> Self {
Self::new()
}
}
impl Screen {
pub fn new() -> Self {
Self { root: None }
}
pub fn within(element: &Element) -> Self {
Self {
root: Some(element.clone()),
}
}
fn document(&self) -> Option<Document> {
window().and_then(|w| w.document())
}
fn query_root(&self) -> Option<Element> {
if let Some(ref root) = self.root {
Some(root.clone())
} else {
self.document().and_then(|d| d.body()).map(|b| b.into())
}
}
fn query_selector_all(&self, selector: &str) -> Vec<Element> {
let root = match self.query_root() {
Some(r) => r,
None => return Vec::new(),
};
root.query_selector_all(selector)
.ok()
.map(|list| node_list_to_elements(&list))
.unwrap_or_default()
}
pub fn get_by_role(&self, role: &str) -> QueryResult {
let selector = format!("[role=\"{}\"]", escape_css_selector(role));
let mut elements = self.query_selector_all(&selector);
let implicit_elements = self.get_elements_with_implicit_role(role);
for elem in implicit_elements {
if !elements.iter().any(|e| e == &elem) {
elements.push(elem);
}
}
QueryResult::new(elements, format!("role=\"{}\"", role))
}
pub fn get_by_role_with_name(&self, role: &str, name: &str) -> QueryResult {
let all_with_role = self.get_by_role(role);
let name_lower = name.to_lowercase();
let filtered: Vec<Element> = all_with_role
.elements
.into_iter()
.filter(|elem| {
let accessible_name = get_accessible_name(elem).to_lowercase();
accessible_name.contains(&name_lower)
})
.collect();
QueryResult::new(filtered, format!("role=\"{}\" name=\"{}\"", role, name))
}
fn get_elements_with_implicit_role(&self, role: &str) -> Vec<Element> {
let tags = match role {
"button" => vec!["button", "input[type=\"button\"]", "input[type=\"submit\"]"],
"textbox" => vec!["input[type=\"text\"]", "input:not([type])", "textarea"],
"checkbox" => vec!["input[type=\"checkbox\"]"],
"radio" => vec!["input[type=\"radio\"]"],
"link" => vec!["a[href]"],
"heading" => vec!["h1", "h2", "h3", "h4", "h5", "h6"],
"list" => vec!["ul", "ol"],
"listitem" => vec!["li"],
"img" => vec!["img"],
"navigation" => vec!["nav"],
"main" => vec!["main"],
"banner" => vec!["header"],
"contentinfo" => vec!["footer"],
"form" => vec!["form"],
"search" => vec!["search"],
"article" => vec!["article"],
"region" => vec!["section[aria-label]", "section[aria-labelledby]"],
"combobox" => vec!["select"],
"option" => vec!["option"],
_ => vec![],
};
let mut results = Vec::new();
for tag_selector in tags {
results.extend(self.query_selector_all(tag_selector));
}
results
}
pub fn get_by_text(&self, text: &str) -> QueryResult {
let root = match self.query_root() {
Some(r) => r,
None => return QueryResult::empty(format!("text=\"{}\"", text)),
};
let text_lower = text.to_lowercase();
let elements = find_elements_with_text(&root, &text_lower);
QueryResult::new(elements, format!("text=\"{}\"", text))
}
pub fn get_by_text_regex(&self, pattern: &str) -> QueryResult {
let root = match self.query_root() {
Some(r) => r,
None => return QueryResult::empty(format!("text_regex=\"{}\"", pattern)),
};
let re = match regex::Regex::new(pattern) {
Ok(r) => r,
Err(_) => return QueryResult::empty(format!("text_regex=\"{}\" (invalid)", pattern)),
};
let elements = find_elements_matching_regex(&root, &re);
QueryResult::new(elements, format!("text_regex=\"{}\"", pattern))
}
pub fn get_by_label_text(&self, label: &str) -> QueryResult {
let label_lower = label.to_lowercase();
let mut elements = Vec::new();
let labels = self.query_selector_all("label");
for label_elem in labels {
let label_text = label_elem.text_content().unwrap_or_default().to_lowercase();
if label_text.contains(&label_lower) {
if let Some(for_id) = label_elem.get_attribute("for") {
if let Some(doc) = self.document() {
if let Some(target) = doc.get_element_by_id(&for_id) {
elements.push(target);
}
}
}
if let Ok(Some(nested)) = label_elem.query_selector("input, select, textarea") {
if !elements.contains(&nested) {
elements.push(nested);
}
}
}
}
let aria_labeled = self.query_selector_all(&format!(
"[aria-label*=\"{}\" i]",
escape_css_selector(label)
));
for elem in aria_labeled {
if !elements.contains(&elem) {
elements.push(elem);
}
}
QueryResult::new(elements, format!("label=\"{}\"", label))
}
pub fn get_by_placeholder_text(&self, placeholder: &str) -> QueryResult {
let selector = format!("[placeholder*=\"{}\" i]", escape_css_selector(placeholder));
let elements = self.query_selector_all(&selector);
QueryResult::new(elements, format!("placeholder=\"{}\"", placeholder))
}
pub fn get_by_test_id(&self, test_id: &str) -> QueryResult {
let selector = format!("[data-testid=\"{}\"]", escape_css_selector(test_id));
let elements = self.query_selector_all(&selector);
QueryResult::new(elements, format!("data-testid=\"{}\"", test_id))
}
pub fn get_by_alt_text(&self, alt: &str) -> QueryResult {
let selector = format!("[alt*=\"{}\" i]", escape_css_selector(alt));
let elements = self.query_selector_all(&selector);
QueryResult::new(elements, format!("alt=\"{}\"", alt))
}
pub fn get_by_title(&self, title: &str) -> QueryResult {
let selector = format!("[title*=\"{}\" i]", escape_css_selector(title));
let elements = self.query_selector_all(&selector);
QueryResult::new(elements, format!("title=\"{}\"", title))
}
pub fn query_selector(&self, selector: &str) -> QueryResult {
let elements = self.query_selector_all(selector);
QueryResult::new(elements, format!("selector=\"{}\"", selector))
}
pub async fn find_by_role(&self, role: &str) -> QueryResult {
self.find_by_role_timeout(role, 1000).await
}
pub async fn find_by_role_timeout(&self, role: &str, timeout_ms: u32) -> QueryResult {
wait_for_query(|| self.get_by_role(role), timeout_ms).await
}
pub async fn find_by_text(&self, text: &str) -> QueryResult {
self.find_by_text_timeout(text, 1000).await
}
pub async fn find_by_text_timeout(&self, text: &str, timeout_ms: u32) -> QueryResult {
let text_owned = text.to_string();
wait_for_query(|| self.get_by_text(&text_owned), timeout_ms).await
}
pub async fn find_by_label_text(&self, label: &str) -> QueryResult {
self.find_by_label_text_timeout(label, 1000).await
}
pub async fn find_by_label_text_timeout(&self, label: &str, timeout_ms: u32) -> QueryResult {
let label_owned = label.to_string();
wait_for_query(|| self.get_by_label_text(&label_owned), timeout_ms).await
}
pub async fn find_by_test_id(&self, test_id: &str) -> QueryResult {
self.find_by_test_id_timeout(test_id, 1000).await
}
pub async fn find_by_test_id_timeout(&self, test_id: &str, timeout_ms: u32) -> QueryResult {
let test_id_owned = test_id.to_string();
wait_for_query(|| self.get_by_test_id(&test_id_owned), timeout_ms).await
}
}
fn node_list_to_elements(list: &NodeList) -> Vec<Element> {
let mut elements = Vec::new();
for i in 0..list.length() {
if let Some(node) = list.get(i) {
if let Ok(elem) = node.dyn_into::<Element>() {
elements.push(elem);
}
}
}
elements
}
fn get_accessible_name(element: &Element) -> String {
if let Some(label) = element.get_attribute("aria-label") {
return label;
}
if let Some(labelledby) = element.get_attribute("aria-labelledby") {
if let Some(window) = window() {
if let Some(doc) = window.document() {
let mut names = Vec::new();
for id in labelledby.split_whitespace() {
if let Some(label_elem) = doc.get_element_by_id(id) {
if let Some(text) = label_elem.text_content() {
names.push(text);
}
}
}
if !names.is_empty() {
return names.join(" ");
}
}
}
}
element.text_content().unwrap_or_default()
}
fn find_elements_with_text(root: &Element, text_lower: &str) -> Vec<Element> {
let mut results = Vec::new();
find_text_recursive(root, text_lower, &mut results);
results
}
fn find_text_recursive(element: &Element, text_lower: &str, results: &mut Vec<Element>) {
let element_text = element.text_content().unwrap_or_default().to_lowercase();
if element_text.contains(text_lower) {
let children = element.children();
let mut child_has_text = false;
for i in 0..children.length() {
if let Some(child) = children.get_with_index(i) {
let child_text = child.text_content().unwrap_or_default().to_lowercase();
if child_text.contains(text_lower) {
child_has_text = true;
find_text_recursive(&child, text_lower, results);
}
}
}
if !child_has_text {
results.push(element.clone());
}
}
}
fn find_elements_matching_regex(root: &Element, re: ®ex::Regex) -> Vec<Element> {
let mut results = Vec::new();
find_regex_recursive(root, re, &mut results);
results
}
fn find_regex_recursive(element: &Element, re: ®ex::Regex, results: &mut Vec<Element>) {
let element_text = element.text_content().unwrap_or_default();
if re.is_match(&element_text) {
let children = element.children();
let mut child_matches = false;
for i in 0..children.length() {
if let Some(child) = children.get_with_index(i) {
let child_text = child.text_content().unwrap_or_default();
if re.is_match(&child_text) {
child_matches = true;
find_regex_recursive(&child, re, results);
}
}
}
if !child_matches {
results.push(element.clone());
}
}
}
async fn wait_for_query<F>(query_fn: F, timeout_ms: u32) -> QueryResult
where
F: Fn() -> QueryResult,
{
use gloo_timers::future::TimeoutFuture;
let interval_ms = 50u32;
let max_attempts = timeout_ms / interval_ms;
for _ in 0..max_attempts {
let result = query_fn();
if result.exists() {
return result;
}
TimeoutFuture::new(interval_ms).await;
}
query_fn()
}
pub fn screen() -> Screen {
Screen::new()
}
pub fn scoped_screen(root: Element) -> Screen {
Screen::within(&root)
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
fn query_result_empty_has_zero_count_and_no_existence() {
let result = QueryResult::empty("test");
assert!(!result.exists());
assert_eq!(result.count(), 0);
}
#[rstest]
fn query_result_description_returns_stored_description() {
let result = QueryResult::empty("role=\"button\"");
assert_eq!(result.description(), "role=\"button\"");
}
#[rstest]
#[should_panic(expected = "No element found")]
fn query_result_get_panics_when_empty() {
let result = QueryResult::empty("test");
result.get();
}
#[rstest]
fn query_result_query_returns_none_when_empty() {
let result = QueryResult::empty("test");
let queried = result.query();
assert!(queried.is_none());
}
#[rstest]
fn screen_default_has_no_root() {
let screen = Screen::default();
assert!(screen.root.is_none());
}
#[rstest]
fn escape_css_selector_no_special_chars() {
assert_eq!(escape_css_selector("button"), "button");
}
#[rstest]
fn escape_css_selector_with_metacharacters() {
assert_eq!(
escape_css_selector("a\"] , body *{display:none}"),
r#"a\"\] \, body \*\{display\:none\}"#
);
}
#[rstest]
fn escape_css_selector_quotes() {
assert_eq!(
escape_css_selector(r#"it's "quoted""#),
r#"it\'s \"quoted\""#
);
}
#[rstest]
fn should_not_exist_succeeds_for_empty_result() {
let result = QueryResult::empty("nonexistent");
result.should_not_exist();
}
#[rstest]
#[should_panic(expected = "Expected element to exist")]
fn should_exist_panics_for_empty_result() {
let result = QueryResult::empty("missing");
result.should_exist();
}
#[rstest]
fn get_all_returns_empty_vec_for_empty_result() {
let result = QueryResult::empty("nothing");
let all = result.get_all();
assert!(all.is_empty());
}
#[rstest]
#[should_panic(expected = "No element found")]
fn get_only_panics_for_empty_result() {
let result = QueryResult::empty("single");
result.get_only();
}
#[rstest]
fn screen_new_is_equivalent_to_default() {
let from_new = Screen::new();
let from_default = Screen::default();
assert_eq!(from_new.root.is_none(), from_default.root.is_none());
}
}