1use anyhow::{anyhow, Result};
2use async_trait::async_trait;
3use scraper::{Html, Selector};
4use std::collections::{HashMap, HashSet};
5use std::sync::Arc;
6
7pub mod transports;
8
9#[derive(Debug, Clone, PartialEq)]
11pub enum Method {
12 Get,
13 Post,
14}
15
16#[derive(Debug, Clone)]
18pub struct HttpRequest {
19 pub method: Method,
20 pub url: String,
21 pub headers: HashMap<String, String>,
22 pub body: Option<String>,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq)]
27pub struct StatusCode(pub u16);
28
29impl StatusCode {
30 pub fn is_success(&self) -> bool {
31 self.0 >= 200 && self.0 < 300
32 }
33}
34
35#[derive(Debug, Clone)]
37pub struct HttpResponse {
38 pub status: StatusCode,
39 pub headers: HashMap<String, String>,
40 pub body: String,
41}
42
43#[async_trait]
45pub trait HttpTransport: Send + Sync {
46 async fn send(&self, req: HttpRequest) -> Result<HttpResponse>;
47}
48
49pub struct Dom<T: HttpTransport> {
51 transport: Arc<T>,
52 html: String,
53}
54
55impl<T: HttpTransport> Dom<T> {
56 pub fn new(transport: T) -> Self {
58 Self {
59 transport: Arc::new(transport),
60 html: String::new(),
61 }
62 }
63
64 pub fn parse(mut self, html: String) -> Result<Self> {
66 self.html = html;
67 Ok(self)
68 }
69
70 pub fn form(&self, locator: &str) -> Result<Form<T>> {
72 Form::find(&self.html, locator, Arc::clone(&self.transport))
73 }
74
75 pub fn button(&self, locator: &str) -> Result<Button<T>> {
77 Button::find(&self.html, locator, Arc::clone(&self.transport))
78 }
79
80 pub fn link(&self, locator: &str) -> Result<Link<T>> {
82 Link::find(&self.html, locator, Arc::clone(&self.transport))
83 }
84
85 pub fn element(&self, locator: &str) -> Result<Element> {
87 Element::find(&self.html, locator)
88 }
89
90 pub fn elements(&self, locator: &str) -> Vec<Element> {
92 Element::find_all(&self.html, locator)
93 }
94
95 pub fn text(&self, locator: &str) -> Result<String> {
97 let element = self.element(locator)?;
98 Ok(element.text())
99 }
100
101 pub fn texts(&self, locator: &str) -> Vec<String> {
103 self.elements(locator).iter().map(|e| e.text()).collect()
104 }
105
106 pub fn inner_html(&self, locator: &str) -> Result<String> {
108 let element = self.element(locator)?;
109 Ok(element.inner_html())
110 }
111
112 pub fn table(&self, locator: &str) -> Result<Table> {
114 Table::find(&self.html, locator)
115 }
116
117 pub fn list(&self, locator: &str) -> Result<List> {
119 List::find(&self.html, locator)
120 }
121
122 pub fn exists(&self, locator: &str) -> bool {
124 self.element(locator).is_ok()
125 }
126
127 pub fn contains_text(&self, text: &str) -> bool {
129 let document = Html::parse_document(&self.html);
130 let body_text: String = document.root_element().text().collect();
131 body_text.contains(text)
132 }
133
134 pub fn title(&self) -> Result<String> {
136 let document = Html::parse_document(&self.html);
137 let title_selector = Selector::parse("title").unwrap();
138 let title_element = document
139 .select(&title_selector)
140 .next()
141 .ok_or_else(|| anyhow!("Title tag not found"))?;
142 Ok(title_element.text().collect::<String>().trim().to_string())
143 }
144
145 pub fn meta(&self, name: &str) -> Result<String> {
147 let document = Html::parse_document(&self.html);
148
149 let name_selector = Selector::parse(&format!("meta[name=\"{}\"]", name)).unwrap();
151 if let Some(meta_element) = document.select(&name_selector).next() {
152 return meta_element
153 .value()
154 .attr("content")
155 .ok_or_else(|| anyhow!("Meta tag '{}' has no content attribute", name))
156 .map(|s| s.to_string());
157 }
158
159 let property_selector = Selector::parse(&format!("meta[property=\"{}\"]", name)).unwrap();
161 if let Some(meta_element) = document.select(&property_selector).next() {
162 return meta_element
163 .value()
164 .attr("content")
165 .ok_or_else(|| anyhow!("Meta tag '{}' has no content attribute", name))
166 .map(|s| s.to_string());
167 }
168
169 Err(anyhow!("Meta tag '{}' not found", name))
170 }
171
172 pub fn image(&self, locator: &str) -> Result<Image> {
174 Image::find(&self.html, locator)
175 }
176
177 pub fn images(&self, locator: &str) -> Vec<Image> {
179 Image::find_all(&self.html, locator)
180 }
181
182 pub fn select_element(&self, locator: &str) -> Result<SelectElement> {
184 SelectElement::find(&self.html, locator)
185 }
186}
187
188#[derive(Debug)]
190pub struct Form<T: HttpTransport> {
191 action: String,
192 method: String,
193 fields: HashMap<String, String>,
194 field_types: HashMap<String, String>,
195 checkboxes: HashMap<String, Vec<String>>, radios: HashMap<String, Vec<String>>, checked_checkboxes: HashSet<String>, selected_radios: HashMap<String, String>, transport: Arc<T>,
201}
202
203impl<T: HttpTransport> Form<T> {
204 fn find(html: &str, locator: &str, transport: Arc<T>) -> Result<Self> {
205 let document = Html::parse_document(html);
206
207 let selector_str = if let Some(test_id) = locator.strip_prefix('@') {
209 format!("form[test-id=\"{}\"]", test_id)
211 } else if locator.starts_with('#') {
212 format!("form{}", locator)
214 } else if locator.starts_with('/') {
215 format!("form[action=\"{}\"]", locator)
217 } else {
218 return Err(anyhow!("Invalid locator: {}", locator));
219 };
220
221 let form_selector =
222 Selector::parse(&selector_str).map_err(|e| anyhow!("Invalid selector: {:?}", e))?;
223
224 let form_element = document
225 .select(&form_selector)
226 .next()
227 .ok_or_else(|| anyhow!("Form not found: {}", locator))?;
228
229 let action = form_element
231 .value()
232 .attr("action")
233 .unwrap_or("")
234 .to_string();
235
236 let method = form_element
238 .value()
239 .attr("method")
240 .unwrap_or("get")
241 .to_string();
242
243 let input_selector =
245 Selector::parse("input").map_err(|e| anyhow!("Invalid selector: {:?}", e))?;
246
247 let mut fields = HashMap::new();
248 let mut field_types = HashMap::new();
249 let mut checkboxes: HashMap<String, Vec<String>> = HashMap::new();
250 let mut radios: HashMap<String, Vec<String>> = HashMap::new();
251 let mut checked_checkboxes = HashSet::new();
252 let mut selected_radios = HashMap::new();
253
254 for input in form_element.select(&input_selector) {
255 if let Some(name) = input.value().attr("name") {
256 let input_type = input.value().attr("type").unwrap_or("text");
257 field_types.insert(name.to_string(), input_type.to_string());
258
259 match input_type {
260 "hidden" => {
261 if let Some(value) = input.value().attr("value") {
263 fields.insert(name.to_string(), value.to_string());
264 }
265 }
266 "checkbox" => {
267 if let Some(value) = input.value().attr("value") {
269 checkboxes
270 .entry(name.to_string())
271 .or_default()
272 .push(value.to_string());
273
274 if input.value().attr("checked").is_some() {
276 checked_checkboxes.insert(format!("{}={}", name, value));
277 }
278 }
279 }
280 "radio" => {
281 if let Some(value) = input.value().attr("value") {
283 radios
284 .entry(name.to_string())
285 .or_default()
286 .push(value.to_string());
287
288 if input.value().attr("checked").is_some() {
290 selected_radios.insert(name.to_string(), value.to_string());
291 }
292 }
293 }
294 _ => {
295 }
297 }
298 }
299 }
300
301 let textarea_selector =
303 Selector::parse("textarea").map_err(|e| anyhow!("Invalid selector: {:?}", e))?;
304 for textarea in form_element.select(&textarea_selector) {
305 if let Some(name) = textarea.value().attr("name") {
306 field_types.insert(name.to_string(), "textarea".to_string());
307 }
308 }
309
310 let select_selector =
311 Selector::parse("select").map_err(|e| anyhow!("Invalid selector: {:?}", e))?;
312 for select in form_element.select(&select_selector) {
313 if let Some(name) = select.value().attr("name") {
314 field_types.insert(name.to_string(), "select".to_string());
315 }
316 }
317
318 Ok(Self {
319 action,
320 method,
321 fields,
322 field_types,
323 checkboxes,
324 radios,
325 checked_checkboxes,
326 selected_radios,
327 transport,
328 })
329 }
330
331 pub fn is_exist(&self, field_name: &str) -> bool {
333 self.field_types.contains_key(field_name)
334 }
335
336 pub fn get_value(&self, field_name: &str) -> Result<String> {
338 self.fields
339 .get(field_name)
340 .cloned()
341 .ok_or_else(|| anyhow!("Field '{}' not found or has no value", field_name))
342 }
343
344 pub fn fill(&mut self, field_name: &str, value: &str) -> Result<&mut Self> {
346 let field_type = self
348 .field_types
349 .get(field_name)
350 .ok_or_else(|| anyhow!("Field '{}' does not exist in the form", field_name))?;
351
352 match field_type.as_str() {
354 "email" => {
355 if !value.contains('@') {
356 return Err(anyhow!("Invalid email format for field '{}'", field_name));
357 }
358 }
359 "number" => {
360 if value.parse::<f64>().is_err() {
361 return Err(anyhow!("Invalid number format for field '{}'", field_name));
362 }
363 }
364 "url" => {
365 if !value.starts_with("http://")
366 && !value.starts_with("https://")
367 && !value.is_empty()
368 {
369 return Err(anyhow!(
370 "Invalid URL format for field '{}'. Must start with http:// or https://",
371 field_name
372 ));
373 }
374 }
375 "tel" => {
376 if !value.chars().all(|c| {
378 c.is_numeric() || c == '-' || c == ' ' || c == '(' || c == ')' || c == '+'
379 }) {
380 return Err(anyhow!(
381 "Invalid phone number format for field '{}'",
382 field_name
383 ));
384 }
385 }
386 "date" => {
387 let parts: Vec<&str> = value.split('-').collect();
389 if parts.len() != 3 {
390 return Err(anyhow!(
391 "Invalid date format for field '{}'. Expected YYYY-MM-DD",
392 field_name
393 ));
394 }
395 if parts[0].len() != 4 || parts[1].len() != 2 || parts[2].len() != 2 {
396 return Err(anyhow!(
397 "Invalid date format for field '{}'. Expected YYYY-MM-DD",
398 field_name
399 ));
400 }
401 for part in &parts {
402 if part.parse::<u32>().is_err() {
403 return Err(anyhow!(
404 "Invalid date format for field '{}'. Expected YYYY-MM-DD",
405 field_name
406 ));
407 }
408 }
409 }
410 _ => {
411 }
413 }
414
415 self.fields
416 .insert(field_name.to_string(), value.to_string());
417 Ok(self)
418 }
419
420 pub fn check(&mut self, field_name: &str, value: &str) -> Result<&mut Self> {
422 let checkbox_values = self
424 .checkboxes
425 .get(field_name)
426 .ok_or_else(|| anyhow!("Checkbox '{}' does not exist in the form", field_name))?;
427
428 if !checkbox_values.contains(&value.to_string()) {
430 return Err(anyhow!(
431 "Checkbox '{}' does not have value '{}'",
432 field_name,
433 value
434 ));
435 }
436
437 self.checked_checkboxes
439 .insert(format!("{}={}", field_name, value));
440 Ok(self)
441 }
442
443 pub fn uncheck(&mut self, field_name: &str, value: &str) -> Result<&mut Self> {
445 let checkbox_values = self
447 .checkboxes
448 .get(field_name)
449 .ok_or_else(|| anyhow!("Checkbox '{}' does not exist in the form", field_name))?;
450
451 if !checkbox_values.contains(&value.to_string()) {
453 return Err(anyhow!(
454 "Checkbox '{}' does not have value '{}'",
455 field_name,
456 value
457 ));
458 }
459
460 self.checked_checkboxes
462 .remove(&format!("{}={}", field_name, value));
463 Ok(self)
464 }
465
466 pub fn choose(&mut self, field_name: &str, value: &str) -> Result<&mut Self> {
468 let radio_values = self
470 .radios
471 .get(field_name)
472 .ok_or_else(|| anyhow!("Radio button '{}' does not exist in the form", field_name))?;
473
474 if !radio_values.contains(&value.to_string()) {
476 return Err(anyhow!(
477 "Radio button '{}' does not have value '{}'",
478 field_name,
479 value
480 ));
481 }
482
483 self.selected_radios
485 .insert(field_name.to_string(), value.to_string());
486 Ok(self)
487 }
488
489 pub fn select(&mut self, field_name: &str, value: &str) -> Result<&mut Self> {
491 let field_type = self
493 .field_types
494 .get(field_name)
495 .ok_or_else(|| anyhow!("Select '{}' does not exist in the form", field_name))?;
496
497 if field_type != "select" {
498 return Err(anyhow!("Field '{}' is not a select element", field_name));
499 }
500
501 self.fields
503 .insert(field_name.to_string(), value.to_string());
504 Ok(self)
505 }
506
507 pub async fn submit(&self) -> Result<HttpResponse> {
509 let mut params = Vec::new();
510
511 for (k, v) in &self.fields {
513 params.push(format!("{}={}", k, v));
514 }
515
516 for checked in &self.checked_checkboxes {
518 params.push(checked.clone());
519 }
520
521 for (name, value) in &self.selected_radios {
523 params.push(format!("{}={}", name, value));
524 }
525
526 let body = params.join("&");
527
528 let mut headers = HashMap::new();
529 if self.method.to_lowercase() == "post" {
530 headers.insert(
531 "Content-Type".to_string(),
532 "application/x-www-form-urlencoded".to_string(),
533 );
534 }
535
536 let req = HttpRequest {
537 method: if self.method.to_lowercase() == "get" {
538 Method::Get
539 } else {
540 Method::Post
541 },
542 url: self.action.clone(),
543 headers,
544 body: Some(body),
545 };
546
547 self.transport.send(req).await
548 }
549}
550
551#[derive(Debug)]
553pub struct Button<T: HttpTransport> {
554 form_action: Option<String>,
555 form_method: Option<String>,
556 html: String,
557 transport: Arc<T>,
558}
559
560impl<T: HttpTransport> Button<T> {
561 fn find(html: &str, locator: &str, transport: Arc<T>) -> Result<Self> {
562 let document = Html::parse_document(html);
563
564 let selector_str = if let Some(test_id) = locator.strip_prefix('@') {
566 format!("button[test-id=\"{}\"]", test_id)
568 } else if locator.starts_with('#') {
569 format!("button{}", locator)
571 } else {
572 "button".to_string()
574 };
575
576 let button_selector =
577 Selector::parse(&selector_str).map_err(|e| anyhow!("Invalid selector: {:?}", e))?;
578
579 let button_element = if locator.starts_with('@') || locator.starts_with('#') {
580 document
582 .select(&button_selector)
583 .next()
584 .ok_or_else(|| anyhow!("Button not found: {}", locator))?
585 } else {
586 document
588 .select(&button_selector)
589 .find(|el| {
590 let text = el.text().collect::<String>();
591 text.trim() == locator
592 })
593 .ok_or_else(|| anyhow!("Button not found: {}", locator))?
594 };
595
596 let mut form_action = None;
598 let mut form_method = None;
599
600 for ancestor in button_element.ancestors() {
602 if let Some(element) = ancestor.value().as_element() {
603 if element.name() == "form" {
604 form_action = ancestor
605 .value()
606 .as_element()
607 .and_then(|e| e.attr("action"))
608 .map(|s| s.to_string());
609 form_method = ancestor
610 .value()
611 .as_element()
612 .and_then(|e| e.attr("method"))
613 .map(|s| s.to_string());
614 break;
615 }
616 }
617 }
618
619 Ok(Self {
620 form_action,
621 form_method,
622 html: html.to_string(),
623 transport,
624 })
625 }
626
627 pub async fn click(&self) -> Result<HttpResponse> {
629 let action = self
630 .form_action
631 .as_ref()
632 .ok_or_else(|| anyhow!("Button is not associated with a form"))?;
633
634 let document = Html::parse_document(&self.html);
636 let form_selector = Selector::parse(&format!("form[action=\"{}\"]", action))
637 .or_else(|_| Selector::parse("form"))
638 .map_err(|e| anyhow!("Invalid selector: {:?}", e))?;
639
640 let mut params = Vec::new();
641
642 if let Some(form_element) = document.select(&form_selector).next() {
643 let input_selector =
644 Selector::parse("input").map_err(|e| anyhow!("Invalid selector: {:?}", e))?;
645
646 for input in form_element.select(&input_selector) {
647 let input_type = input.value().attr("type").unwrap_or("text");
648 let name = input.value().attr("name");
649 let value = input.value().attr("value");
650
651 if let (Some(n), Some(v)) = (name, value) {
652 if !matches!(
654 input_type,
655 "checkbox" | "radio" | "submit" | "button" | "reset"
656 ) {
657 params.push(format!("{}={}", n, v));
658 }
659 }
660 }
661 }
662
663 let body = params.join("&");
664 let method_str = self.form_method.as_deref().unwrap_or("get").to_lowercase();
665
666 let mut headers = HashMap::new();
667 if method_str == "post" {
668 headers.insert(
669 "Content-Type".to_string(),
670 "application/x-www-form-urlencoded".to_string(),
671 );
672 }
673
674 let req = HttpRequest {
675 method: if method_str == "post" {
676 Method::Post
677 } else {
678 Method::Get
679 },
680 url: action.clone(),
681 headers,
682 body: if method_str == "post" {
683 Some(body)
684 } else {
685 None
686 },
687 };
688
689 self.transport.send(req).await
690 }
691}
692
693#[derive(Debug)]
695pub struct Link<T: HttpTransport> {
696 href: String,
697 transport: Arc<T>,
698}
699
700impl<T: HttpTransport> Link<T> {
701 fn find(html: &str, locator: &str, transport: Arc<T>) -> Result<Self> {
702 let document = Html::parse_document(html);
703
704 let selector_str = if let Some(test_id) = locator.strip_prefix('@') {
706 format!("a[test-id=\"{}\"]", test_id)
708 } else if locator.starts_with('#') {
709 format!("a{}", locator)
711 } else {
712 "a".to_string()
714 };
715
716 let link_selector =
717 Selector::parse(&selector_str).map_err(|e| anyhow!("Invalid selector: {:?}", e))?;
718
719 let link_element = if locator.starts_with('@') || locator.starts_with('#') {
720 document
722 .select(&link_selector)
723 .next()
724 .ok_or_else(|| anyhow!("Link not found: {}", locator))?
725 } else {
726 document
728 .select(&link_selector)
729 .find(|el| {
730 let text = el.text().collect::<String>();
731 text.trim() == locator
732 })
733 .ok_or_else(|| anyhow!("Link not found: {}", locator))?
734 };
735
736 let href = link_element
738 .value()
739 .attr("href")
740 .ok_or_else(|| anyhow!("Link has no href attribute"))?
741 .to_string();
742
743 Ok(Self { href, transport })
744 }
745
746 pub async fn click(&self) -> Result<HttpResponse> {
748 let req = HttpRequest {
749 method: Method::Get,
750 url: self.href.clone(),
751 headers: HashMap::new(),
752 body: None,
753 };
754
755 self.transport.send(req).await
756 }
757}
758
759#[derive(Debug, Clone)]
761pub struct Element {
762 text_content: String,
763 inner_html: String,
764 attributes: HashMap<String, String>,
765}
766
767impl Element {
768 fn find(html: &str, locator: &str) -> Result<Self> {
769 let document = Html::parse_document(html);
770 let selector_str = Self::locator_to_selector(locator)?;
771 let selector =
772 Selector::parse(&selector_str).map_err(|e| anyhow!("Invalid selector: {:?}", e))?;
773
774 let element = document
775 .select(&selector)
776 .next()
777 .ok_or_else(|| anyhow!("Element not found: {}", locator))?;
778
779 Ok(Self::from_element_ref(element))
780 }
781
782 fn find_all(html: &str, locator: &str) -> Vec<Self> {
783 let document = Html::parse_document(html);
784 let selector_str = match Self::locator_to_selector(locator) {
785 Ok(s) => s,
786 Err(_) => return Vec::new(),
787 };
788 let selector = match Selector::parse(&selector_str) {
789 Ok(s) => s,
790 Err(_) => return Vec::new(),
791 };
792
793 document
794 .select(&selector)
795 .map(Self::from_element_ref)
796 .collect()
797 }
798
799 fn locator_to_selector(locator: &str) -> Result<String> {
800 if let Some(test_id) = locator.strip_prefix('@') {
801 Ok(format!("[test-id=\"{}\"]", test_id))
802 } else if locator.starts_with('#') || locator.starts_with('.') {
803 Ok(locator.to_string())
804 } else {
805 Err(anyhow!(
806 "Invalid locator: {}. Must start with @, #, or .",
807 locator
808 ))
809 }
810 }
811
812 fn from_element_ref(element: scraper::element_ref::ElementRef) -> Self {
813 let text_content = element.text().collect::<String>();
814 let inner_html = element.inner_html();
815 let mut attributes = HashMap::new();
816
817 for (name, value) in element.value().attrs() {
818 attributes.insert(name.to_string(), value.to_string());
819 }
820
821 Self {
822 text_content,
823 inner_html,
824 attributes,
825 }
826 }
827
828 pub fn text(&self) -> String {
830 self.text_content.clone()
831 }
832
833 pub fn attr(&self, name: &str) -> Option<String> {
835 self.attributes.get(name).cloned()
836 }
837
838 pub fn has_class(&self, class: &str) -> bool {
840 if let Some(classes) = self.attributes.get("class") {
841 classes.split_whitespace().any(|c| c == class)
842 } else {
843 false
844 }
845 }
846
847 pub fn inner_html(&self) -> String {
849 self.inner_html.clone()
850 }
851
852 pub fn text_contains(&self, text: &str) -> bool {
854 self.text_content.contains(text)
855 }
856
857 pub fn is_disabled(&self) -> bool {
859 self.attributes.contains_key("disabled")
860 }
861
862 pub fn is_required(&self) -> bool {
864 self.attributes.contains_key("required")
865 }
866
867 pub fn is_readonly(&self) -> bool {
869 self.attributes.contains_key("readonly")
870 }
871
872 pub fn is_checked(&self) -> bool {
874 self.attributes.contains_key("checked")
875 }
876}
877
878#[derive(Debug, Clone)]
880pub struct Row {
881 cells: Vec<String>,
882 headers: Vec<String>,
883}
884
885impl Row {
886 pub fn cells(&self) -> Vec<String> {
888 self.cells.clone()
889 }
890
891 pub fn cell(&self, index: usize) -> Result<String> {
893 self.cells
894 .get(index)
895 .cloned()
896 .ok_or_else(|| anyhow!("Cell index {} out of bounds", index))
897 }
898
899 pub fn get(&self, column: &str) -> Result<String> {
901 let index = self
902 .headers
903 .iter()
904 .position(|h| h == column)
905 .ok_or_else(|| anyhow!("Column '{}' not found", column))?;
906 self.cell(index)
907 }
908}
909
910#[derive(Debug, Clone)]
912pub struct Table {
913 headers: Vec<String>,
914 rows: Vec<Row>,
915}
916
917impl Table {
918 fn find(html: &str, locator: &str) -> Result<Self> {
919 let document = Html::parse_document(html);
920 let selector_str = Element::locator_to_selector(locator)?;
921 let table_selector =
922 Selector::parse(&selector_str).map_err(|e| anyhow!("Invalid selector: {:?}", e))?;
923
924 let table_element = document
925 .select(&table_selector)
926 .next()
927 .ok_or_else(|| anyhow!("Table not found: {}", locator))?;
928
929 let th_selector = Selector::parse("thead th, tr th").unwrap();
931 let headers: Vec<String> = table_element
932 .select(&th_selector)
933 .map(|th| th.text().collect::<String>().trim().to_string())
934 .collect();
935
936 let tr_selector = Selector::parse("tbody tr, tr").unwrap();
938 let td_selector = Selector::parse("td").unwrap();
939
940 let rows: Vec<Row> = table_element
941 .select(&tr_selector)
942 .filter_map(|tr| {
943 let cells: Vec<String> = tr
944 .select(&td_selector)
945 .map(|td| td.text().collect::<String>().trim().to_string())
946 .collect();
947
948 if cells.is_empty() {
949 None
950 } else {
951 Some(Row {
952 cells,
953 headers: headers.clone(),
954 })
955 }
956 })
957 .collect();
958
959 Ok(Self { headers, rows })
960 }
961
962 pub fn headers(&self) -> Vec<String> {
964 self.headers.clone()
965 }
966
967 pub fn rows(&self) -> Vec<Row> {
969 self.rows.clone()
970 }
971
972 pub fn row(&self, index: usize) -> Result<Row> {
974 self.rows
975 .get(index)
976 .cloned()
977 .ok_or_else(|| anyhow!("Row index {} out of bounds", index))
978 }
979
980 pub fn cell(&self, row: usize, col: usize) -> Result<String> {
982 let row_data = self.row(row)?;
983 row_data.cell(col)
984 }
985
986 pub fn find_row(&self, column: &str, value: &str) -> Result<Row> {
988 self.rows
989 .iter()
990 .find(|row| row.get(column).map(|v| v == value).unwrap_or(false))
991 .cloned()
992 .ok_or_else(|| anyhow!("Row with {}='{}' not found", column, value))
993 }
994}
995
996#[derive(Debug, Clone)]
998pub struct List {
999 items: Vec<String>,
1000}
1001
1002impl List {
1003 fn find(html: &str, locator: &str) -> Result<Self> {
1004 let document = Html::parse_document(html);
1005 let selector_str = Element::locator_to_selector(locator)?;
1006 let list_selector =
1007 Selector::parse(&selector_str).map_err(|e| anyhow!("Invalid selector: {:?}", e))?;
1008
1009 let list_element = document
1010 .select(&list_selector)
1011 .next()
1012 .ok_or_else(|| anyhow!("List not found: {}", locator))?;
1013
1014 let li_selector = Selector::parse("li").unwrap();
1016 let items: Vec<String> = list_element
1017 .select(&li_selector)
1018 .map(|li| li.text().collect::<String>().trim().to_string())
1019 .collect();
1020
1021 Ok(Self { items })
1022 }
1023
1024 pub fn items(&self) -> Vec<String> {
1026 self.items.clone()
1027 }
1028
1029 pub fn item(&self, index: usize) -> Result<String> {
1031 self.items
1032 .get(index)
1033 .cloned()
1034 .ok_or_else(|| anyhow!("Item index {} out of bounds", index))
1035 }
1036
1037 pub fn len(&self) -> usize {
1039 self.items.len()
1040 }
1041
1042 pub fn is_empty(&self) -> bool {
1044 self.items.is_empty()
1045 }
1046
1047 pub fn contains(&self, text: &str) -> bool {
1049 self.items.iter().any(|item| item == text)
1050 }
1051}
1052
1053#[derive(Debug, Clone)]
1055pub struct Image {
1056 src: String,
1057 alt: Option<String>,
1058 width: Option<String>,
1059 height: Option<String>,
1060}
1061
1062impl Image {
1063 fn find(html: &str, locator: &str) -> Result<Self> {
1064 let document = Html::parse_document(html);
1065 let selector_str = if let Some(test_id) = locator.strip_prefix('@') {
1066 format!("img[test-id=\"{}\"]", test_id)
1067 } else if locator.starts_with('#') || locator.starts_with('.') {
1068 format!("img{}", locator)
1069 } else if locator == "img" {
1070 "img".to_string()
1071 } else {
1072 return Err(anyhow!(
1073 "Invalid locator: {}. Must start with @, #, . or be 'img'",
1074 locator
1075 ));
1076 };
1077
1078 let selector =
1079 Selector::parse(&selector_str).map_err(|e| anyhow!("Invalid selector: {:?}", e))?;
1080
1081 let img_element = document
1082 .select(&selector)
1083 .next()
1084 .ok_or_else(|| anyhow!("Image not found: {}", locator))?;
1085
1086 Ok(Self::from_element_ref(img_element))
1087 }
1088
1089 fn find_all(html: &str, locator: &str) -> Vec<Self> {
1090 let document = Html::parse_document(html);
1091 let selector_str = if let Some(test_id) = locator.strip_prefix('@') {
1092 format!("img[test-id=\"{}\"]", test_id)
1093 } else if locator.starts_with('#') || locator.starts_with('.') {
1094 format!("img{}", locator)
1095 } else if locator == "img" {
1096 "img".to_string()
1097 } else {
1098 return Vec::new();
1099 };
1100
1101 let selector = match Selector::parse(&selector_str) {
1102 Ok(s) => s,
1103 Err(_) => return Vec::new(),
1104 };
1105
1106 document
1107 .select(&selector)
1108 .map(Self::from_element_ref)
1109 .collect()
1110 }
1111
1112 fn from_element_ref(element: scraper::element_ref::ElementRef) -> Self {
1113 let src = element.value().attr("src").unwrap_or("").to_string();
1114 let alt = element.value().attr("alt").map(|s| s.to_string());
1115 let width = element.value().attr("width").map(|s| s.to_string());
1116 let height = element.value().attr("height").map(|s| s.to_string());
1117
1118 Self {
1119 src,
1120 alt,
1121 width,
1122 height,
1123 }
1124 }
1125
1126 pub fn src(&self) -> String {
1128 self.src.clone()
1129 }
1130
1131 pub fn alt(&self) -> Option<String> {
1133 self.alt.clone()
1134 }
1135
1136 pub fn width(&self) -> Option<String> {
1138 self.width.clone()
1139 }
1140
1141 pub fn height(&self) -> Option<String> {
1143 self.height.clone()
1144 }
1145}
1146
1147#[derive(Debug, Clone)]
1149pub struct SelectOption {
1150 value: String,
1151 text: String,
1152 selected: bool,
1153}
1154
1155impl SelectOption {
1156 pub fn value(&self) -> String {
1158 self.value.clone()
1159 }
1160
1161 pub fn text(&self) -> String {
1163 self.text.clone()
1164 }
1165
1166 pub fn is_selected(&self) -> bool {
1168 self.selected
1169 }
1170}
1171
1172#[derive(Debug, Clone)]
1174pub struct SelectElement {
1175 options: Vec<SelectOption>,
1176}
1177
1178impl SelectElement {
1179 fn find(html: &str, locator: &str) -> Result<Self> {
1180 let document = Html::parse_document(html);
1181 let selector_str = if let Some(test_id) = locator.strip_prefix('@') {
1182 format!("select[test-id=\"{}\"]", test_id)
1183 } else if locator.starts_with('#') || locator.starts_with('.') {
1184 format!("select{}", locator)
1185 } else {
1186 return Err(anyhow!(
1187 "Invalid locator: {}. Must start with @, #, or .",
1188 locator
1189 ));
1190 };
1191
1192 let selector =
1193 Selector::parse(&selector_str).map_err(|e| anyhow!("Invalid selector: {:?}", e))?;
1194
1195 let select_element = document
1196 .select(&selector)
1197 .next()
1198 .ok_or_else(|| anyhow!("Select element not found: {}", locator))?;
1199
1200 let option_selector = Selector::parse("option").unwrap();
1202 let options: Vec<SelectOption> = select_element
1203 .select(&option_selector)
1204 .map(|option| {
1205 let text_content = option.text().collect::<String>();
1206 let value = option
1207 .value()
1208 .attr("value")
1209 .map(|s| s.to_string())
1210 .unwrap_or_else(|| text_content.trim().to_string());
1211 let text = text_content.trim().to_string();
1212 let selected = option.value().attr("selected").is_some();
1213
1214 SelectOption {
1215 value,
1216 text,
1217 selected,
1218 }
1219 })
1220 .collect();
1221
1222 Ok(Self { options })
1223 }
1224
1225 pub fn options(&self) -> Vec<SelectOption> {
1227 self.options.clone()
1228 }
1229
1230 pub fn selected_option(&self) -> Result<SelectOption> {
1232 self.options
1233 .iter()
1234 .find(|opt| opt.selected)
1235 .cloned()
1236 .ok_or_else(|| anyhow!("No option is selected"))
1237 }
1238}