Skip to main content

victauri_test/
locator.rs

1//! Playwright-style composable element locators with actions, queries, and auto-waiting expectations.
2//!
3//! [`Locator`] is the primary entry point. Create one via a factory method, optionally
4//! refine it with chained filters, then perform actions or query properties against a
5//! live [`VictauriClient`] connection.
6//!
7//! # Examples
8//!
9//! ```rust,ignore
10//! use victauri_test::locator::Locator;
11//!
12//! let submit = Locator::role("button").and_text("Submit");
13//! submit.click(&mut client).await.unwrap();
14//!
15//! let email = Locator::placeholder("Enter email");
16//! email.fill(&mut client, "user@example.com").await.unwrap();
17//!
18//! Locator::test_id("toast")
19//!     .expect(&mut client)
20//!     .to_be_visible()
21//!     .await
22//!     .unwrap();
23//! ```
24
25use std::fmt;
26use std::time::{Duration, Instant};
27
28use serde::{Deserialize, Serialize};
29use serde_json::{Value, json};
30
31use crate::VictauriClient;
32use crate::error::TestError;
33
34// ── Data types ──────────────────────────────────────────────────────────────
35
36/// Bounding rectangle of a DOM element in CSS pixels.
37#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
38pub struct Bounds {
39    /// X offset from the viewport left edge.
40    pub x: f64,
41    /// Y offset from the viewport top edge.
42    pub y: f64,
43    /// Element width.
44    pub width: f64,
45    /// Element height.
46    pub height: f64,
47}
48
49/// A single element resolved from a [`Locator`] query.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct LocatorMatch {
52    /// Ref handle ID used to target this element in subsequent actions.
53    pub ref_id: String,
54    /// HTML tag name (e.g. `"button"`, `"input"`).
55    pub tag: String,
56    /// ARIA role, if present.
57    pub role: Option<String>,
58    /// Accessible name, if present.
59    pub name: Option<String>,
60    /// Visible text content, if any.
61    pub text: Option<String>,
62    /// Whether the element is currently visible.
63    pub visible: bool,
64    /// Whether the element is currently enabled (not disabled).
65    pub enabled: bool,
66    /// Form control value, if applicable.
67    pub value: Option<String>,
68    /// Bounding rectangle in CSS pixels.
69    pub bounds: Option<Bounds>,
70}
71
72// ── Internal enums ──────────────────────────────────────────────────────────
73
74/// Primary query strategy used to find elements via the bridge.
75#[derive(Debug, Clone)]
76enum Strategy {
77    Role(String),
78    Text(String),
79    TextExact(String),
80    TestId(String),
81    Css(String),
82    Label(String),
83    Placeholder(String),
84    AltText(String),
85    Title(String),
86}
87
88/// Additional client-side filter applied after the bridge query returns.
89#[derive(Debug, Clone)]
90enum Filter {
91    Text(String),
92    TextExact(String),
93    Role(String),
94    Name(String),
95    Tag(String),
96    HasAttribute(String, Option<String>),
97}
98
99/// Which element to pick when multiple match.
100#[derive(Debug, Clone)]
101enum Pick {
102    First,
103    Nth(usize),
104    Last,
105}
106
107// ── Locator ─────────────────────────────────────────────────────────────────
108
109/// Composable element query with actions, property queries, and auto-waiting expectations.
110///
111/// Locators are lazy: they describe *how* to find an element but do not contact
112/// the server until an action, query, or expectation method is called.
113///
114/// All refinement methods return a new `Locator` (the type is `Clone`), so the
115/// original remains usable.
116#[derive(Debug, Clone)]
117pub struct Locator {
118    strategy: Strategy,
119    filters: Vec<Filter>,
120    pick: Pick,
121}
122
123// Compile-time guarantee that Locator can cross task boundaries.
124const _: () = {
125    fn _assert_send_sync<T: Send + Sync>() {}
126    fn _check() {
127        _assert_send_sync::<Locator>();
128    }
129};
130
131impl Locator {
132    // ── Factory methods ─────────────────────────────────────────────────
133
134    /// Locate elements by ARIA role (e.g. `"button"`, `"textbox"`).
135    #[must_use]
136    pub fn role(role: &str) -> Self {
137        Self {
138            strategy: Strategy::Role(role.to_string()),
139            filters: Vec::new(),
140            pick: Pick::First,
141        }
142    }
143
144    /// Locate elements whose visible text contains `text` (case-insensitive substring).
145    #[must_use]
146    pub fn text(text: &str) -> Self {
147        Self {
148            strategy: Strategy::Text(text.to_string()),
149            filters: Vec::new(),
150            pick: Pick::First,
151        }
152    }
153
154    /// Locate elements whose visible text exactly equals `text`.
155    #[must_use]
156    pub fn text_exact(text: &str) -> Self {
157        Self {
158            strategy: Strategy::TextExact(text.to_string()),
159            filters: Vec::new(),
160            pick: Pick::First,
161        }
162    }
163
164    /// Locate elements by `data-testid` attribute.
165    #[must_use]
166    pub fn test_id(id: &str) -> Self {
167        Self {
168            strategy: Strategy::TestId(id.to_string()),
169            filters: Vec::new(),
170            pick: Pick::First,
171        }
172    }
173
174    /// Locate elements by CSS selector.
175    #[must_use]
176    pub fn css(selector: &str) -> Self {
177        Self {
178            strategy: Strategy::Css(selector.to_string()),
179            filters: Vec::new(),
180            pick: Pick::First,
181        }
182    }
183
184    /// Locate form controls by their associated `<label>` text.
185    #[must_use]
186    pub fn label(text: &str) -> Self {
187        Self {
188            strategy: Strategy::Label(text.to_string()),
189            filters: Vec::new(),
190            pick: Pick::First,
191        }
192    }
193
194    /// Locate elements by `placeholder` attribute.
195    #[must_use]
196    pub fn placeholder(text: &str) -> Self {
197        Self {
198            strategy: Strategy::Placeholder(text.to_string()),
199            filters: Vec::new(),
200            pick: Pick::First,
201        }
202    }
203
204    /// Locate elements by `alt` attribute (images, areas).
205    #[must_use]
206    pub fn alt_text(alt: &str) -> Self {
207        Self {
208            strategy: Strategy::AltText(alt.to_string()),
209            filters: Vec::new(),
210            pick: Pick::First,
211        }
212    }
213
214    /// Locate elements by `title` attribute.
215    #[must_use]
216    pub fn title(title: &str) -> Self {
217        Self {
218            strategy: Strategy::Title(title.to_string()),
219            filters: Vec::new(),
220            pick: Pick::First,
221        }
222    }
223
224    // ── Refinement (chainable) ──────────────────────────────────────────
225
226    /// Further filter by case-insensitive text substring.
227    #[must_use]
228    pub fn and_text(mut self, text: &str) -> Self {
229        self.filters.push(Filter::Text(text.to_string()));
230        self
231    }
232
233    /// Further filter by exact text match.
234    #[must_use]
235    pub fn and_text_exact(mut self, text: &str) -> Self {
236        self.filters.push(Filter::TextExact(text.to_string()));
237        self
238    }
239
240    /// Further filter by ARIA role.
241    #[must_use]
242    pub fn and_role(mut self, role: &str) -> Self {
243        self.filters.push(Filter::Role(role.to_string()));
244        self
245    }
246
247    /// Further filter by accessible name (case-insensitive substring).
248    #[must_use]
249    pub fn name(mut self, name: &str) -> Self {
250        self.filters.push(Filter::Name(name.to_string()));
251        self
252    }
253
254    /// Further filter by HTML tag name.
255    #[must_use]
256    pub fn and_tag(mut self, tag: &str) -> Self {
257        self.filters.push(Filter::Tag(tag.to_string()));
258        self
259    }
260
261    /// Further filter by the presence (and optionally value) of an HTML attribute.
262    #[must_use]
263    pub fn and_has_attribute(mut self, attr_name: &str, attr_value: Option<&str>) -> Self {
264        self.filters.push(Filter::HasAttribute(
265            attr_name.to_string(),
266            attr_value.map(String::from),
267        ));
268        self
269    }
270
271    /// Select the nth match (zero-based).
272    #[must_use]
273    pub fn nth(mut self, n: usize) -> Self {
274        self.pick = Pick::Nth(n);
275        self
276    }
277
278    /// Select the first match (default behavior, explicit for readability).
279    #[must_use]
280    pub fn first(mut self) -> Self {
281        self.pick = Pick::First;
282        self
283    }
284
285    /// Select the last match.
286    #[must_use]
287    pub fn last(mut self) -> Self {
288        self.pick = Pick::Last;
289        self
290    }
291
292    // ── Actions ─────────────────────────────────────────────────────────
293
294    /// Click the resolved element.
295    ///
296    /// # Errors
297    ///
298    /// Returns [`TestError::ElementNotFound`] if no element matches.
299    pub async fn click(&self, client: &mut VictauriClient) -> Result<Value, TestError> {
300        let el = self.resolve_one(client).await?;
301        client.click(&el.ref_id).await
302    }
303
304    /// Double-click the resolved element.
305    ///
306    /// # Errors
307    ///
308    /// Returns [`TestError::ElementNotFound`] if no element matches.
309    pub async fn double_click(&self, client: &mut VictauriClient) -> Result<Value, TestError> {
310        let el = self.resolve_one(client).await?;
311        client.double_click(&el.ref_id).await
312    }
313
314    /// Clear the field and fill it with `value`.
315    ///
316    /// # Errors
317    ///
318    /// Returns [`TestError::ElementNotFound`] if no element matches.
319    pub async fn fill(&self, client: &mut VictauriClient, value: &str) -> Result<Value, TestError> {
320        let el = self.resolve_one(client).await?;
321        client.fill(&el.ref_id, value).await
322    }
323
324    /// Type `text` character-by-character into the resolved element.
325    ///
326    /// # Errors
327    ///
328    /// Returns [`TestError::ElementNotFound`] if no element matches.
329    pub async fn type_text(
330        &self,
331        client: &mut VictauriClient,
332        text: &str,
333    ) -> Result<Value, TestError> {
334        let el = self.resolve_one(client).await?;
335        client.type_text(&el.ref_id, text).await
336    }
337
338    /// Press a keyboard key (e.g. `"Enter"`, `"Control+c"`).
339    ///
340    /// # Errors
341    ///
342    /// Returns [`TestError::ElementNotFound`] if no element matches.
343    pub async fn press_key(
344        &self,
345        client: &mut VictauriClient,
346        key: &str,
347    ) -> Result<Value, TestError> {
348        let _el = self.resolve_one(client).await?;
349        client.press_key(key).await
350    }
351
352    /// Hover over the resolved element.
353    ///
354    /// # Errors
355    ///
356    /// Returns [`TestError::ElementNotFound`] if no element matches.
357    pub async fn hover(&self, client: &mut VictauriClient) -> Result<Value, TestError> {
358        let el = self.resolve_one(client).await?;
359        client.hover(&el.ref_id).await
360    }
361
362    /// Focus the resolved element.
363    ///
364    /// # Errors
365    ///
366    /// Returns [`TestError::ElementNotFound`] if no element matches.
367    pub async fn focus(&self, client: &mut VictauriClient) -> Result<Value, TestError> {
368        let el = self.resolve_one(client).await?;
369        client.focus(&el.ref_id).await
370    }
371
372    /// Remove focus from the currently active element.
373    ///
374    /// # Errors
375    ///
376    /// Returns [`TestError::ElementNotFound`] if no element matches.
377    pub async fn blur(&self, client: &mut VictauriClient) -> Result<Value, TestError> {
378        let _el = self.resolve_one(client).await?;
379        client.eval_js("document.activeElement?.blur()").await
380    }
381
382    /// Scroll the resolved element into view.
383    ///
384    /// # Errors
385    ///
386    /// Returns [`TestError::ElementNotFound`] if no element matches.
387    pub async fn scroll_into_view(&self, client: &mut VictauriClient) -> Result<Value, TestError> {
388        let el = self.resolve_one(client).await?;
389        client.scroll_to(&el.ref_id).await
390    }
391
392    /// Select option(s) in a `<select>` element.
393    ///
394    /// # Errors
395    ///
396    /// Returns [`TestError::ElementNotFound`] if no element matches.
397    pub async fn select_option(
398        &self,
399        client: &mut VictauriClient,
400        values: &[&str],
401    ) -> Result<Value, TestError> {
402        let el = self.resolve_one(client).await?;
403        client.select_option(&el.ref_id, values).await
404    }
405
406    /// Check a checkbox or radio button (sets `checked = true`).
407    ///
408    /// # Errors
409    ///
410    /// Returns [`TestError::ElementNotFound`] if no element matches.
411    pub async fn check(&self, client: &mut VictauriClient) -> Result<Value, TestError> {
412        let el = self.resolve_one(client).await?;
413        let code = format!(
414            "(function() {{ var el = window.__VICTAURI__?.getRef({}); \
415             if (!el) return null; \
416             if (!el.checked) {{ el.checked = true; \
417             el.dispatchEvent(new Event('change', {{bubbles:true}})); \
418             el.dispatchEvent(new Event('input', {{bubbles:true}})); }} \
419             return true; }})()",
420            serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string()),
421        );
422        client.eval_js(&code).await
423    }
424
425    /// Uncheck a checkbox (sets `checked = false`).
426    ///
427    /// # Errors
428    ///
429    /// Returns [`TestError::ElementNotFound`] if no element matches.
430    pub async fn uncheck(&self, client: &mut VictauriClient) -> Result<Value, TestError> {
431        let el = self.resolve_one(client).await?;
432        let code = format!(
433            "(function() {{ var el = window.__VICTAURI__?.getRef({}); \
434             if (!el) return null; \
435             if (el.checked) {{ el.checked = false; \
436             el.dispatchEvent(new Event('change', {{bubbles:true}})); \
437             el.dispatchEvent(new Event('input', {{bubbles:true}})); }} \
438             return true; }})()",
439            serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string()),
440        );
441        client.eval_js(&code).await
442    }
443
444    // ── Query methods ───────────────────────────────────────────────────
445
446    /// Get the `textContent` of the resolved element.
447    ///
448    /// # Errors
449    ///
450    /// Returns [`TestError::ElementNotFound`] if no element matches.
451    pub async fn text_content(&self, client: &mut VictauriClient) -> Result<String, TestError> {
452        let val = self
453            .eval_on_element(client, "return el.textContent || \"\";")
454            .await?;
455        Ok(value_to_string(&val))
456    }
457
458    /// Get the `innerText` of the resolved element.
459    ///
460    /// # Errors
461    ///
462    /// Returns [`TestError::ElementNotFound`] if no element matches.
463    pub async fn inner_text(&self, client: &mut VictauriClient) -> Result<String, TestError> {
464        let val = self
465            .eval_on_element(client, "return el.innerText || \"\";")
466            .await?;
467        Ok(value_to_string(&val))
468    }
469
470    /// Get the current `value` of an input/textarea/select element.
471    ///
472    /// # Errors
473    ///
474    /// Returns [`TestError::ElementNotFound`] if no element matches.
475    pub async fn input_value(&self, client: &mut VictauriClient) -> Result<String, TestError> {
476        let val = self
477            .eval_on_element(client, "return el.value || \"\";")
478            .await?;
479        Ok(value_to_string(&val))
480    }
481
482    /// Whether the resolved element is visible.
483    ///
484    /// # Errors
485    ///
486    /// Returns [`TestError::ElementNotFound`] if no element matches.
487    pub async fn is_visible(&self, client: &mut VictauriClient) -> Result<bool, TestError> {
488        let el = self.resolve_one(client).await?;
489        Ok(el.visible)
490    }
491
492    /// Whether the resolved element is enabled (not disabled).
493    ///
494    /// # Errors
495    ///
496    /// Returns [`TestError::ElementNotFound`] if no element matches.
497    pub async fn is_enabled(&self, client: &mut VictauriClient) -> Result<bool, TestError> {
498        let el = self.resolve_one(client).await?;
499        Ok(el.enabled)
500    }
501
502    /// Whether the resolved element is checked (checkbox/radio).
503    ///
504    /// # Errors
505    ///
506    /// Returns [`TestError::ElementNotFound`] if no element matches.
507    pub async fn is_checked(&self, client: &mut VictauriClient) -> Result<bool, TestError> {
508        let val = self.eval_on_element(client, "return !!el.checked;").await?;
509        Ok(val.as_bool().unwrap_or(false))
510    }
511
512    /// Whether the resolved element currently has focus.
513    ///
514    /// # Errors
515    ///
516    /// Returns [`TestError::ElementNotFound`] if no element matches.
517    pub async fn is_focused(&self, client: &mut VictauriClient) -> Result<bool, TestError> {
518        let val = self
519            .eval_on_element(client, "return document.activeElement === el;")
520            .await?;
521        Ok(val.as_bool().unwrap_or(false))
522    }
523
524    /// Count the number of elements matching this locator.
525    ///
526    /// # Errors
527    ///
528    /// Returns errors from [`VictauriClient::call_tool`].
529    pub async fn count(&self, client: &mut VictauriClient) -> Result<usize, TestError> {
530        let all = self.resolve_all(client).await?;
531        Ok(all.len())
532    }
533
534    /// Get the bounding rectangle of the resolved element, if available.
535    ///
536    /// # Errors
537    ///
538    /// Returns [`TestError::ElementNotFound`] if no element matches.
539    pub async fn bounding_box(
540        &self,
541        client: &mut VictauriClient,
542    ) -> Result<Option<Bounds>, TestError> {
543        let el = self.resolve_one(client).await?;
544        Ok(el.bounds)
545    }
546
547    /// Read the value of an HTML attribute on the resolved element.
548    ///
549    /// # Errors
550    ///
551    /// Returns [`TestError::ElementNotFound`] if no element matches.
552    pub async fn get_attribute(
553        &self,
554        client: &mut VictauriClient,
555        attr_name: &str,
556    ) -> Result<Option<String>, TestError> {
557        let escaped = attr_name.replace('\\', "\\\\").replace('"', "\\\"");
558        let js_body = format!("return el.getAttribute(\"{escaped}\");");
559        let val = self.eval_on_element(client, &js_body).await?;
560        if val.is_null() {
561            Ok(None)
562        } else {
563            Ok(Some(value_to_string(&val)))
564        }
565    }
566
567    /// Resolve all matching elements (ignoring the pick setting).
568    ///
569    /// # Errors
570    ///
571    /// Returns errors from [`VictauriClient::call_tool`].
572    pub async fn all(&self, client: &mut VictauriClient) -> Result<Vec<LocatorMatch>, TestError> {
573        self.resolve_all(client).await
574    }
575
576    /// Get `textContent` for every matching element.
577    ///
578    /// # Errors
579    ///
580    /// Returns errors from [`VictauriClient::call_tool`].
581    pub async fn all_text_contents(
582        &self,
583        client: &mut VictauriClient,
584    ) -> Result<Vec<String>, TestError> {
585        let elements = self.resolve_all(client).await?;
586        let mut texts = Vec::with_capacity(elements.len());
587        for el in &elements {
588            let code = format!(
589                "(function() {{ var el = window.__VICTAURI__?.getRef({}); \
590                 if (!el) return \"\"; \
591                 return el.textContent || \"\"; }})()",
592                serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string()),
593            );
594            let val = client.eval_js(&code).await?;
595            texts.push(value_to_string(&val));
596        }
597        Ok(texts)
598    }
599
600    // ── Expectations ────────────────────────────────────────────────────
601
602    /// Create an auto-waiting expectation builder for this locator.
603    pub fn expect<'a>(&'a self, client: &'a mut VictauriClient) -> LocatorExpect<'a> {
604        LocatorExpect {
605            locator: self,
606            client,
607            timeout_ms: 5000,
608            poll_ms: 200,
609            negated: false,
610        }
611    }
612
613    // ── Resolution internals ────────────────────────────────────────────
614
615    async fn resolve_all(
616        &self,
617        client: &mut VictauriClient,
618    ) -> Result<Vec<LocatorMatch>, TestError> {
619        let query = self.build_query();
620        let result = client.find_elements(query).await?;
621        let mut elements = Self::parse_elements(&result);
622        elements = self.apply_filters(elements);
623        Ok(elements)
624    }
625
626    async fn resolve_one(&self, client: &mut VictauriClient) -> Result<LocatorMatch, TestError> {
627        let all = self.resolve_all(client).await?;
628        self.pick_one(all)
629    }
630
631    fn pick_one(&self, all: Vec<LocatorMatch>) -> Result<LocatorMatch, TestError> {
632        if all.is_empty() {
633            return Err(TestError::ElementNotFound(format!(
634                "no elements match {self}"
635            )));
636        }
637        match self.pick {
638            Pick::First => Ok(all.into_iter().next().expect("checked non-empty")),
639            Pick::Last => Ok(all.into_iter().last().expect("checked non-empty")),
640            Pick::Nth(n) => {
641                let total = all.len();
642                all.into_iter().nth(n).ok_or_else(|| {
643                    TestError::ElementNotFound(format!(
644                        "{self}: wanted index {n} but only {total} elements matched"
645                    ))
646                })
647            }
648        }
649    }
650
651    fn build_query(&self) -> Value {
652        let mut query = json!({});
653        match &self.strategy {
654            Strategy::Role(r) => {
655                query["role"] = json!(r);
656            }
657            Strategy::Text(t) => {
658                query["text"] = json!(t);
659            }
660            Strategy::TextExact(t) => {
661                query["text"] = json!(t);
662                query["exact"] = json!(true);
663            }
664            Strategy::TestId(id) => {
665                query["test_id"] = json!(id);
666            }
667            Strategy::Css(sel) => {
668                query["css"] = json!(sel);
669            }
670            Strategy::Label(t) => {
671                query["label"] = json!(t);
672            }
673            Strategy::Placeholder(t) => {
674                query["placeholder"] = json!(t);
675            }
676            Strategy::AltText(a) => {
677                query["alt"] = json!(a);
678            }
679            Strategy::Title(t) => {
680                query["title_attr"] = json!(t);
681            }
682        }
683
684        // Promote filters that the bridge can handle natively into the query
685        for filter in &self.filters {
686            match filter {
687                Filter::Role(r) => {
688                    query["role"] = json!(r);
689                }
690                Filter::Name(n) => {
691                    query["name"] = json!(n);
692                }
693                Filter::Tag(t) => {
694                    query["tag"] = json!(t);
695                }
696                // Text, TextExact, and HasAttribute are applied client-side
697                Filter::Text(_) | Filter::TextExact(_) | Filter::HasAttribute(_, _) => {}
698            }
699        }
700
701        query["max_results"] = json!(50);
702        query
703    }
704
705    fn apply_filters(&self, elements: Vec<LocatorMatch>) -> Vec<LocatorMatch> {
706        let mut result = elements;
707
708        // TextExact strategy requires exact client-side match (bridge `text` with
709        // `exact:true` may not be supported on all versions).
710        if let Strategy::TextExact(ref expected) = self.strategy {
711            result.retain(|el| {
712                el.text
713                    .as_deref()
714                    .is_some_and(|t| t.trim() == expected.as_str())
715            });
716        }
717
718        for filter in &self.filters {
719            match filter {
720                Filter::Text(expected) => {
721                    let lower = expected.to_lowercase();
722                    result.retain(|el| {
723                        el.text
724                            .as_deref()
725                            .is_some_and(|t| t.to_lowercase().contains(&lower))
726                    });
727                }
728                Filter::TextExact(expected) => {
729                    result.retain(|el| {
730                        el.text
731                            .as_deref()
732                            .is_some_and(|t| t.trim() == expected.as_str())
733                    });
734                }
735                Filter::Role(expected) => {
736                    result.retain(|el| el.role.as_deref().is_some_and(|r| r == expected.as_str()));
737                }
738                Filter::Name(expected) => {
739                    let lower = expected.to_lowercase();
740                    result.retain(|el| {
741                        el.name
742                            .as_deref()
743                            .is_some_and(|n| n.to_lowercase().contains(&lower))
744                    });
745                }
746                Filter::Tag(expected) => {
747                    result.retain(|el| el.tag == *expected);
748                }
749                // HasAttribute cannot be checked without DOM access; keep all.
750                Filter::HasAttribute(_, _) => {}
751            }
752        }
753
754        result
755    }
756
757    fn parse_elements(result: &Value) -> Vec<LocatorMatch> {
758        let array = result
759            .as_array()
760            .or_else(|| result.get("elements").and_then(Value::as_array));
761
762        let Some(arr) = array else {
763            return Vec::new();
764        };
765
766        let mut out = Vec::with_capacity(arr.len());
767        for item in arr {
768            let Some(ref_id) = item.get("ref_id").and_then(Value::as_str) else {
769                continue;
770            };
771            let tag = item
772                .get("tag")
773                .and_then(Value::as_str)
774                .unwrap_or("unknown")
775                .to_string();
776
777            let bounds = item.get("bounds").and_then(|b| {
778                Some(Bounds {
779                    x: b.get("x")?.as_f64()?,
780                    y: b.get("y")?.as_f64()?,
781                    width: b.get("width")?.as_f64()?,
782                    height: b.get("height")?.as_f64()?,
783                })
784            });
785
786            out.push(LocatorMatch {
787                ref_id: ref_id.to_string(),
788                tag,
789                role: item.get("role").and_then(Value::as_str).map(String::from),
790                name: item.get("name").and_then(Value::as_str).map(String::from),
791                text: item.get("text").and_then(Value::as_str).map(String::from),
792                visible: item.get("visible").and_then(Value::as_bool).unwrap_or(true),
793                enabled: item.get("enabled").and_then(Value::as_bool).unwrap_or(true),
794                value: item.get("value").and_then(Value::as_str).map(String::from),
795                bounds,
796            });
797        }
798        out
799    }
800
801    async fn eval_on_element(
802        &self,
803        client: &mut VictauriClient,
804        js_body: &str,
805    ) -> Result<Value, TestError> {
806        let el = self.resolve_one(client).await?;
807        let ref_str = serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string());
808        let code = format!(
809            "(function() {{ var el = window.__VICTAURI__?.getRef({ref_str}); \
810             if (!el) return null; {js_body} }})()"
811        );
812        client.eval_js(&code).await
813    }
814}
815
816impl fmt::Display for Locator {
817    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
818        match &self.strategy {
819            Strategy::Role(r) => write!(f, "role(\"{r}\")")?,
820            Strategy::Text(t) => write!(f, "text(\"{t}\")")?,
821            Strategy::TextExact(t) => write!(f, "text_exact(\"{t}\")")?,
822            Strategy::TestId(id) => write!(f, "test_id(\"{id}\")")?,
823            Strategy::Css(s) => write!(f, "css(\"{s}\")")?,
824            Strategy::Label(t) => write!(f, "label(\"{t}\")")?,
825            Strategy::Placeholder(t) => write!(f, "placeholder(\"{t}\")")?,
826            Strategy::AltText(a) => write!(f, "alt_text(\"{a}\")")?,
827            Strategy::Title(t) => write!(f, "title(\"{t}\")")?,
828        }
829
830        for filter in &self.filters {
831            match filter {
832                Filter::Text(t) => write!(f, ".and_text(\"{t}\")")?,
833                Filter::TextExact(t) => write!(f, ".and_text_exact(\"{t}\")")?,
834                Filter::Role(r) => write!(f, ".and_role(\"{r}\")")?,
835                Filter::Name(n) => write!(f, ".name(\"{n}\")")?,
836                Filter::Tag(t) => write!(f, ".and_tag(\"{t}\")")?,
837                Filter::HasAttribute(a, None) => write!(f, ".and_has_attribute(\"{a}\", None)")?,
838                Filter::HasAttribute(a, Some(v)) => {
839                    write!(f, ".and_has_attribute(\"{a}\", Some(\"{v}\"))")?;
840                }
841            }
842        }
843
844        match &self.pick {
845            Pick::First => {}
846            Pick::Nth(n) => write!(f, ".nth({n})")?,
847            Pick::Last => write!(f, ".last()")?,
848        }
849
850        Ok(())
851    }
852}
853
854// ── LocatorExpect ───────────────────────────────────────────────────────────
855
856/// Auto-waiting assertion builder created by [`Locator::expect`].
857///
858/// Each assertion method polls the condition repeatedly until it passes or
859/// the timeout expires. Use [`.not()`](Self::not) to negate the next assertion.
860pub struct LocatorExpect<'a> {
861    locator: &'a Locator,
862    client: &'a mut VictauriClient,
863    timeout_ms: u64,
864    poll_ms: u64,
865    negated: bool,
866}
867
868impl<'a> LocatorExpect<'a> {
869    /// Override the maximum wait time in milliseconds (default: 5000).
870    #[must_use]
871    pub fn timeout_ms(mut self, ms: u64) -> Self {
872        self.timeout_ms = ms;
873        self
874    }
875
876    /// Override the polling interval in milliseconds (default: 200).
877    #[must_use]
878    pub fn poll_ms(mut self, ms: u64) -> Self {
879        self.poll_ms = ms;
880        self
881    }
882
883    /// Negate the next assertion (e.g. `.not().to_be_visible()` waits until hidden).
884    #[must_use]
885    #[allow(clippy::should_implement_trait)]
886    pub fn not(mut self) -> Self {
887        self.negated = !self.negated;
888        self
889    }
890
891    // ── Assertion methods ───────────────────────────────────────────────
892
893    /// Wait until the element is visible.
894    ///
895    /// # Errors
896    ///
897    /// Returns [`TestError::Timeout`] if the condition is not met in time.
898    pub async fn to_be_visible(self) -> Result<(), TestError> {
899        let negated = self.negated;
900        let desc = if negated {
901            format!("{} to NOT be visible", self.locator)
902        } else {
903            format!("{} to be visible", self.locator)
904        };
905        self.poll_until_simple(|el| el.visible, &desc).await
906    }
907
908    /// Wait until the element is hidden (not visible).
909    ///
910    /// # Errors
911    ///
912    /// Returns [`TestError::Timeout`] if the condition is not met in time.
913    pub async fn to_be_hidden(self) -> Result<(), TestError> {
914        let negated = self.negated;
915        let desc = if negated {
916            format!("{} to NOT be hidden", self.locator)
917        } else {
918            format!("{} to be hidden", self.locator)
919        };
920        // "hidden" means visible==false, so invert the check
921        let effective_negated = !negated;
922        self.poll_until_simple_with_negated(|el| el.visible, effective_negated, &desc)
923            .await
924    }
925
926    /// Wait until the element is enabled.
927    ///
928    /// # Errors
929    ///
930    /// Returns [`TestError::Timeout`] if the condition is not met in time.
931    pub async fn to_be_enabled(self) -> Result<(), TestError> {
932        let negated = self.negated;
933        let desc = if negated {
934            format!("{} to NOT be enabled", self.locator)
935        } else {
936            format!("{} to be enabled", self.locator)
937        };
938        self.poll_until_simple(|el| el.enabled, &desc).await
939    }
940
941    /// Wait until the element is disabled.
942    ///
943    /// # Errors
944    ///
945    /// Returns [`TestError::Timeout`] if the condition is not met in time.
946    pub async fn to_be_disabled(self) -> Result<(), TestError> {
947        let negated = self.negated;
948        let desc = if negated {
949            format!("{} to NOT be disabled", self.locator)
950        } else {
951            format!("{} to be disabled", self.locator)
952        };
953        let effective_negated = !negated;
954        self.poll_until_simple_with_negated(|el| el.enabled, effective_negated, &desc)
955            .await
956    }
957
958    /// Wait until the element has keyboard focus.
959    ///
960    /// # Errors
961    ///
962    /// Returns [`TestError::Timeout`] if the condition is not met in time.
963    pub async fn to_be_focused(self) -> Result<(), TestError> {
964        let negated = self.negated;
965        let desc = if negated {
966            format!("{} to NOT be focused", self.locator)
967        } else {
968            format!("{} to be focused", self.locator)
969        };
970        let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
971        let poll = Duration::from_millis(self.poll_ms);
972        let locator = self.locator.clone();
973        let client = self.client;
974        loop {
975            let result = check_focused(&locator, client).await;
976            let condition_met = match result {
977                Ok(met) => {
978                    if negated {
979                        !met
980                    } else {
981                        met
982                    }
983                }
984                Err(_) if negated => true,
985                Err(e) => return Err(e),
986            };
987            if condition_met {
988                return Ok(());
989            }
990            if Instant::now() >= deadline {
991                return Err(TestError::Timeout(format!(
992                    "expected {desc} within {}ms",
993                    deadline
994                        .duration_since(Instant::now().checked_sub(poll).unwrap_or(Instant::now()))
995                        .as_millis()
996                )));
997            }
998            tokio::time::sleep(poll).await;
999        }
1000    }
1001
1002    /// Wait until the element's text content equals `expected`.
1003    ///
1004    /// # Errors
1005    ///
1006    /// Returns [`TestError::Timeout`] if the condition is not met in time.
1007    pub async fn to_have_text(self, expected: &str) -> Result<(), TestError> {
1008        let negated = self.negated;
1009        let desc = if negated {
1010            format!("{} to NOT have text \"{expected}\"", self.locator)
1011        } else {
1012            format!("{} to have text \"{expected}\"", self.locator)
1013        };
1014        let expected_owned = expected.to_string();
1015        let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1016        let poll = Duration::from_millis(self.poll_ms);
1017        let locator = self.locator.clone();
1018        let client = self.client;
1019        loop {
1020            let result = check_text_content(&locator, client).await;
1021            let condition_met = match result {
1022                Ok(actual) => {
1023                    let matches = actual.trim() == expected_owned.as_str();
1024                    if negated { !matches } else { matches }
1025                }
1026                Err(_) if negated => true,
1027                Err(e) => return Err(e),
1028            };
1029            if condition_met {
1030                return Ok(());
1031            }
1032            if Instant::now() >= deadline {
1033                return Err(TestError::Timeout(format!(
1034                    "expected {desc} within {}ms",
1035                    self.timeout_ms
1036                )));
1037            }
1038            tokio::time::sleep(poll).await;
1039        }
1040    }
1041
1042    /// Wait until the element's text content contains `expected` as a substring.
1043    ///
1044    /// # Errors
1045    ///
1046    /// Returns [`TestError::Timeout`] if the condition is not met in time.
1047    pub async fn to_contain_text(self, expected: &str) -> Result<(), TestError> {
1048        let negated = self.negated;
1049        let desc = if negated {
1050            format!("{} to NOT contain text \"{expected}\"", self.locator)
1051        } else {
1052            format!("{} to contain text \"{expected}\"", self.locator)
1053        };
1054        let expected_owned = expected.to_string();
1055        let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1056        let poll = Duration::from_millis(self.poll_ms);
1057        let locator = self.locator.clone();
1058        let client = self.client;
1059        loop {
1060            let result = check_text_content(&locator, client).await;
1061            let condition_met = match result {
1062                Ok(actual) => {
1063                    let matches = actual.contains(expected_owned.as_str());
1064                    if negated { !matches } else { matches }
1065                }
1066                Err(_) if negated => true,
1067                Err(e) => return Err(e),
1068            };
1069            if condition_met {
1070                return Ok(());
1071            }
1072            if Instant::now() >= deadline {
1073                return Err(TestError::Timeout(format!(
1074                    "expected {desc} within {}ms",
1075                    self.timeout_ms
1076                )));
1077            }
1078            tokio::time::sleep(poll).await;
1079        }
1080    }
1081
1082    /// Wait until the element's input value equals `expected`.
1083    ///
1084    /// # Errors
1085    ///
1086    /// Returns [`TestError::Timeout`] if the condition is not met in time.
1087    pub async fn to_have_value(self, expected: &str) -> Result<(), TestError> {
1088        let negated = self.negated;
1089        let desc = if negated {
1090            format!("{} to NOT have value \"{expected}\"", self.locator)
1091        } else {
1092            format!("{} to have value \"{expected}\"", self.locator)
1093        };
1094        let expected_owned = expected.to_string();
1095        let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1096        let poll = Duration::from_millis(self.poll_ms);
1097        let locator = self.locator.clone();
1098        let client = self.client;
1099        loop {
1100            let result = check_input_value(&locator, client).await;
1101            let condition_met = match result {
1102                Ok(actual) => {
1103                    let matches = actual == expected_owned;
1104                    if negated { !matches } else { matches }
1105                }
1106                Err(_) if negated => true,
1107                Err(e) => return Err(e),
1108            };
1109            if condition_met {
1110                return Ok(());
1111            }
1112            if Instant::now() >= deadline {
1113                return Err(TestError::Timeout(format!(
1114                    "expected {desc} within {}ms",
1115                    self.timeout_ms
1116                )));
1117            }
1118            tokio::time::sleep(poll).await;
1119        }
1120    }
1121
1122    /// Wait until the element has the given attribute with the given value.
1123    ///
1124    /// # Errors
1125    ///
1126    /// Returns [`TestError::Timeout`] if the condition is not met in time.
1127    pub async fn to_have_attribute(self, attr_name: &str, value: &str) -> Result<(), TestError> {
1128        let negated = self.negated;
1129        let desc = if negated {
1130            format!(
1131                "{} to NOT have attribute {attr_name}=\"{value}\"",
1132                self.locator
1133            )
1134        } else {
1135            format!("{} to have attribute {attr_name}=\"{value}\"", self.locator)
1136        };
1137        let attr_owned = attr_name.to_string();
1138        let value_owned = value.to_string();
1139        let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1140        let poll = Duration::from_millis(self.poll_ms);
1141        let locator = self.locator.clone();
1142        let client = self.client;
1143        loop {
1144            let result = check_attribute(&locator, client, &attr_owned).await;
1145            let condition_met = match result {
1146                Ok(actual) => {
1147                    let matches = actual.as_deref() == Some(value_owned.as_str());
1148                    if negated { !matches } else { matches }
1149                }
1150                Err(_) if negated => true,
1151                Err(e) => return Err(e),
1152            };
1153            if condition_met {
1154                return Ok(());
1155            }
1156            if Instant::now() >= deadline {
1157                return Err(TestError::Timeout(format!(
1158                    "expected {desc} within {}ms",
1159                    self.timeout_ms
1160                )));
1161            }
1162            tokio::time::sleep(poll).await;
1163        }
1164    }
1165
1166    /// Wait until the number of matching elements equals `expected`.
1167    ///
1168    /// # Errors
1169    ///
1170    /// Returns [`TestError::Timeout`] if the condition is not met in time.
1171    pub async fn to_have_count(self, expected: usize) -> Result<(), TestError> {
1172        let negated = self.negated;
1173        let desc = if negated {
1174            format!("{} to NOT have count {expected}", self.locator)
1175        } else {
1176            format!("{} to have count {expected}", self.locator)
1177        };
1178        let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1179        let poll = Duration::from_millis(self.poll_ms);
1180        let locator = self.locator.clone();
1181        let client = self.client;
1182        loop {
1183            let result = locator.resolve_all(client).await;
1184            let condition_met = match result {
1185                Ok(all) => {
1186                    let matches = all.len() == expected;
1187                    if negated { !matches } else { matches }
1188                }
1189                Err(_) if negated && expected != 0 => true,
1190                Err(_) if !negated && expected == 0 => true,
1191                Err(e) => return Err(e),
1192            };
1193            if condition_met {
1194                return Ok(());
1195            }
1196            if Instant::now() >= deadline {
1197                return Err(TestError::Timeout(format!(
1198                    "expected {desc} within {}ms",
1199                    self.timeout_ms
1200                )));
1201            }
1202            tokio::time::sleep(poll).await;
1203        }
1204    }
1205
1206    /// Wait until the element is checked (checkbox/radio).
1207    ///
1208    /// # Errors
1209    ///
1210    /// Returns [`TestError::Timeout`] if the condition is not met in time.
1211    pub async fn to_be_checked(self) -> Result<(), TestError> {
1212        let negated = self.negated;
1213        let desc = if negated {
1214            format!("{} to NOT be checked", self.locator)
1215        } else {
1216            format!("{} to be checked", self.locator)
1217        };
1218        let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1219        let poll = Duration::from_millis(self.poll_ms);
1220        let locator = self.locator.clone();
1221        let client = self.client;
1222        loop {
1223            let result = check_checked(&locator, client).await;
1224            let condition_met = match result {
1225                Ok(checked) => {
1226                    if negated {
1227                        !checked
1228                    } else {
1229                        checked
1230                    }
1231                }
1232                Err(_) if negated => true,
1233                Err(e) => return Err(e),
1234            };
1235            if condition_met {
1236                return Ok(());
1237            }
1238            if Instant::now() >= deadline {
1239                return Err(TestError::Timeout(format!(
1240                    "expected {desc} within {}ms",
1241                    self.timeout_ms
1242                )));
1243            }
1244            tokio::time::sleep(poll).await;
1245        }
1246    }
1247
1248    /// Wait until the element is unchecked.
1249    ///
1250    /// # Errors
1251    ///
1252    /// Returns [`TestError::Timeout`] if the condition is not met in time.
1253    pub async fn to_be_unchecked(self) -> Result<(), TestError> {
1254        let negated = self.negated;
1255        let desc = if negated {
1256            format!("{} to NOT be unchecked", self.locator)
1257        } else {
1258            format!("{} to be unchecked", self.locator)
1259        };
1260        let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1261        let poll = Duration::from_millis(self.poll_ms);
1262        let locator = self.locator.clone();
1263        let client = self.client;
1264        loop {
1265            let result = check_checked(&locator, client).await;
1266            let condition_met = match result {
1267                Ok(checked) => {
1268                    let unchecked = !checked;
1269                    if negated { !unchecked } else { unchecked }
1270                }
1271                Err(_) if negated => true,
1272                Err(e) => return Err(e),
1273            };
1274            if condition_met {
1275                return Ok(());
1276            }
1277            if Instant::now() >= deadline {
1278                return Err(TestError::Timeout(format!(
1279                    "expected {desc} within {}ms",
1280                    self.timeout_ms
1281                )));
1282            }
1283            tokio::time::sleep(poll).await;
1284        }
1285    }
1286
1287    /// Wait until the element exists in the DOM.
1288    ///
1289    /// # Errors
1290    ///
1291    /// Returns [`TestError::Timeout`] if the condition is not met in time.
1292    pub async fn to_be_attached(self) -> Result<(), TestError> {
1293        let negated = self.negated;
1294        let desc = if negated {
1295            format!("{} to NOT be attached", self.locator)
1296        } else {
1297            format!("{} to be attached", self.locator)
1298        };
1299        let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1300        let poll = Duration::from_millis(self.poll_ms);
1301        let locator = self.locator.clone();
1302        let client = self.client;
1303        loop {
1304            let result = locator.resolve_one(client).await;
1305            let condition_met = match result {
1306                Ok(_) => !negated,
1307                Err(TestError::ElementNotFound(_)) => negated,
1308                Err(e) => return Err(e),
1309            };
1310            if condition_met {
1311                return Ok(());
1312            }
1313            if Instant::now() >= deadline {
1314                return Err(TestError::Timeout(format!(
1315                    "expected {desc} within {}ms",
1316                    self.timeout_ms
1317                )));
1318            }
1319            tokio::time::sleep(poll).await;
1320        }
1321    }
1322
1323    /// Wait until the element is removed from the DOM.
1324    ///
1325    /// # Errors
1326    ///
1327    /// Returns [`TestError::Timeout`] if the condition is not met in time.
1328    pub async fn to_be_detached(self) -> Result<(), TestError> {
1329        let negated = self.negated;
1330        let desc = if negated {
1331            format!("{} to NOT be detached", self.locator)
1332        } else {
1333            format!("{} to be detached", self.locator)
1334        };
1335        let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1336        let poll = Duration::from_millis(self.poll_ms);
1337        let locator = self.locator.clone();
1338        let client = self.client;
1339        loop {
1340            let result = locator.resolve_one(client).await;
1341            let condition_met = match result {
1342                Ok(_) => negated,
1343                Err(TestError::ElementNotFound(_)) => !negated,
1344                Err(e) => return Err(e),
1345            };
1346            if condition_met {
1347                return Ok(());
1348            }
1349            if Instant::now() >= deadline {
1350                return Err(TestError::Timeout(format!(
1351                    "expected {desc} within {}ms",
1352                    self.timeout_ms
1353                )));
1354            }
1355            tokio::time::sleep(poll).await;
1356        }
1357    }
1358
1359    // ── Internal polling helpers ─────────────────────────────────────────
1360
1361    /// Poll a condition that only depends on the resolved `LocatorMatch` fields.
1362    async fn poll_until_simple<F>(self, check: F, description: &str) -> Result<(), TestError>
1363    where
1364        F: Fn(&LocatorMatch) -> bool,
1365    {
1366        let negated = self.negated;
1367        self.poll_until_simple_with_negated(check, negated, description)
1368            .await
1369    }
1370
1371    async fn poll_until_simple_with_negated<F>(
1372        self,
1373        check: F,
1374        negated: bool,
1375        description: &str,
1376    ) -> Result<(), TestError>
1377    where
1378        F: Fn(&LocatorMatch) -> bool,
1379    {
1380        let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1381        let poll = Duration::from_millis(self.poll_ms);
1382        let locator = self.locator.clone();
1383        let client = self.client;
1384        loop {
1385            let result = locator.resolve_one(client).await;
1386            let condition_met = match result {
1387                Ok(el) => {
1388                    let raw = check(&el);
1389                    if negated { !raw } else { raw }
1390                }
1391                Err(TestError::ElementNotFound(_)) if negated => true,
1392                Err(e @ TestError::ElementNotFound(_)) => {
1393                    if Instant::now() >= deadline {
1394                        return Err(e);
1395                    }
1396                    false
1397                }
1398                Err(e) => return Err(e),
1399            };
1400            if condition_met {
1401                return Ok(());
1402            }
1403            if Instant::now() >= deadline {
1404                return Err(TestError::Timeout(format!(
1405                    "expected {description} within {}ms",
1406                    self.timeout_ms
1407                )));
1408            }
1409            tokio::time::sleep(poll).await;
1410        }
1411    }
1412}
1413
1414// ── Free-standing check functions (avoid lifetime issues with closures) ─────
1415
1416async fn check_focused(locator: &Locator, client: &mut VictauriClient) -> Result<bool, TestError> {
1417    let el = locator.resolve_one(client).await?;
1418    let ref_str = serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string());
1419    let code = format!(
1420        "(function() {{ var el = window.__VICTAURI__?.getRef({ref_str}); \
1421         if (!el) return false; return document.activeElement === el; }})()"
1422    );
1423    let val = client.eval_js(&code).await?;
1424    Ok(val.as_bool().unwrap_or(false))
1425}
1426
1427async fn check_text_content(
1428    locator: &Locator,
1429    client: &mut VictauriClient,
1430) -> Result<String, TestError> {
1431    let el = locator.resolve_one(client).await?;
1432    let ref_str = serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string());
1433    let code = format!(
1434        "(function() {{ var el = window.__VICTAURI__?.getRef({ref_str}); \
1435         if (!el) return \"\"; return el.textContent || \"\"; }})()"
1436    );
1437    let val = client.eval_js(&code).await?;
1438    Ok(value_to_string(&val))
1439}
1440
1441async fn check_input_value(
1442    locator: &Locator,
1443    client: &mut VictauriClient,
1444) -> Result<String, TestError> {
1445    let el = locator.resolve_one(client).await?;
1446    let ref_str = serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string());
1447    let code = format!(
1448        "(function() {{ var el = window.__VICTAURI__?.getRef({ref_str}); \
1449         if (!el) return \"\"; return el.value || \"\"; }})()"
1450    );
1451    let val = client.eval_js(&code).await?;
1452    Ok(value_to_string(&val))
1453}
1454
1455async fn check_attribute(
1456    locator: &Locator,
1457    client: &mut VictauriClient,
1458    attr_name: &str,
1459) -> Result<Option<String>, TestError> {
1460    let el = locator.resolve_one(client).await?;
1461    let ref_str = serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string());
1462    let escaped = attr_name.replace('\\', "\\\\").replace('"', "\\\"");
1463    let code = format!(
1464        "(function() {{ var el = window.__VICTAURI__?.getRef({ref_str}); \
1465         if (!el) return null; return el.getAttribute(\"{escaped}\"); }})()"
1466    );
1467    let val = client.eval_js(&code).await?;
1468    if val.is_null() {
1469        Ok(None)
1470    } else {
1471        Ok(Some(value_to_string(&val)))
1472    }
1473}
1474
1475async fn check_checked(locator: &Locator, client: &mut VictauriClient) -> Result<bool, TestError> {
1476    let el = locator.resolve_one(client).await?;
1477    let ref_str = serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string());
1478    let code = format!(
1479        "(function() {{ var el = window.__VICTAURI__?.getRef({ref_str}); \
1480         if (!el) return false; return !!el.checked; }})()"
1481    );
1482    let val = client.eval_js(&code).await?;
1483    Ok(val.as_bool().unwrap_or(false))
1484}
1485
1486// ── Helpers ─────────────────────────────────────────────────────────────────
1487
1488fn value_to_string(val: &Value) -> String {
1489    match val {
1490        Value::String(s) => s.clone(),
1491        Value::Null => String::new(),
1492        other => other.to_string(),
1493    }
1494}
1495
1496// ── Tests ───────────────────────────────────────────────────────────────────
1497
1498#[cfg(test)]
1499mod tests {
1500    use super::*;
1501    use serde_json::json;
1502
1503    #[test]
1504    fn locator_role_build_query() {
1505        let loc = Locator::role("button");
1506        let q = loc.build_query();
1507        assert_eq!(q["role"], json!("button"));
1508        assert_eq!(q["max_results"], json!(50));
1509    }
1510
1511    #[test]
1512    fn locator_text_build_query() {
1513        let loc = Locator::text("Submit");
1514        let q = loc.build_query();
1515        assert_eq!(q["text"], json!("Submit"));
1516    }
1517
1518    #[test]
1519    fn locator_test_id_build_query() {
1520        let loc = Locator::test_id("email");
1521        let q = loc.build_query();
1522        assert_eq!(q["test_id"], json!("email"));
1523    }
1524
1525    #[test]
1526    fn locator_css_build_query() {
1527        let loc = Locator::css(".card > h2");
1528        let q = loc.build_query();
1529        assert_eq!(q["css"], json!(".card > h2"));
1530    }
1531
1532    #[test]
1533    fn locator_with_name_filter() {
1534        let loc = Locator::role("button").name("Submit");
1535        let q = loc.build_query();
1536        assert_eq!(q["role"], json!("button"));
1537        assert_eq!(q["name"], json!("Submit"));
1538    }
1539
1540    #[test]
1541    fn locator_with_tag_filter() {
1542        let loc = Locator::text("Click me").and_tag("button");
1543        let q = loc.build_query();
1544        assert_eq!(q["text"], json!("Click me"));
1545        assert_eq!(q["tag"], json!("button"));
1546    }
1547
1548    #[test]
1549    fn locator_nth_selection() {
1550        let loc = Locator::css("li").nth(3);
1551        match loc.pick {
1552            Pick::Nth(n) => assert_eq!(n, 3),
1553            _ => panic!("expected Pick::Nth"),
1554        }
1555    }
1556
1557    #[test]
1558    fn locator_first_last() {
1559        let first = Locator::css("p").first();
1560        assert!(matches!(first.pick, Pick::First));
1561
1562        let last = Locator::css("p").last();
1563        assert!(matches!(last.pick, Pick::Last));
1564    }
1565
1566    #[test]
1567    fn parse_elements_array() {
1568        let data = json!([
1569            {"ref_id": "e1", "tag": "button", "role": "button", "name": "OK", "text": "OK",
1570             "visible": true, "enabled": true, "value": null,
1571             "bounds": {"x": 10.0, "y": 20.0, "width": 80.0, "height": 30.0}},
1572            {"ref_id": "e2", "tag": "input", "role": "textbox", "name": null, "text": "",
1573             "visible": true, "enabled": false, "value": "hello",
1574             "bounds": {"x": 0.0, "y": 0.0, "width": 200.0, "height": 24.0}}
1575        ]);
1576        let elements = Locator::parse_elements(&data);
1577        assert_eq!(elements.len(), 2);
1578        assert_eq!(elements[0].ref_id, "e1");
1579        assert_eq!(elements[0].tag, "button");
1580        assert!(elements[0].visible);
1581        assert!(elements[0].enabled);
1582        assert_eq!(elements[0].bounds.unwrap().width, 80.0);
1583        assert_eq!(elements[1].ref_id, "e2");
1584        assert!(!elements[1].enabled);
1585        assert_eq!(elements[1].value.as_deref(), Some("hello"));
1586    }
1587
1588    #[test]
1589    fn parse_elements_object() {
1590        let data = json!({
1591            "elements": [
1592                {"ref_id": "e5", "tag": "div", "visible": true, "enabled": true}
1593            ]
1594        });
1595        let elements = Locator::parse_elements(&data);
1596        assert_eq!(elements.len(), 1);
1597        assert_eq!(elements[0].ref_id, "e5");
1598        assert_eq!(elements[0].tag, "div");
1599    }
1600
1601    #[test]
1602    fn parse_elements_empty() {
1603        let data = json!([]);
1604        let elements = Locator::parse_elements(&data);
1605        assert!(elements.is_empty());
1606
1607        let data2 = json!({"elements": []});
1608        let elements2 = Locator::parse_elements(&data2);
1609        assert!(elements2.is_empty());
1610
1611        let data3 = json!(null);
1612        let elements3 = Locator::parse_elements(&data3);
1613        assert!(elements3.is_empty());
1614    }
1615
1616    #[test]
1617    fn apply_filters_exact_text() {
1618        let loc = Locator::role("button").and_text_exact("Submit");
1619        let elements = vec![
1620            make_match("e1", "button", Some("Submit Form")),
1621            make_match("e2", "button", Some("Submit")),
1622            make_match("e3", "button", Some("Cancel")),
1623        ];
1624        let filtered = loc.apply_filters(elements);
1625        assert_eq!(filtered.len(), 1);
1626        assert_eq!(filtered[0].ref_id, "e2");
1627    }
1628
1629    #[test]
1630    fn apply_filters_role() {
1631        let loc = Locator::text("OK").and_role("button");
1632        let elements = vec![
1633            LocatorMatch {
1634                ref_id: "e1".into(),
1635                tag: "button".into(),
1636                role: Some("button".into()),
1637                name: None,
1638                text: Some("OK".into()),
1639                visible: true,
1640                enabled: true,
1641                value: None,
1642                bounds: None,
1643            },
1644            LocatorMatch {
1645                ref_id: "e2".into(),
1646                tag: "span".into(),
1647                role: Some("generic".into()),
1648                name: None,
1649                text: Some("OK".into()),
1650                visible: true,
1651                enabled: true,
1652                value: None,
1653                bounds: None,
1654            },
1655        ];
1656        let filtered = loc.apply_filters(elements);
1657        assert_eq!(filtered.len(), 1);
1658        assert_eq!(filtered[0].ref_id, "e1");
1659    }
1660
1661    #[test]
1662    fn apply_filters_tag() {
1663        let loc = Locator::role("button").and_tag("a");
1664        let elements = vec![
1665            LocatorMatch {
1666                ref_id: "e1".into(),
1667                tag: "button".into(),
1668                role: Some("button".into()),
1669                name: None,
1670                text: None,
1671                visible: true,
1672                enabled: true,
1673                value: None,
1674                bounds: None,
1675            },
1676            LocatorMatch {
1677                ref_id: "e2".into(),
1678                tag: "a".into(),
1679                role: Some("button".into()),
1680                name: None,
1681                text: None,
1682                visible: true,
1683                enabled: true,
1684                value: None,
1685                bounds: None,
1686            },
1687        ];
1688        let filtered = loc.apply_filters(elements);
1689        assert_eq!(filtered.len(), 1);
1690        assert_eq!(filtered[0].ref_id, "e2");
1691    }
1692
1693    #[test]
1694    fn locator_display_role() {
1695        let loc = Locator::role("button").name("Submit");
1696        assert_eq!(loc.to_string(), "role(\"button\").name(\"Submit\")");
1697    }
1698
1699    #[test]
1700    fn locator_display_css_nth() {
1701        let loc = Locator::css(".card").nth(2);
1702        assert_eq!(loc.to_string(), "css(\".card\").nth(2)");
1703    }
1704
1705    #[test]
1706    fn locator_clone_and_modify() {
1707        let base = Locator::role("button");
1708        let submit = base.clone().name("Submit");
1709        let cancel = base.clone().name("Cancel");
1710
1711        assert_eq!(base.to_string(), "role(\"button\")");
1712        assert_eq!(submit.to_string(), "role(\"button\").name(\"Submit\")");
1713        assert_eq!(cancel.to_string(), "role(\"button\").name(\"Cancel\")");
1714    }
1715
1716    #[test]
1717    fn locator_send_sync() {
1718        fn assert_send_sync<T: Send + Sync>() {}
1719        assert_send_sync::<Locator>();
1720        assert_send_sync::<LocatorMatch>();
1721        assert_send_sync::<Bounds>();
1722    }
1723
1724    #[test]
1725    fn locator_label_build_query() {
1726        let loc = Locator::label("Email");
1727        let q = loc.build_query();
1728        assert_eq!(q["label"], json!("Email"));
1729    }
1730
1731    #[test]
1732    fn locator_placeholder_build_query() {
1733        let loc = Locator::placeholder("Enter email");
1734        let q = loc.build_query();
1735        assert_eq!(q["placeholder"], json!("Enter email"));
1736    }
1737
1738    #[test]
1739    fn locator_alt_text_build_query() {
1740        let loc = Locator::alt_text("Logo");
1741        let q = loc.build_query();
1742        assert_eq!(q["alt"], json!("Logo"));
1743    }
1744
1745    #[test]
1746    fn locator_title_build_query() {
1747        let loc = Locator::title("Close");
1748        let q = loc.build_query();
1749        assert_eq!(q["title_attr"], json!("Close"));
1750    }
1751
1752    #[test]
1753    fn locator_text_exact_build_query() {
1754        let loc = Locator::text_exact("Submit");
1755        let q = loc.build_query();
1756        assert_eq!(q["text"], json!("Submit"));
1757        assert_eq!(q["exact"], json!(true));
1758    }
1759
1760    #[test]
1761    fn locator_display_all_strategies() {
1762        assert_eq!(Locator::text("hi").to_string(), "text(\"hi\")");
1763        assert_eq!(Locator::text_exact("hi").to_string(), "text_exact(\"hi\")");
1764        assert_eq!(Locator::test_id("x").to_string(), "test_id(\"x\")");
1765        assert_eq!(Locator::label("E").to_string(), "label(\"E\")");
1766        assert_eq!(Locator::placeholder("p").to_string(), "placeholder(\"p\")");
1767        assert_eq!(Locator::alt_text("a").to_string(), "alt_text(\"a\")");
1768        assert_eq!(Locator::title("t").to_string(), "title(\"t\")");
1769    }
1770
1771    #[test]
1772    fn locator_display_has_attribute() {
1773        let loc = Locator::css("input")
1774            .and_has_attribute("required", None)
1775            .and_has_attribute("type", Some("email"));
1776        assert_eq!(
1777            loc.to_string(),
1778            "css(\"input\").and_has_attribute(\"required\", None).and_has_attribute(\"type\", Some(\"email\"))"
1779        );
1780    }
1781
1782    #[test]
1783    fn locator_display_last() {
1784        let loc = Locator::role("listitem").last();
1785        assert_eq!(loc.to_string(), "role(\"listitem\").last()");
1786    }
1787
1788    #[test]
1789    fn locator_display_and_text() {
1790        let loc = Locator::role("link").and_text("docs");
1791        assert_eq!(loc.to_string(), "role(\"link\").and_text(\"docs\")");
1792    }
1793
1794    #[test]
1795    fn parse_elements_skips_missing_ref_id() {
1796        let data = json!([
1797            {"tag": "div", "visible": true, "enabled": true},
1798            {"ref_id": "e1", "tag": "span", "visible": true, "enabled": true}
1799        ]);
1800        let elements = Locator::parse_elements(&data);
1801        assert_eq!(elements.len(), 1);
1802        assert_eq!(elements[0].ref_id, "e1");
1803    }
1804
1805    #[test]
1806    fn pick_one_first() {
1807        let loc = Locator::css("p").first();
1808        let elements = vec![
1809            make_match("e1", "p", Some("first")),
1810            make_match("e2", "p", Some("second")),
1811        ];
1812        let picked = loc.pick_one(elements).unwrap();
1813        assert_eq!(picked.ref_id, "e1");
1814    }
1815
1816    #[test]
1817    fn pick_one_last() {
1818        let loc = Locator::css("p").last();
1819        let elements = vec![
1820            make_match("e1", "p", Some("first")),
1821            make_match("e2", "p", Some("second")),
1822        ];
1823        let picked = loc.pick_one(elements).unwrap();
1824        assert_eq!(picked.ref_id, "e2");
1825    }
1826
1827    #[test]
1828    fn pick_one_nth() {
1829        let loc = Locator::css("p").nth(1);
1830        let elements = vec![
1831            make_match("e1", "p", Some("first")),
1832            make_match("e2", "p", Some("second")),
1833            make_match("e3", "p", Some("third")),
1834        ];
1835        let picked = loc.pick_one(elements).unwrap();
1836        assert_eq!(picked.ref_id, "e2");
1837    }
1838
1839    #[test]
1840    fn pick_one_empty_returns_error() {
1841        let loc = Locator::css("p");
1842        let result = loc.pick_one(Vec::new());
1843        assert!(result.is_err());
1844        let err = result.unwrap_err();
1845        assert!(matches!(err, TestError::ElementNotFound(_)));
1846    }
1847
1848    #[test]
1849    fn pick_one_nth_out_of_bounds() {
1850        let loc = Locator::css("p").nth(5);
1851        let elements = vec![make_match("e1", "p", None)];
1852        let result = loc.pick_one(elements);
1853        assert!(result.is_err());
1854    }
1855
1856    #[test]
1857    fn apply_filters_name_case_insensitive() {
1858        let loc = Locator::role("button").name("submit");
1859        let elements = vec![
1860            LocatorMatch {
1861                ref_id: "e1".into(),
1862                tag: "button".into(),
1863                role: Some("button".into()),
1864                name: Some("Submit Form".into()),
1865                text: Some("Submit".into()),
1866                visible: true,
1867                enabled: true,
1868                value: None,
1869                bounds: None,
1870            },
1871            LocatorMatch {
1872                ref_id: "e2".into(),
1873                tag: "button".into(),
1874                role: Some("button".into()),
1875                name: Some("Cancel".into()),
1876                text: Some("Cancel".into()),
1877                visible: true,
1878                enabled: true,
1879                value: None,
1880                bounds: None,
1881            },
1882        ];
1883        let filtered = loc.apply_filters(elements);
1884        assert_eq!(filtered.len(), 1);
1885        assert_eq!(filtered[0].ref_id, "e1");
1886    }
1887
1888    #[test]
1889    fn apply_filters_text_case_insensitive() {
1890        let loc = Locator::role("button").and_text("submit");
1891        let elements = vec![
1892            make_match("e1", "button", Some("Submit Form")),
1893            make_match("e2", "button", Some("Cancel")),
1894        ];
1895        let filtered = loc.apply_filters(elements);
1896        assert_eq!(filtered.len(), 1);
1897        assert_eq!(filtered[0].ref_id, "e1");
1898    }
1899
1900    #[test]
1901    fn text_exact_strategy_filters_client_side() {
1902        let loc = Locator::text_exact("OK");
1903        let elements = vec![
1904            make_match("e1", "span", Some("OK")),
1905            make_match("e2", "span", Some("OK button")),
1906        ];
1907        let filtered = loc.apply_filters(elements);
1908        assert_eq!(filtered.len(), 1);
1909        assert_eq!(filtered[0].ref_id, "e1");
1910    }
1911
1912    #[test]
1913    fn bounds_deserialize() {
1914        let json_str = r#"{"x":10.5,"y":20.0,"width":100.0,"height":50.5}"#;
1915        let bounds: Bounds = serde_json::from_str(json_str).unwrap();
1916        assert_eq!(bounds.x, 10.5);
1917        assert_eq!(bounds.height, 50.5);
1918    }
1919
1920    #[test]
1921    fn bounds_serialize_roundtrip() {
1922        let bounds = Bounds {
1923            x: 1.0,
1924            y: 2.0,
1925            width: 3.0,
1926            height: 4.0,
1927        };
1928        let json = serde_json::to_string(&bounds).unwrap();
1929        let deserialized: Bounds = serde_json::from_str(&json).unwrap();
1930        assert_eq!(bounds, deserialized);
1931    }
1932
1933    #[test]
1934    fn value_to_string_converts_types() {
1935        assert_eq!(value_to_string(&json!("hello")), "hello");
1936        assert_eq!(value_to_string(&json!(null)), "");
1937        assert_eq!(value_to_string(&json!(42)), "42");
1938        assert_eq!(value_to_string(&json!(true)), "true");
1939    }
1940
1941    // ── Test helpers ────────────────────────────────────────────────────
1942
1943    fn make_match(ref_id: &str, tag: &str, text: Option<&str>) -> LocatorMatch {
1944        LocatorMatch {
1945            ref_id: ref_id.into(),
1946            tag: tag.into(),
1947            role: None,
1948            name: None,
1949            text: text.map(String::from),
1950            visible: true,
1951            enabled: true,
1952            value: None,
1953            bounds: None,
1954        }
1955    }
1956}