lightdom_test/
lib.rs

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/// HTTP method enumeration
10#[derive(Debug, Clone, PartialEq)]
11pub enum Method {
12    Get,
13    Post,
14}
15
16/// HTTP request structure
17#[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/// HTTP status code
26#[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/// HTTP response structure
36#[derive(Debug, Clone)]
37pub struct HttpResponse {
38    pub status: StatusCode,
39    pub headers: HashMap<String, String>,
40    pub body: String,
41}
42
43/// HTTP transport layer trait
44#[async_trait]
45pub trait HttpTransport: Send + Sync {
46    async fn send(&self, req: HttpRequest) -> Result<HttpResponse>;
47}
48
49/// Structure for manipulating HTML documents
50pub struct Dom<T: HttpTransport> {
51    transport: Arc<T>,
52    html: String,
53}
54
55impl<T: HttpTransport> Dom<T> {
56    /// Create a new Dom instance
57    pub fn new(transport: T) -> Self {
58        Self {
59            transport: Arc::new(transport),
60            html: String::new(),
61        }
62    }
63
64    /// Parse HTML and return a Dom instance
65    pub fn parse(mut self, html: String) -> Result<Self> {
66        self.html = html;
67        Ok(self)
68    }
69
70    /// Get a form
71    pub fn form(&self, locator: &str) -> Result<Form<T>> {
72        Form::find(&self.html, locator, Arc::clone(&self.transport))
73    }
74
75    /// Get a button
76    pub fn button(&self, locator: &str) -> Result<Button<T>> {
77        Button::find(&self.html, locator, Arc::clone(&self.transport))
78    }
79
80    /// Get a link
81    pub fn link(&self, locator: &str) -> Result<Link<T>> {
82        Link::find(&self.html, locator, Arc::clone(&self.transport))
83    }
84
85    /// Get an element
86    pub fn element(&self, locator: &str) -> Result<Element> {
87        Element::find(&self.html, locator)
88    }
89
90    /// Get multiple elements
91    pub fn elements(&self, locator: &str) -> Vec<Element> {
92        Element::find_all(&self.html, locator)
93    }
94
95    /// Get the text of an element
96    pub fn text(&self, locator: &str) -> Result<String> {
97        let element = self.element(locator)?;
98        Ok(element.text())
99    }
100
101    /// Get the text of multiple elements
102    pub fn texts(&self, locator: &str) -> Vec<String> {
103        self.elements(locator).iter().map(|e| e.text()).collect()
104    }
105
106    /// Get the inner HTML of an element
107    pub fn inner_html(&self, locator: &str) -> Result<String> {
108        let element = self.element(locator)?;
109        Ok(element.inner_html())
110    }
111
112    /// Get a table
113    pub fn table(&self, locator: &str) -> Result<Table> {
114        Table::find(&self.html, locator)
115    }
116
117    /// Get a list
118    pub fn list(&self, locator: &str) -> Result<List> {
119        List::find(&self.html, locator)
120    }
121
122    /// Check if an element exists
123    pub fn exists(&self, locator: &str) -> bool {
124        self.element(locator).is_ok()
125    }
126
127    /// Check if an element containing the specified text exists
128    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    /// Get the content of the title tag
135    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    /// Get the content attribute of a meta tag
146    pub fn meta(&self, name: &str) -> Result<String> {
147        let document = Html::parse_document(&self.html);
148
149        // Search by name attribute
150        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        // Search by property attribute (for OGP tags)
160        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    /// Get an image
173    pub fn image(&self, locator: &str) -> Result<Image> {
174        Image::find(&self.html, locator)
175    }
176
177    /// Get multiple images
178    pub fn images(&self, locator: &str) -> Vec<Image> {
179        Image::find_all(&self.html, locator)
180    }
181
182    /// Get a select element
183    pub fn select_element(&self, locator: &str) -> Result<SelectElement> {
184        SelectElement::find(&self.html, locator)
185    }
186}
187
188/// HTML form structure
189#[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    // Checkbox/radio information
196    checkboxes: HashMap<String, Vec<String>>, // name -> [values]
197    radios: HashMap<String, Vec<String>>,     // name -> [values]
198    checked_checkboxes: HashSet<String>,      // Set of "name=value"
199    selected_radios: HashMap<String, String>, // name -> selected value
200    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        // Generate selector based on locator
208        let selector_str = if let Some(test_id) = locator.strip_prefix('@') {
209            // test-id attribute
210            format!("form[test-id=\"{}\"]", test_id)
211        } else if locator.starts_with('#') {
212            // id attribute
213            format!("form{}", locator)
214        } else if locator.starts_with('/') {
215            // action attribute
216            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        // Get action attribute
230        let action = form_element
231            .value()
232            .attr("action")
233            .unwrap_or("")
234            .to_string();
235
236        // Get method attribute
237        let method = form_element
238            .value()
239            .attr("method")
240            .unwrap_or("get")
241            .to_string();
242
243        // Collect hidden fields in the form in advance
244        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                        // Set hidden field value in advance
262                        if let Some(value) = input.value().attr("value") {
263                            fields.insert(name.to_string(), value.to_string());
264                        }
265                    }
266                    "checkbox" => {
267                        // Collect checkbox information
268                        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                            // Record as checked if checked attribute is true
275                            if input.value().attr("checked").is_some() {
276                                checked_checkboxes.insert(format!("{}={}", name, value));
277                            }
278                        }
279                    }
280                    "radio" => {
281                        // Collect radio information
282                        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                            // Record as selected if checked attribute is true
289                            if input.value().attr("checked").is_some() {
290                                selected_radios.insert(name.to_string(), value.to_string());
291                            }
292                        }
293                    }
294                    _ => {
295                        // text, email, password, number, etc.
296                    }
297                }
298            }
299        }
300
301        // Also collect textarea and select elements
302        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    /// Check if field exists in form
332    pub fn is_exist(&self, field_name: &str) -> bool {
333        self.field_types.contains_key(field_name)
334    }
335
336    /// Get current field value
337    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    /// Fill field with value
345    pub fn fill(&mut self, field_name: &str, value: &str) -> Result<&mut Self> {
346        // Check if field exists
347        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        // Validation based on type
353        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                // Phone numbers allow only digits, hyphens, spaces, parentheses, and +
377                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                // Check YYYY-MM-DD format
388                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                // No validation for text, password, hidden, textarea, select, etc.
412            }
413        }
414
415        self.fields
416            .insert(field_name.to_string(), value.to_string());
417        Ok(self)
418    }
419
420    /// Check a checkbox
421    pub fn check(&mut self, field_name: &str, value: &str) -> Result<&mut Self> {
422        // Check if checkbox exists
423        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        // Check if specified value exists
429        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        // Set to checked state
438        self.checked_checkboxes
439            .insert(format!("{}={}", field_name, value));
440        Ok(self)
441    }
442
443    /// Uncheck a checkbox
444    pub fn uncheck(&mut self, field_name: &str, value: &str) -> Result<&mut Self> {
445        // Check if checkbox exists
446        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        // Check if specified value exists
452        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        // Uncheck
461        self.checked_checkboxes
462            .remove(&format!("{}={}", field_name, value));
463        Ok(self)
464    }
465
466    /// Select a radio button
467    pub fn choose(&mut self, field_name: &str, value: &str) -> Result<&mut Self> {
468        // Check if radio button exists
469        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        // Check if specified value exists
475        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        // Select radio button
484        self.selected_radios
485            .insert(field_name.to_string(), value.to_string());
486        Ok(self)
487    }
488
489    /// Select an option in select box
490    pub fn select(&mut self, field_name: &str, value: &str) -> Result<&mut Self> {
491        // Check if select box exists
492        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        // Set value (option existence check ideally done from actual HTML, but omitted for simplification)
502        self.fields
503            .insert(field_name.to_string(), value.to_string());
504        Ok(self)
505    }
506
507    /// Submit form
508    pub async fn submit(&self) -> Result<HttpResponse> {
509        let mut params = Vec::new();
510
511        // Regular fields
512        for (k, v) in &self.fields {
513            params.push(format!("{}={}", k, v));
514        }
515
516        // Checked checkboxes
517        for checked in &self.checked_checkboxes {
518            params.push(checked.clone());
519        }
520
521        // Selected radio buttons
522        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/// HTML button structure
552#[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        // Generate selector based on locator
565        let selector_str = if let Some(test_id) = locator.strip_prefix('@') {
566            // test-id attribute
567            format!("button[test-id=\"{}\"]", test_id)
568        } else if locator.starts_with('#') {
569            // id attribute
570            format!("button{}", locator)
571        } else {
572            // Text search (contains) processed later
573            "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            // Attribute-based search
581            document
582                .select(&button_selector)
583                .next()
584                .ok_or_else(|| anyhow!("Button not found: {}", locator))?
585        } else {
586            // Text-based search
587            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        // Find the form that contains the button
597        let mut form_action = None;
598        let mut form_method = None;
599
600        // Traverse parent elements to find form
601        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    /// Click button
628    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        // Collect default form values (hidden, text, email, etc.)
635        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                    // Collect default values for hidden, text, email, etc. fields
653                    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/// HTML link structure
694#[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        // Generate selector based on locator
705        let selector_str = if let Some(test_id) = locator.strip_prefix('@') {
706            // test-id attribute
707            format!("a[test-id=\"{}\"]", test_id)
708        } else if locator.starts_with('#') {
709            // id attribute
710            format!("a{}", locator)
711        } else {
712            // Search by text
713            "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            // Attribute-based search
721            document
722                .select(&link_selector)
723                .next()
724                .ok_or_else(|| anyhow!("Link not found: {}", locator))?
725        } else {
726            // Text-based search
727            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        // Get href attribute
737        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    /// Click link
747    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/// HTML element structure
760#[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    /// Get element text content
829    pub fn text(&self) -> String {
830        self.text_content.clone()
831    }
832
833    /// Get value of specified attribute
834    pub fn attr(&self, name: &str) -> Option<String> {
835        self.attributes.get(name).cloned()
836    }
837
838    /// Check if element has specified class
839    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    /// Get element inner HTML
848    pub fn inner_html(&self) -> String {
849        self.inner_html.clone()
850    }
851
852    /// Check if text contains specified string
853    pub fn text_contains(&self, text: &str) -> bool {
854        self.text_content.contains(text)
855    }
856
857    /// Check if element has disabled attribute
858    pub fn is_disabled(&self) -> bool {
859        self.attributes.contains_key("disabled")
860    }
861
862    /// Check if element has required attribute
863    pub fn is_required(&self) -> bool {
864        self.attributes.contains_key("required")
865    }
866
867    /// Check if element has readonly attribute
868    pub fn is_readonly(&self) -> bool {
869        self.attributes.contains_key("readonly")
870    }
871
872    /// Check if element has checked attribute
873    pub fn is_checked(&self) -> bool {
874        self.attributes.contains_key("checked")
875    }
876}
877
878/// Table row structure
879#[derive(Debug, Clone)]
880pub struct Row {
881    cells: Vec<String>,
882    headers: Vec<String>,
883}
884
885impl Row {
886    /// Get text of all cells in row
887    pub fn cells(&self) -> Vec<String> {
888        self.cells.clone()
889    }
890
891    /// Get text of cell at specified index
892    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    /// Get cell text by header name
900    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/// HTML table structure
911#[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        // Get headers
930        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        // Get rows
937        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    /// Get table headers
963    pub fn headers(&self) -> Vec<String> {
964        self.headers.clone()
965    }
966
967    /// Get all table rows
968    pub fn rows(&self) -> Vec<Row> {
969        self.rows.clone()
970    }
971
972    /// Get row at specified index
973    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    /// Get text of cell at specified row and column
981    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    /// Search for row where specified column value matches
987    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/// HTML list structure
997#[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        // Get li elements
1015        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    /// Get text of all list items
1025    pub fn items(&self) -> Vec<String> {
1026        self.items.clone()
1027    }
1028
1029    /// Get text of item at specified index
1030    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    /// Return number of list items
1038    pub fn len(&self) -> usize {
1039        self.items.len()
1040    }
1041
1042    /// Return whether list is empty
1043    pub fn is_empty(&self) -> bool {
1044        self.items.is_empty()
1045    }
1046
1047    /// Check if item containing specified text exists
1048    pub fn contains(&self, text: &str) -> bool {
1049        self.items.iter().any(|item| item == text)
1050    }
1051}
1052
1053/// HTML image structure
1054#[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    /// Get image src attribute
1127    pub fn src(&self) -> String {
1128        self.src.clone()
1129    }
1130
1131    /// Get image alt attribute
1132    pub fn alt(&self) -> Option<String> {
1133        self.alt.clone()
1134    }
1135
1136    /// Get image width attribute
1137    pub fn width(&self) -> Option<String> {
1138        self.width.clone()
1139    }
1140
1141    /// Get image height attribute
1142    pub fn height(&self) -> Option<String> {
1143        self.height.clone()
1144    }
1145}
1146
1147/// Select option structure
1148#[derive(Debug, Clone)]
1149pub struct SelectOption {
1150    value: String,
1151    text: String,
1152    selected: bool,
1153}
1154
1155impl SelectOption {
1156    /// Get option value attribute
1157    pub fn value(&self) -> String {
1158        self.value.clone()
1159    }
1160
1161    /// Get option display text
1162    pub fn text(&self) -> String {
1163        self.text.clone()
1164    }
1165
1166    /// Check if option is selected
1167    pub fn is_selected(&self) -> bool {
1168        self.selected
1169    }
1170}
1171
1172/// HTML select element structure
1173#[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        // Get option elements
1201        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    /// Get all options
1226    pub fn options(&self) -> Vec<SelectOption> {
1227        self.options.clone()
1228    }
1229
1230    /// Get selected option
1231    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}