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