Skip to main content

playwright_cdp/
locator.rs

1//! `Locator` — a Playwright-style element reference resolved lazily against
2//! the page's selector engine.
3
4use crate::error::{Error, Result};
5use crate::options::{
6    CheckOptions, ClickOptions, DragToOptions, FillOptions, FilterOptions, HoverOptions,
7    PressOptions, PressSequentiallyOptions, ScreenshotOptions, SelectOption, SelectOptions,
8    WaitForOptions,
9};
10use crate::page::Page;
11use crate::selectors;
12use crate::types::BoundingBox;
13use base64::Engine;
14use serde_json::{json, Value};
15use std::time::Duration;
16
17/// A lazy element locator.
18#[derive(Clone)]
19pub struct Locator {
20    page: Page,
21    selector: String,
22    strict: bool,
23    forced_index: Option<usize>,
24    /// Ordered list of same-origin iframe selectors to descend through before
25    /// querying `selector`. Each entry is resolved in the previous frame's
26    /// `contentDocument` (the first in the top document). Empty means query
27    /// the top document. Populated by [`FrameLocator`](crate::FrameLocator).
28    frame_chain: Vec<String>,
29}
30
31impl Locator {
32    pub(crate) fn new(page: Page, selector: String, strict: bool, forced_index: Option<usize>) -> Self {
33        Self {
34            page,
35            selector,
36            strict,
37            forced_index,
38            frame_chain: Vec::new(),
39        }
40    }
41
42    /// Construct a locator scoped to a chain of same-origin iframes' content
43    /// documents. `frame_chain` is the ordered list of iframe selectors
44    /// (outermost first); `selector` is resolved in the deepest frame.
45    pub(crate) fn new_in_frame(
46        page: Page,
47        frame_chain: Vec<String>,
48        selector: String,
49        strict: bool,
50        forced_index: Option<usize>,
51    ) -> Self {
52        Self {
53            page,
54            selector,
55            strict,
56            forced_index,
57            frame_chain,
58        }
59    }
60
61    pub fn selector(&self) -> &str {
62        &self.selector
63    }
64
65    /// Pick the element at a specific index (disables strict mode).
66    pub fn nth(&self, index: i32) -> Locator {
67        let mut l = self.clone();
68        l.strict = false;
69        l.forced_index = Some(if index < 0 { 0 } else { index as usize });
70        l
71    }
72
73    pub fn first(&self) -> Locator {
74        self.nth(0)
75    }
76
77    pub fn last(&self) -> Locator {
78        let mut l = self.clone();
79        l.strict = false;
80        l.forced_index = Some(usize::MAX); // resolved at query time
81        l
82    }
83
84    /// Number of matching elements.
85    pub async fn count(&self) -> Result<usize> {
86        self.count_query().await
87    }
88
89    /// Resolve the count, descending this locator's iframe chain.
90    async fn count_query(&self) -> Result<usize> {
91        let ctx = self.page.ctx().await;
92        if self.frame_chain.is_empty() {
93            selectors::count(self.page.session(), ctx, &self.selector).await
94        } else {
95            selectors::count_in(self.page.session(), ctx, &self.frame_chain, &self.selector).await
96        }
97    }
98
99    /// Resolve a single matching element to its `RemoteObjectId`, descending
100    /// this locator's iframe chain.
101    async fn element_query(&self, index: usize) -> Result<Option<String>> {
102        let ctx = self.page.ctx().await;
103        if self.frame_chain.is_empty() {
104            selectors::element_at(self.page.session(), ctx, &self.selector, index).await
105        } else {
106            selectors::element_at_in(self.page.session(), ctx, &self.frame_chain, &self.selector, index).await
107        }
108    }
109
110    /// Resolve to a single element RemoteObjectId, enforcing strict mode.
111    async fn resolve(&self) -> Result<String> {
112        let n = self.count_query().await?;
113        if n == 0 {
114            return Err(Error::ElementNotFound(format!(
115                "no element matched '{}'",
116                self.selector
117            )));
118        }
119        if self.strict && self.forced_index.is_none() && n > 1 {
120            return Err(Error::ElementNotFound(format!(
121                "strict mode violation: {n} elements matched '{}'",
122                self.selector
123            )));
124        }
125        let index = match self.forced_index {
126            Some(usize::MAX) => n - 1,
127            Some(i) => i,
128            None => 0,
129        };
130        self.element_query(index)
131            .await?
132            .ok_or_else(|| Error::ElementNotFound(format!("no element at index {index} for '{}'", self.selector)))
133    }
134
135    async fn release(&self, object_id: &str) {
136        let _ = self
137            .page
138            .session()
139            .send("Runtime.releaseObject", json!({ "objectId": object_id }))
140            .await;
141    }
142
143    // --- actions ---
144
145    pub async fn click(&self, opts: Option<ClickOptions>) -> Result<()> {
146        let opts = opts.unwrap_or_default();
147        let oid = self.resolve().await?;
148        let result = click_element(&self.page, &oid, &opts).await;
149        self.release(&oid).await;
150        result
151    }
152
153    pub async fn fill(&self, text: &str, _opts: Option<FillOptions>) -> Result<()> {
154        let oid = self.resolve().await?;
155        let result = selectors::eval_object(
156            self.page.session(),
157            &oid,
158            "(el, text) => { el.focus(); el.value = text; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); }",
159            json!(text),
160        )
161        .await
162        .map(|_| ());
163        self.release(&oid).await;
164        result
165    }
166
167    pub async fn type_text(&self, text: &str) -> Result<()> {
168        self.fill(text, None).await
169    }
170
171    /// Playwright-style alias for [`Self::type_text`] (the underscore mirrors
172    /// the reserved keyword). `options.delay` is currently ignored (fill-based).
173    pub async fn type_(&self, text: &str, _options: Option<PressSequentiallyOptions>) -> Result<()> {
174        self.type_text(text).await
175    }
176
177    pub async fn press(&self, key: &str, options: Option<PressOptions>) -> Result<()> {
178        let oid = self.resolve().await?;
179        let _ = self
180            .page
181            .session()
182            .send("DOM.focus", json!({ "objectId": oid }))
183            .await;
184        let delay = options.and_then(|o| o.delay).map(|ms| Duration::from_millis(ms as u64));
185        let down = self
186            .page
187            .session()
188            .send("Input.dispatchKeyEvent", json!({ "type": "keyDown", "key": key }))
189            .await;
190        if let Some(d) = delay {
191            tokio::time::sleep(d).await;
192        }
193        let up = self
194            .page
195            .session()
196            .send("Input.dispatchKeyEvent", json!({ "type": "keyUp", "key": key }))
197            .await;
198        self.release(&oid).await;
199        down.map(|_: Value| ())?;
200        up.map(|_: Value| ())
201    }
202
203    pub async fn hover(&self, opts: Option<HoverOptions>) -> Result<()> {
204        let opts = opts.unwrap_or_default();
205        let oid = self.resolve().await?;
206        let _ = self
207            .page
208            .session()
209            .send("DOM.scrollIntoViewIfNeeded", json!({ "objectId": oid }))
210            .await;
211        let (bx, by, bw, bh) = element_box(&self.page, &oid).await?;
212        let (x, y) = match &opts.position {
213            Some(p) => (bx + p.x, by + p.y),
214            None => (bx + bw / 2.0, by + bh / 2.0),
215        };
216        let result = self
217            .page
218            .session()
219            .send(
220                "Input.dispatchMouseEvent",
221                json!({ "type": "mouseMoved", "x": x, "y": y, "button": "none", "buttons": 0 }),
222            )
223            .await
224            .map(|_: Value| ());
225        self.release(&oid).await;
226        result
227    }
228
229    pub async fn focus(&self) -> Result<()> {
230        let oid = self.resolve().await?;
231        let result = self
232            .page
233            .session()
234            .send("DOM.focus", json!({ "objectId": oid }))
235            .await
236            .map(|_: Value| ());
237        self.release(&oid).await;
238        result
239    }
240
241    /// Remove focus from the element.
242    pub async fn blur(&self) -> Result<()> {
243        let oid = self.resolve().await?;
244        let r = selectors::eval_object(self.page.session(), &oid, "(el) => { el.blur(); }", Value::Null)
245            .await
246            .map(|_| ());
247        self.release(&oid).await;
248        r
249    }
250
251    /// The current value of an `<input>`/`<textarea>`/`<select>`.
252    pub async fn input_value(&self, _options: Option<()>) -> Result<String> {
253        let oid = self.resolve().await?;
254        let v: Value = {
255            let res = selectors::eval_object(
256                self.page.session(),
257                &oid,
258                "(el) => el.value",
259                Value::Null,
260            )
261            .await;
262            self.release(&oid).await;
263            res?
264        };
265        Ok(v.as_str().unwrap_or("").to_string())
266    }
267
268    /// Scroll the element into view.
269    pub async fn scroll_into_view_if_needed(&self) -> Result<()> {
270        let oid = self.resolve().await?;
271        let r = self
272            .page
273            .session()
274            .send("DOM.scrollIntoViewIfNeeded", json!({ "objectId": oid }))
275            .await
276            .map(|_: Value| ());
277        self.release(&oid).await;
278        r
279    }
280
281    /// Dispatch a synthetic DOM event on the element.
282    pub async fn dispatch_event(&self, type_: &str, init: Option<Value>) -> Result<()> {
283        let oid = self.resolve().await?;
284        let init = init.unwrap_or(Value::Null);
285        let r = selectors::eval_object(
286            self.page.session(),
287            &oid,
288            "(el, args) => { el.dispatchEvent(new Event(args.type, args.init || undefined)); }",
289            json!({ "type": type_, "init": init }),
290        )
291        .await
292        .map(|_| ());
293        self.release(&oid).await;
294        r
295    }
296
297    /// Evaluate `expression` with the resolved element bound as the first arg.
298    pub async fn evaluate<R: serde::de::DeserializeOwned>(
299        &self,
300        expression: &str,
301        arg: Option<Value>,
302    ) -> Result<R> {
303        let oid = self.resolve().await?;
304        let function = format!("(el, arg) => {{ return ({expression}); }}");
305        let v = selectors::eval_object(self.page.session(), &oid, &function, arg.unwrap_or(Value::Null))
306            .await?;
307        self.release(&oid).await;
308        serde_json::from_value::<R>(v).map_err(Error::from)
309    }
310
311    /// Evaluate `expression` against all matching elements (bound as an array).
312    pub async fn evaluate_all<R: serde::de::DeserializeOwned>(
313        &self,
314        expression: &str,
315        arg: Option<Value>,
316    ) -> Result<R> {
317        let function = if self.frame_chain.is_empty() {
318            format!(
319                "(arg) => {{ const els = self.__pwcdpInjected.querySelectorAll(document, {selector:?}); return ({expression}); }}",
320                selector = self.selector,
321                expression = expression
322            )
323        } else {
324            // Descend the same-origin iframe chain to the deepest contentDocument.
325            format!(
326                "(arg) => {{ let root = document; for (const fs of {chain:?}) {{ const f = self.__pwcdpInjected.querySelectorAll(root, fs)[0]; root = f && f.contentDocument; if (!root) {{ const els = []; return ({expression}); }} }} const els = self.__pwcdpInjected.querySelectorAll(root, {selector:?}); return ({expression}); }}",
327                chain = self.frame_chain,
328                selector = self.selector,
329                expression = expression
330            )
331        };
332        let v = selectors::eval_context(
333            self.page.session(),
334            self.page.ctx().await,
335            &function,
336            arg.unwrap_or(Value::Null),
337        )
338        .await?;
339        serde_json::from_value::<R>(v).map_err(Error::from)
340    }
341
342    // --- info ---
343
344    pub async fn text_content(&self) -> Result<Option<String>> {
345        self.read("(el) => el.textContent").await
346    }
347
348    pub async fn inner_text(&self) -> Result<String> {
349        self.read("(el) => el.innerText")
350            .await
351            .map(|v: Option<String>| v.unwrap_or_default())
352    }
353
354    pub async fn inner_html(&self) -> Result<String> {
355        self.read("(el) => el.innerHTML")
356            .await
357            .map(|v: Option<String>| v.unwrap_or_default())
358    }
359
360    pub async fn get_attribute(&self, name: &str) -> Result<Option<String>> {
361        let oid = self.resolve().await?;
362        let v = selectors::eval_object(
363            self.page.session(),
364            &oid,
365            "(el, name) => el.getAttribute(name)",
366            json!(name),
367        )
368        .await?;
369        self.release(&oid).await;
370        Ok(v.as_str().map(String::from))
371    }
372
373    async fn read<R: serde::de::DeserializeOwned>(&self, function: &str) -> Result<R> {
374        let oid = self.resolve().await?;
375        let v = selectors::eval_object(self.page.session(), &oid, function, Value::Null).await?;
376        self.release(&oid).await;
377        serde_json::from_value::<R>(v).map_err(Error::from)
378    }
379
380    pub async fn is_visible(&self) -> Result<bool> {
381        self.state("visible").await
382    }
383    pub async fn is_enabled(&self) -> Result<bool> {
384        self.state("enabled").await
385    }
386    pub async fn is_checked(&self) -> Result<bool> {
387        self.state("checked").await
388    }
389    pub async fn is_editable(&self) -> Result<bool> {
390        self.state("editable").await
391    }
392
393    async fn state(&self, field: &str) -> Result<bool> {
394        let oid = self.resolve().await?;
395        let function = format!(
396            "(el) => self.__pwcdpInjected && self.__pwcdpInjected.elementState(el).{field}"
397        );
398        let v = selectors::eval_object(self.page.session(), &oid, &function, Value::Null).await?;
399        self.release(&oid).await;
400        Ok(v.as_bool().unwrap_or(false))
401    }
402
403    pub async fn bounding_box(&self) -> Result<Option<BoundingBox>> {
404        let oid = self.resolve().await?;
405        let r = element_box(&self.page, &oid).await;
406        self.release(&oid).await;
407        match r {
408            Ok((x, y, w, h)) => Ok(Some(BoundingBox { x, y, width: w, height: h })),
409            Err(_) => Ok(None),
410        }
411    }
412
413    /// Wait until the element resolves (and, for actions, is visible).
414    pub async fn wait_for(&self, opts: Option<WaitForOptions>) -> Result<()> {
415        let timeout = opts
416            .and_then(|o| o.timeout)
417            .map(|ms| Duration::from_millis(ms as u64))
418            .unwrap_or_else(|| Duration::from_secs(30));
419        let deadline = tokio::time::Instant::now() + timeout;
420        loop {
421            if self.count().await? > 0 {
422                return Ok(());
423            }
424            if tokio::time::Instant::now() >= deadline {
425                return Err(Error::Timeout(format!(
426                    "wait_for '{}' timed out after {}ms",
427                    self.selector,
428                    timeout.as_millis()
429                )));
430            }
431            tokio::time::sleep(Duration::from_millis(100)).await;
432        }
433    }
434
435    // --- additional actions ---
436
437    /// Clear an input/textarea (fill with empty string).
438    pub async fn clear(&self, options: Option<FillOptions>) -> Result<()> {
439        self.fill("", options).await
440    }
441
442    /// Double-click (two press/release cycles with clickCount 2).
443    pub async fn dblclick(&self, options: Option<ClickOptions>) -> Result<()> {
444        let mut opts = options.unwrap_or_default();
445        opts.click_count = Some(opts.click_count.unwrap_or(2));
446        self.click(Some(opts)).await
447    }
448
449    /// Check a checkbox/radio if not already checked.
450    pub async fn check(&self, options: Option<CheckOptions>) -> Result<()> {
451        self.set_checked(true, options).await
452    }
453
454    /// Uncheck a checkbox if checked.
455    pub async fn uncheck(&self, options: Option<CheckOptions>) -> Result<()> {
456        self.set_checked(false, options).await
457    }
458
459    /// Set the checked state of a checkbox/radio, clicking only if needed.
460    pub async fn set_checked(&self, checked: bool, options: Option<CheckOptions>) -> Result<()> {
461        let opts = options.unwrap_or_default();
462        let current = self.is_checked().await?;
463        if current != checked {
464            let click_opts = ClickOptions {
465                force: opts.force,
466                position: opts.position,
467                timeout: opts.timeout,
468                trial: opts.trial,
469                ..Default::default()
470            };
471            self.click(Some(click_opts)).await?;
472        }
473        Ok(())
474    }
475
476    /// Select an `<option>` by value/label/index. Returns the now-selected values.
477    pub async fn select_option(
478        &self,
479        value: impl Into<SelectOption>,
480        _options: Option<SelectOptions>,
481    ) -> Result<Vec<String>> {
482        let value = value.into();
483        let arg: Value = match value {
484            SelectOption::Value(v) => json!({ "Value": v }),
485            SelectOption::Label(v) => json!({ "Label": v }),
486            SelectOption::Index(i) => json!({ "Index": i }),
487        };
488        let oid = self.resolve().await?;
489        let v: Value = selectors::eval_object(
490            self.page.session(),
491            &oid,
492            "(el, sel) => {
493                const opts = Array.from(el.options || []);
494                const matched = [];
495                for (const o of opts) {
496                    const hit = sel.Value != null ? o.value === sel.Value
497                            : sel.Label != null ? o.label === sel.Label
498                            : sel.Index != null ? o.index === sel.Index : false;
499                    if (hit) { o.selected = true; matched.push(o.value); }
500                }
501                el.dispatchEvent(new Event('input', { bubbles: true }));
502                el.dispatchEvent(new Event('change', { bubbles: true }));
503                return matched;
504            }",
505            arg,
506        )
507        .await?;
508        self.release(&oid).await;
509        Ok(v.as_array()
510            .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
511            .unwrap_or_default())
512    }
513
514    /// Set files on an `<input type=file>` by server-side path(s).
515    pub async fn set_input_files(&self, files: &[&str]) -> Result<()> {
516        let oid = self.resolve().await?;
517        // Resolve the remote object to a DOM nodeId via DOM.describeNode.
518        let desc = self
519            .page
520            .session()
521            .send("DOM.requestNode", json!({ "objectId": oid }))
522            .await?;
523        let node_id = desc
524            .get("nodeId")
525            .and_then(|v| v.as_i64())
526            .ok_or_else(|| Error::ProtocolError("requestNode missing nodeId".into()))?;
527        let r = self
528            .page
529            .session()
530            .send(
531                "DOM.setFileInputFiles",
532                json!({ "nodeId": node_id, "files": files }),
533            )
534            .await
535            .map(|_: Value| ());
536        self.release(&oid).await;
537        r
538    }
539
540    /// Tap (touch) the element. Approximated as a click on desktop CDP.
541    pub async fn tap(&self, options: Option<ClickOptions>) -> Result<()> {
542        self.click(options).await
543    }
544
545    /// Drag this element onto `target` via mouse motion.
546    pub async fn drag_to(&self, target: &Locator, _options: Option<DragToOptions>) -> Result<()> {
547        let oid = self.resolve().await?;
548        let _ = self
549            .page
550            .session()
551            .send("DOM.scrollIntoViewIfNeeded", json!({ "objectId": oid }))
552            .await;
553        let (sx, sy) = element_center(&self.page, &oid).await?;
554        let target_oid = target.resolve().await?;
555        let (tx, ty) = element_center(&target.page, &target_oid).await?;
556        target.release(&target_oid).await;
557
558        self.page
559            .session()
560            .send(
561                "Input.dispatchMouseEvent",
562                json!({ "type": "mouseMoved", "x": sx, "y": sy, "button": "none", "buttons": 0 }),
563            )
564            .await?;
565        self.page
566            .session()
567            .send(
568                "Input.dispatchMouseEvent",
569                json!({ "type": "mousePressed", "x": sx, "y": sy, "button": "left", "buttons": 1, "clickCount": 1 }),
570            )
571            .await?;
572        let steps = 10;
573        for i in 1..=steps {
574            let x = sx + (tx - sx) * (i as f64) / (steps as f64);
575            let y = sy + (ty - sy) * (i as f64) / (steps as f64);
576            self.page
577                .session()
578                .send(
579                    "Input.dispatchMouseEvent",
580                    json!({ "type": "mouseMoved", "x": x, "y": y, "button": "none", "buttons": 1 }),
581                )
582                .await?;
583        }
584        let r = self
585            .page
586            .session()
587            .send(
588                "Input.dispatchMouseEvent",
589                json!({ "type": "mouseReleased", "x": tx, "y": ty, "button": "left", "buttons": 0, "clickCount": 1 }),
590            )
591            .await
592            .map(|_: Value| ());
593        self.release(&oid).await;
594        r
595    }
596
597    // --- more info ---
598
599    pub async fn is_hidden(&self) -> Result<bool> {
600        Ok(!self.is_visible().await?)
601    }
602
603    pub async fn is_disabled(&self) -> Result<bool> {
604        let oid = self.resolve().await?;
605        let v = selectors::eval_object(
606            self.page.session(),
607            &oid,
608            "(el) => el.disabled === true",
609            Value::Null,
610        )
611        .await?;
612        self.release(&oid).await;
613        Ok(v.as_bool().unwrap_or(false))
614    }
615
616    pub async fn is_focused(&self) -> Result<bool> {
617        let oid = self.resolve().await?;
618        let v = selectors::eval_object(
619            self.page.session(),
620            &oid,
621            "(el) => el === document.activeElement",
622            Value::Null,
623        )
624        .await?;
625        self.release(&oid).await;
626        Ok(v.as_bool().unwrap_or(false))
627    }
628
629    /// All matching locators (one per resolved element).
630    pub async fn all(&self) -> Result<Vec<Locator>> {
631        let n = self.count().await?;
632        let mut out = Vec::with_capacity(n);
633        for i in 0..n {
634            let mut l = self.clone();
635            l.strict = false;
636            l.forced_index = Some(i);
637            out.push(l);
638        }
639        Ok(out)
640    }
641
642    pub async fn all_inner_texts(&self) -> Result<Vec<String>> {
643        let n = self.count().await?;
644        let mut out = Vec::with_capacity(n);
645        for i in 0..n {
646            if let Some(oid) = self.element_query(i).await? {
647                let v = selectors::eval_object(
648                    self.page.session(),
649                    &oid,
650                    "(el) => el.innerText",
651                    Value::Null,
652                )
653                .await
654                .ok();
655                let _ = self
656                    .page
657                    .session()
658                    .send("Runtime.releaseObject", json!({ "objectId": oid }))
659                    .await;
660                out.push(v.and_then(|x| x.as_str().map(String::from)).unwrap_or_default());
661            }
662        }
663        Ok(out)
664    }
665
666    pub async fn all_text_contents(&self) -> Result<Vec<String>> {
667        let n = self.count().await?;
668        let mut out = Vec::with_capacity(n);
669        for i in 0..n {
670            if let Some(oid) = self.element_query(i).await? {
671                let v = selectors::eval_object(
672                    self.page.session(),
673                    &oid,
674                    "(el) => el.textContent",
675                    Value::Null,
676                )
677                .await
678                .ok();
679                let _ = self
680                    .page
681                    .session()
682                    .send("Runtime.releaseObject", json!({ "objectId": oid }))
683                    .await;
684                out.push(v.and_then(|x| x.as_str().map(String::from)).unwrap_or_default());
685            }
686        }
687        Ok(out)
688    }
689
690    /// Capture a screenshot clipped to this element.
691    pub async fn screenshot(&self, opts: Option<ScreenshotOptions>) -> Result<Vec<u8>> {
692        let oid = self.resolve().await?;
693        let (x, y, w, h) = element_box(&self.page, &oid).await?;
694        self.release(&oid).await;
695
696        let opts = opts.unwrap_or_default();
697        let format = match opts.r#type.unwrap_or_default() {
698            crate::types::ScreenshotType::Png => "png",
699            crate::types::ScreenshotType::Jpeg => "jpeg",
700            crate::types::ScreenshotType::Webp => "webp",
701        };
702        let mut params = json!({
703            "format": format,
704            "clip": { "x": x, "y": y, "width": w, "height": h, "scale": 1 },
705        });
706        if opts.omit_background.unwrap_or(false) && format == "png" {
707            params["omitBackground"] = json!(true);
708        }
709        if let Some(q) = opts.quality {
710            if format == "jpeg" {
711                params["quality"] = json!(q);
712            }
713        }
714        let resp = self
715            .page
716            .session()
717            .send("Page.captureScreenshot", params)
718            .await?;
719        let data = resp
720            .get("data")
721            .and_then(|v| v.as_str())
722            .ok_or_else(|| Error::ProtocolError("screenshot missing data".into()))?;
723        let bytes = base64::engine::general_purpose::STANDARD
724            .decode(data)
725            .map_err(|e| Error::ProtocolError(format!("base64 decode: {e}")))?;
726        if let Some(path) = &opts.path {
727            tokio::fs::write(path, &bytes).await?;
728        }
729        Ok(bytes)
730    }
731
732    // --- composition ---
733
734    /// Filter matches by text presence (minimal).
735    pub fn filter(&self, options: FilterOptions) -> Locator {
736        let mut sel = self.selector.clone();
737        if let Some(t) = options.has_text {
738            sel.push_str(&format!(" >> text={t}"));
739        }
740        let mut l = self.clone();
741        l.selector = sel;
742        l
743    }
744
745    /// Chain this locator with another (both must match, in order).
746    pub fn and_(&self, other: &Locator) -> Locator {
747        let mut l = self.clone();
748        l.selector = format!("{} >> {}", self.selector, other.selector);
749        l
750    }
751
752    /// Union with another locator (either matches).
753    pub fn or_(&self, other: &Locator) -> Locator {
754        let mut l = self.clone();
755        l.selector = format!("{}, {}", self.selector, other.selector);
756        l
757    }
758
759    // --- locator-relative chaining ---
760
761    fn chain(&self, sub: impl Into<String>) -> Locator {
762        let mut l = self.clone();
763        l.selector = format!("{} >> {}", self.selector, sub.into());
764        l.forced_index = None;
765        l.strict = true;
766        l
767    }
768
769    /// Find a descendant matching `selector`.
770    pub fn locator(&self, selector: impl Into<String>) -> Locator {
771        self.chain(selector)
772    }
773
774    pub fn get_by_text(&self, text: &str, _exact: bool) -> Locator {
775        self.chain(format!("text={text}"))
776    }
777
778    pub fn get_by_label(&self, text: &str) -> Locator {
779        self.chain(format!("[aria-label=\"{}\"]", esc(text)))
780    }
781
782    pub fn get_by_placeholder(&self, text: &str) -> Locator {
783        self.chain(format!("[placeholder=\"{}\"]", esc(text)))
784    }
785
786    pub fn get_by_alt_text(&self, text: &str) -> Locator {
787        self.chain(format!("[alt=\"{}\"]", esc(text)))
788    }
789
790    pub fn get_by_title(&self, text: &str) -> Locator {
791        self.chain(format!("[title=\"{}\"]", esc(text)))
792    }
793
794    pub fn get_by_test_id(&self, test_id: &str) -> Locator {
795        self.chain(format!("[data-testid=\"{}\"]", esc(test_id)))
796    }
797
798    pub fn get_by_role(
799        &self,
800        role: crate::types::AriaRole,
801        opts: Option<crate::options::GetByRoleOptions>,
802    ) -> Locator {
803        let opts = opts.unwrap_or_default();
804        let mut sel = format!("role={}", role.as_str());
805        if let Some(name) = &opts.name {
806            sel.push_str(&format!("[name=\"{name}\"]"));
807        }
808        if opts.exact == Some(true) {
809            sel.push_str("[exact=\"true\"]");
810        }
811        self.chain(sel)
812    }
813
814    // --- aria snapshot ---
815
816    /// Capture an aria-snapshot (Playwright's YAML-ish accessibility-tree
817    /// format) rooted at this locator's element.
818    ///
819    /// Resolves the element to its `RemoteObjectId`, then asks CDP for the
820    /// partial accessibility tree rooted there (`fetchRelatives: false`) and
821    /// serializes it via [`crate::aria_snapshot`].
822    pub async fn aria_snapshot(&self) -> Result<String> {
823        let oid = self.resolve().await?;
824
825        // Resolve the element's backend DOM node id, which is how AX nodes link
826        // back to the DOM. We then fetch the full AX tree and render the
827        // subtree rooted at the matching node. (CDP's `getPartialAXTree` with
828        // `fetchRelatives: false` returns only the single node, with no
829        // descendants — insufficient for a useful snapshot.)
830        let desc = self
831            .page
832            .session()
833            .send("DOM.describeNode", json!({ "objectId": oid }))
834            .await;
835        self.release(&oid).await;
836        let desc = desc?;
837        let backend_node_id = desc
838            .get("node")
839            .and_then(|n| n.get("backendNodeId"))
840            .and_then(|v| v.as_i64())
841            .ok_or_else(|| {
842                Error::ProtocolError("DOM.describeNode missing backendNodeId".into())
843            })?;
844
845        // Best-effort: some Chrome builds need the Accessibility domain enabled.
846        let _ = self
847            .page
848            .session()
849            .send("Accessibility.enable", json!({}))
850            .await;
851        let resp = self
852            .page
853            .session()
854            .send("Accessibility.getFullAXTree", json!({}))
855            .await?;
856        let nodes = resp
857            .get("nodes")
858            .and_then(|v| v.as_array())
859            .cloned()
860            .unwrap_or_default();
861        Ok(crate::aria_snapshot::serialize(&nodes, Some(backend_node_id)))
862    }
863}
864
865fn esc(s: &str) -> String {
866    s.replace('\\', "\\\\").replace('"', "\\\"")
867}
868
869// --- element-level action helpers ---
870
871/// Compute an element's content-box as `(x, y, width, height)` where `(x, y)`
872/// is the top-left corner.
873pub(crate) async fn element_box(page: &Page, object_id: &str) -> Result<(f64, f64, f64, f64)> {
874    let resp = page
875        .session()
876        .send("DOM.getBoxModel", json!({ "objectId": object_id }))
877        .await?;
878    let model = resp
879        .get("model")
880        .ok_or_else(|| Error::ProtocolError("getBoxModel missing model".into()))?;
881    let (x, y, w, h) = quad_box(model.get("content"))
882        .ok_or_else(|| Error::ProtocolError("getBoxModel content quad malformed".into()))?;
883    // Prefer the model's own dimensions when available.
884    let mw = model.get("width").and_then(|v| v.as_f64());
885    let mh = model.get("height").and_then(|v| v.as_f64());
886    Ok((x, y, mw.unwrap_or(w), mh.unwrap_or(h)))
887}
888
889/// Center of an element's content box.
890pub(crate) async fn element_center(page: &Page, object_id: &str) -> Result<(f64, f64)> {
891    let (x, y, w, h) = element_box(page, object_id).await?;
892    Ok((x + w / 2.0, y + h / 2.0))
893}
894
895/// Wait for an element to be actionable (visible) unless skipping via `force`.
896async fn wait_actionable(
897    page: &Page,
898    object_id: &str,
899    timeout: Duration,
900) -> Result<()> {
901    let function = "(el) => self.__pwcdpInjected && self.__pwcdpInjected.elementState(el).visible";
902    let deadline = tokio::time::Instant::now() + timeout;
903    loop {
904        let v = selectors::eval_object(page.session(), object_id, function, Value::Null).await?;
905        if v.as_bool() == Some(true) {
906            return Ok(());
907        }
908        if tokio::time::Instant::now() >= deadline {
909            return Err(Error::Timeout(format!(
910                "element not visible within {}ms",
911                timeout.as_millis()
912            )));
913        }
914        tokio::time::sleep(Duration::from_millis(100)).await;
915    }
916}
917
918fn modifier_key(m: &crate::types::KeyboardModifier) -> &'static str {
919    use crate::types::KeyboardModifier::*;
920    match m {
921        Alt => "Alt",
922        Control => "Control",
923        ControlOrMeta => "Control",
924        Meta => "Meta",
925        Shift => "Shift",
926    }
927}
928
929/// Dispatch a mouse click (press + release) honoring the full `ClickOptions`.
930/// Fixes the historical `buttons`-bitmask bug: press uses the button's bitmask,
931/// release uses `0`.
932pub(crate) async fn click_element(
933    page: &Page,
934    object_id: &str,
935    opts: &ClickOptions,
936) -> Result<()> {
937    let _ = page
938        .session()
939        .send("DOM.scrollIntoViewIfNeeded", json!({ "objectId": object_id }))
940        .await;
941
942    let force = opts.force.unwrap_or(false);
943    let trial = opts.trial.unwrap_or(false);
944    if !force {
945        let timeout = opts
946            .timeout
947            .map(|ms| Duration::from_millis(ms as u64))
948            .unwrap_or_else(|| page.default_timeout());
949        wait_actionable(page, object_id, timeout).await?;
950    }
951
952    let (bx, by, bw, bh) = element_box(page, object_id).await?;
953    let (x, y) = match &opts.position {
954        Some(p) => (bx + p.x, by + p.y),
955        None => (bx + bw / 2.0, by + bh / 2.0),
956    };
957    let button = opts.button.unwrap_or_default();
958    let count = opts.click_count.unwrap_or(1);
959    let delay = opts.delay.map(|ms| Duration::from_millis(ms as u64));
960
961    // Hold modifiers.
962    if let Some(mods) = &opts.modifiers {
963        for m in mods {
964            let _ = page
965                .session()
966                .send(
967                    "Input.dispatchKeyEvent",
968                    json!({ "type": "keyDown", "key": modifier_key(m) }),
969                )
970                .await;
971        }
972    }
973
974    let result = if trial {
975        Ok(())
976    } else {
977        let mut out = Ok(());
978        page.session()
979            .send(
980                "Input.dispatchMouseEvent",
981                json!({ "type": "mouseMoved", "x": x, "y": y, "button": "none", "buttons": 0 }),
982            )
983            .await?;
984        for i in 0..count {
985            if let Err(e) = page
986                .session()
987                .send(
988                    "Input.dispatchMouseEvent",
989                    json!({ "type": "mousePressed", "x": x, "y": y, "button": button.as_str(), "buttons": button.bitmask(), "clickCount": i + 1 }),
990                )
991                .await
992            {
993                out = Err(e);
994                break;
995            }
996            if let Err(e) = page
997                .session()
998                .send(
999                    "Input.dispatchMouseEvent",
1000                    json!({ "type": "mouseReleased", "x": x, "y": y, "button": button.as_str(), "buttons": 0, "clickCount": i + 1 }),
1001                )
1002                .await
1003            {
1004                out = Err(e);
1005                break;
1006            }
1007            if let Some(d) = delay {
1008                if i + 1 < count {
1009                    tokio::time::sleep(d).await;
1010                }
1011            }
1012        }
1013        out
1014    };
1015
1016    // Release modifiers.
1017    if let Some(mods) = &opts.modifiers {
1018        for m in mods {
1019            let _ = page
1020                .session()
1021                .send(
1022                    "Input.dispatchKeyEvent",
1023                    json!({ "type": "keyUp", "key": modifier_key(m) }),
1024                )
1025                .await;
1026        }
1027    }
1028
1029    result
1030}
1031
1032fn quad_box(quad: Option<&Value>) -> Option<(f64, f64, f64, f64)> {
1033    let arr = quad.and_then(|v| v.as_array())?;
1034    let nums: Vec<f64> = arr.iter().filter_map(|v| v.as_f64()).collect();
1035    if nums.len() < 8 {
1036        return None;
1037    }
1038    let xs = [nums[0], nums[2], nums[4], nums[6]];
1039    let ys = [nums[1], nums[3], nums[5], nums[7]];
1040    let xmin = xs.iter().cloned().fold(f64::INFINITY, f64::min);
1041    let xmax = xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1042    let ymin = ys.iter().cloned().fold(f64::INFINITY, f64::min);
1043    let ymax = ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1044    Some((xmin, ymin, xmax - xmin, ymax - ymin))
1045}