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    /// The execution-context id of the frame this locator is scoped to, when it
30    /// was produced by a [`Frame`](crate::Frame) (rather than a
31    /// [`FrameLocator`](crate::FrameLocator) frame chain). When set and
32    /// `frame_chain` is empty, resolution runs in this context directly, so
33    /// `document` is the child frame's document.
34    frame_ctx: Option<i64>,
35}
36
37impl Locator {
38    pub(crate) fn new(page: Page, selector: String, strict: bool, forced_index: Option<usize>) -> Self {
39        Self {
40            page,
41            selector,
42            strict,
43            forced_index,
44            frame_chain: Vec::new(),
45            frame_ctx: None,
46        }
47    }
48
49    /// Construct a locator scoped to a specific frame's execution context.
50    /// Resolution runs via `Runtime.evaluate { contextId }` in that context,
51    /// so `document` resolves to the child frame's document.
52    pub(crate) fn new_in_frame_ctx(
53        page: Page,
54        frame_ctx: Option<i64>,
55        selector: String,
56        strict: bool,
57        forced_index: Option<usize>,
58    ) -> Self {
59        Self {
60            page,
61            selector,
62            strict,
63            forced_index,
64            frame_chain: Vec::new(),
65            frame_ctx,
66        }
67    }
68
69    /// Construct a locator scoped to a chain of same-origin iframes' content
70    /// documents. `frame_chain` is the ordered list of iframe selectors
71    /// (outermost first); `selector` is resolved in the deepest frame.
72    pub(crate) fn new_in_frame(
73        page: Page,
74        frame_chain: Vec<String>,
75        selector: String,
76        strict: bool,
77        forced_index: Option<usize>,
78    ) -> Self {
79        Self {
80            page,
81            selector,
82            strict,
83            forced_index,
84            frame_chain,
85            frame_ctx: None,
86        }
87    }
88
89    pub fn selector(&self) -> &str {
90        &self.selector
91    }
92
93    /// Pick the element at a specific index (disables strict mode).
94    pub fn nth(&self, index: i32) -> Locator {
95        let mut l = self.clone();
96        l.strict = false;
97        l.forced_index = Some(if index < 0 { 0 } else { index as usize });
98        l
99    }
100
101    pub fn first(&self) -> Locator {
102        self.nth(0)
103    }
104
105    pub fn last(&self) -> Locator {
106        let mut l = self.clone();
107        l.strict = false;
108        l.forced_index = Some(usize::MAX); // resolved at query time
109        l
110    }
111
112    /// Number of matching elements.
113    pub async fn count(&self) -> Result<usize> {
114        self.count_query().await
115    }
116
117    /// Resolve the count, descending this locator's iframe chain.
118    async fn count_query(&self) -> Result<usize> {
119        if self.frame_chain.is_empty() {
120            let ctx = self.resolve_ctx().await;
121            selectors::count(self.page.session(), ctx, &self.selector).await
122        } else {
123            let ctx = self.page.ctx().await;
124            selectors::count_in(self.page.session(), ctx, &self.frame_chain, &self.selector).await
125        }
126    }
127
128    /// Resolve a single matching element to its `RemoteObjectId`, descending
129    /// this locator's iframe chain.
130    async fn element_query(&self, index: usize) -> Result<Option<String>> {
131        if self.frame_chain.is_empty() {
132            let ctx = self.resolve_ctx().await;
133            selectors::element_at(self.page.session(), ctx, &self.selector, index).await
134        } else {
135            let ctx = self.page.ctx().await;
136            selectors::element_at_in(self.page.session(), ctx, &self.frame_chain, &self.selector, index).await
137        }
138    }
139
140    /// The execution context to run a frame-scoped query in: the locator's
141    /// `frame_ctx` if it was produced by a [`Frame`](crate::Frame), otherwise
142    /// the page's main-world context.
143    async fn resolve_ctx(&self) -> Option<i64> {
144        match self.frame_ctx {
145            // A pinned child-frame context may go stale across navigations; if
146            // it is gone from the page's frame map, fall back to the page ctx.
147            Some(id) => Some(id),
148            None => self.page.ctx().await,
149        }
150    }
151
152    /// Resolve to a single element RemoteObjectId, enforcing strict mode.
153    async fn resolve(&self) -> Result<String> {
154        let n = self.count_query().await?;
155        if n == 0 {
156            return Err(Error::ElementNotFound(format!(
157                "no element matched '{}'",
158                self.selector
159            )));
160        }
161        if self.strict && self.forced_index.is_none() && n > 1 {
162            return Err(Error::ElementNotFound(format!(
163                "strict mode violation: {n} elements matched '{}'",
164                self.selector
165            )));
166        }
167        let index = match self.forced_index {
168            Some(usize::MAX) => n - 1,
169            Some(i) => i,
170            None => 0,
171        };
172        self.element_query(index)
173            .await?
174            .ok_or_else(|| Error::ElementNotFound(format!("no element at index {index} for '{}'", self.selector)))
175    }
176
177    async fn release(&self, object_id: &str) {
178        let _ = self
179            .page
180            .session()
181            .send("Runtime.releaseObject", json!({ "objectId": object_id }))
182            .await;
183    }
184
185    // --- actions ---
186
187    pub async fn click(&self, opts: Option<ClickOptions>) -> Result<()> {
188        let opts = opts.unwrap_or_default();
189        let oid = self.resolve().await?;
190        let result = click_element(&self.page, &oid, &opts).await;
191        self.release(&oid).await;
192        result
193    }
194
195    pub async fn fill(&self, text: &str, _opts: Option<FillOptions>) -> Result<()> {
196        let oid = self.resolve().await?;
197        let result = selectors::eval_object(
198            self.page.session(),
199            &oid,
200            "(el, text) => { el.focus(); el.value = text; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); }",
201            json!(text),
202        )
203        .await
204        .map(|_| ());
205        self.release(&oid).await;
206        result
207    }
208
209    pub async fn type_text(&self, text: &str) -> Result<()> {
210        self.fill(text, None).await
211    }
212
213    /// Type each character of `text` sequentially, optionally pausing between
214    /// keystrokes. Unlike [`Self::fill`](crate::Locator::fill) (which sets the
215    /// value atomically), this dispatches one `keyDown`/`keyUp` per character,
216    /// so it triggers per-keystroke event handlers (e.g. typeahead search).
217    ///
218    /// `options.delay` is in milliseconds, matching Playwright.
219    pub async fn press_sequentially(&self, text: &str, opts: Option<PressSequentiallyOptions>) -> Result<()> {
220        let delay = opts
221            .and_then(|o| o.delay)
222            .map(|ms| Duration::from_millis(ms as u64));
223        let oid = self.resolve().await?;
224        // Focus so the keystrokes land on this element.
225        let _ = self
226            .page
227            .session()
228            .send("DOM.focus", json!({ "objectId": oid }))
229            .await;
230        let result = press_text(&self.page, &oid, text, delay).await;
231        self.release(&oid).await;
232        result
233    }
234
235    /// Playwright-style alias: type each character sequentially. Mirrors the
236    /// reserved keyword with a trailing underscore.
237    pub async fn type_(&self, text: &str, opts: Option<PressSequentiallyOptions>) -> Result<()> {
238        self.press_sequentially(text, opts).await
239    }
240
241    pub async fn press(&self, key: &str, options: Option<PressOptions>) -> Result<()> {
242        let oid = self.resolve().await?;
243        let delay = options.and_then(|o| o.delay).map(|ms| Duration::from_millis(ms as u64));
244        let result = press_key(&self.page, &oid, key, delay).await;
245        self.release(&oid).await;
246        result
247    }
248
249    pub async fn hover(&self, opts: Option<HoverOptions>) -> Result<()> {
250        let opts = opts.unwrap_or_default();
251        let oid = self.resolve().await?;
252        let result = hover_element(&self.page, &oid, &opts).await;
253        self.release(&oid).await;
254        result
255    }
256
257    pub async fn focus(&self) -> Result<()> {
258        let oid = self.resolve().await?;
259        let result = self
260            .page
261            .session()
262            .send("DOM.focus", json!({ "objectId": oid }))
263            .await
264            .map(|_: Value| ());
265        self.release(&oid).await;
266        result
267    }
268
269    /// Remove focus from the element.
270    pub async fn blur(&self) -> Result<()> {
271        let oid = self.resolve().await?;
272        let r = selectors::eval_object(self.page.session(), &oid, "(el) => { el.blur(); }", Value::Null)
273            .await
274            .map(|_| ());
275        self.release(&oid).await;
276        r
277    }
278
279    /// The current value of an `<input>`/`<textarea>`/`<select>`.
280    pub async fn input_value(&self, _options: Option<()>) -> Result<String> {
281        let oid = self.resolve().await?;
282        let v: Value = {
283            let res = selectors::eval_object(
284                self.page.session(),
285                &oid,
286                "(el) => el.value",
287                Value::Null,
288            )
289            .await;
290            self.release(&oid).await;
291            res?
292        };
293        Ok(v.as_str().unwrap_or("").to_string())
294    }
295
296    /// Scroll the element into view.
297    pub async fn scroll_into_view_if_needed(&self) -> Result<()> {
298        let oid = self.resolve().await?;
299        let r = self
300            .page
301            .session()
302            .send("DOM.scrollIntoViewIfNeeded", json!({ "objectId": oid }))
303            .await
304            .map(|_: Value| ());
305        self.release(&oid).await;
306        r
307    }
308
309    /// Dispatch a synthetic DOM event on the element.
310    pub async fn dispatch_event(&self, type_: &str, init: Option<Value>) -> Result<()> {
311        let oid = self.resolve().await?;
312        let init = init.unwrap_or(Value::Null);
313        let r = selectors::eval_object(
314            self.page.session(),
315            &oid,
316            "(el, args) => { el.dispatchEvent(new Event(args.type, args.init || undefined)); }",
317            json!({ "type": type_, "init": init }),
318        )
319        .await
320        .map(|_| ());
321        self.release(&oid).await;
322        r
323    }
324
325    /// Evaluate `expression` with the resolved element bound as the first arg.
326    pub async fn evaluate<R: serde::de::DeserializeOwned>(
327        &self,
328        expression: &str,
329        arg: Option<Value>,
330    ) -> Result<R> {
331        let oid = self.resolve().await?;
332        let function = format!("(el, arg) => {{ return ({expression}); }}");
333        let v = selectors::eval_object(self.page.session(), &oid, &function, arg.unwrap_or(Value::Null))
334            .await?;
335        self.release(&oid).await;
336        serde_json::from_value::<R>(v).map_err(Error::from)
337    }
338
339    /// Evaluate `page_function` with the resolved element bound as the first
340    /// argument, returning a [`JSHandle`](crate::js_handle::JSHandle) to the
341    /// resulting remote object (by reference, not by value).
342    ///
343    /// `page_function` is wrapped as `(el, arg) => { return (<page_function>); }`
344    /// and run via `Runtime.callFunctionOn` with the element's `objectId` as
345    /// the receiver (`returnByValue: false`). The resolved element is released
346    /// afterward, mirroring [`Locator::evaluate`].
347    pub async fn evaluate_handle(
348        &self,
349        page_function: &str,
350        arg: Option<Value>,
351    ) -> Result<crate::js_handle::JSHandle> {
352        let oid = self.resolve().await?;
353        let function = format!("(el, arg) => {{ return ({page_function}); }}");
354        let params = json!({
355            "objectId": oid,
356            "functionDeclaration": function,
357            "arguments": [{ "value": arg.unwrap_or(Value::Null) }],
358            "returnByValue": false,
359            "awaitPromise": true,
360        });
361        let resp = self.page.session().send("Runtime.callFunctionOn", params).await;
362        // Release the resolved element regardless of the call outcome.
363        self.release(&oid).await;
364        let resp = resp?;
365        if let Some(exc) = resp.get("exceptionDetails") {
366            let msg = exc
367                .get("exception")
368                .and_then(|e| e.get("description"))
369                .and_then(|v| v.as_str())
370                .unwrap_or("evaluation threw");
371            return Err(Error::ProtocolError(format!("eval error: {msg}")));
372        }
373        let result_oid = resp
374            .get("result")
375            .and_then(|r| r.get("objectId"))
376            .and_then(|v| v.as_str())
377            .ok_or_else(|| {
378                Error::ProtocolError(
379                    "callFunctionOn did not return a remote object (no objectId)".into(),
380                )
381            })?
382            .to_string();
383        Ok(crate::js_handle::JSHandle::new(self.page.session_arc(), result_oid))
384    }
385
386    /// Evaluate `expression` against all matching elements (bound as an array).
387    pub async fn evaluate_all<R: serde::de::DeserializeOwned>(
388        &self,
389        expression: &str,
390        arg: Option<Value>,
391    ) -> Result<R> {
392        let function = if self.frame_chain.is_empty() {
393            format!(
394                "(arg) => {{ const els = self.__pwcdpInjected.querySelectorAll(document, {selector:?}); return ({expression}); }}",
395                selector = self.selector,
396                expression = expression
397            )
398        } else {
399            // Descend the same-origin iframe chain to the deepest contentDocument.
400            format!(
401                "(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}); }}",
402                chain = self.frame_chain,
403                selector = self.selector,
404                expression = expression
405            )
406        };
407        let ctx = if self.frame_chain.is_empty() {
408            self.resolve_ctx().await
409        } else {
410            self.page.ctx().await
411        };
412        let v = selectors::eval_context(
413            self.page.session(),
414            ctx,
415            &function,
416            arg.unwrap_or(Value::Null),
417        )
418        .await?;
419        serde_json::from_value::<R>(v).map_err(Error::from)
420    }
421
422    // --- info ---
423
424    pub async fn text_content(&self) -> Result<Option<String>> {
425        self.read("(el) => el.textContent").await
426    }
427
428    pub async fn inner_text(&self) -> Result<String> {
429        self.read("(el) => el.innerText")
430            .await
431            .map(|v: Option<String>| v.unwrap_or_default())
432    }
433
434    pub async fn inner_html(&self) -> Result<String> {
435        self.read("(el) => el.innerHTML")
436            .await
437            .map(|v: Option<String>| v.unwrap_or_default())
438    }
439
440    pub async fn get_attribute(&self, name: &str) -> Result<Option<String>> {
441        let oid = self.resolve().await?;
442        let v = selectors::eval_object(
443            self.page.session(),
444            &oid,
445            "(el, name) => el.getAttribute(name)",
446            json!(name),
447        )
448        .await?;
449        self.release(&oid).await;
450        Ok(v.as_str().map(String::from))
451    }
452
453    async fn read<R: serde::de::DeserializeOwned>(&self, function: &str) -> Result<R> {
454        let oid = self.resolve().await?;
455        let v = selectors::eval_object(self.page.session(), &oid, function, Value::Null).await?;
456        self.release(&oid).await;
457        serde_json::from_value::<R>(v).map_err(Error::from)
458    }
459
460    pub async fn is_visible(&self) -> Result<bool> {
461        self.state("visible").await
462    }
463    pub async fn is_enabled(&self) -> Result<bool> {
464        self.state("enabled").await
465    }
466    pub async fn is_checked(&self) -> Result<bool> {
467        self.state("checked").await
468    }
469    pub async fn is_editable(&self) -> Result<bool> {
470        self.state("editable").await
471    }
472
473    async fn state(&self, field: &str) -> Result<bool> {
474        let oid = self.resolve().await?;
475        let function = format!(
476            "(el) => self.__pwcdpInjected && self.__pwcdpInjected.elementState(el).{field}"
477        );
478        let v = selectors::eval_object(self.page.session(), &oid, &function, Value::Null).await?;
479        self.release(&oid).await;
480        Ok(v.as_bool().unwrap_or(false))
481    }
482
483    pub async fn bounding_box(&self) -> Result<Option<BoundingBox>> {
484        let oid = self.resolve().await?;
485        let r = element_box(&self.page, &oid).await;
486        self.release(&oid).await;
487        match r {
488            Ok((x, y, w, h)) => Ok(Some(BoundingBox { x, y, width: w, height: h })),
489            Err(_) => Ok(None),
490        }
491    }
492
493    /// Wait until the element resolves (and, for actions, is visible).
494    pub async fn wait_for(&self, opts: Option<WaitForOptions>) -> Result<()> {
495        let timeout = opts
496            .and_then(|o| o.timeout)
497            .map(|ms| Duration::from_millis(ms as u64))
498            .unwrap_or_else(|| Duration::from_secs(30));
499        let deadline = tokio::time::Instant::now() + timeout;
500        loop {
501            if self.count().await? > 0 {
502                return Ok(());
503            }
504            if tokio::time::Instant::now() >= deadline {
505                return Err(Error::Timeout(format!(
506                    "wait_for '{}' timed out after {}ms",
507                    self.selector,
508                    timeout.as_millis()
509                )));
510            }
511            tokio::time::sleep(Duration::from_millis(100)).await;
512        }
513    }
514
515    // --- additional actions ---
516
517    /// Clear an input/textarea (fill with empty string).
518    pub async fn clear(&self, options: Option<FillOptions>) -> Result<()> {
519        self.fill("", options).await
520    }
521
522    /// Double-click (two press/release cycles with clickCount 2).
523    pub async fn dblclick(&self, options: Option<ClickOptions>) -> Result<()> {
524        let mut opts = options.unwrap_or_default();
525        opts.click_count = Some(opts.click_count.unwrap_or(2));
526        self.click(Some(opts)).await
527    }
528
529    /// Check a checkbox/radio if not already checked.
530    pub async fn check(&self, options: Option<CheckOptions>) -> Result<()> {
531        self.set_checked(true, options).await
532    }
533
534    /// Uncheck a checkbox if checked.
535    pub async fn uncheck(&self, options: Option<CheckOptions>) -> Result<()> {
536        self.set_checked(false, options).await
537    }
538
539    /// Set the checked state of a checkbox/radio, clicking only if needed.
540    pub async fn set_checked(&self, checked: bool, options: Option<CheckOptions>) -> Result<()> {
541        let opts = options.unwrap_or_default();
542        let oid = self.resolve().await?;
543        let result = set_checked_element(&self.page, &oid, checked, &opts).await;
544        self.release(&oid).await;
545        result
546    }
547
548    /// Select an `<option>` by value/label/index. Returns the now-selected values.
549    pub async fn select_option(
550        &self,
551        value: impl Into<SelectOption>,
552        _options: Option<SelectOptions>,
553    ) -> Result<Vec<String>> {
554        let value = value.into();
555        let arg: Value = match value {
556            SelectOption::Value(v) => json!({ "Value": v }),
557            SelectOption::Label(v) => json!({ "Label": v }),
558            SelectOption::Index(i) => json!({ "Index": i }),
559        };
560        let oid = self.resolve().await?;
561        let result = select_option_element(&self.page, &oid, arg).await;
562        self.release(&oid).await;
563        result
564    }
565
566    /// Set files on an `<input type=file>` by server-side path(s).
567    pub async fn set_input_files(&self, files: &[&str]) -> Result<()> {
568        let oid = self.resolve().await?;
569        // Resolve the remote object to a DOM nodeId via DOM.describeNode.
570        let desc = self
571            .page
572            .session()
573            .send("DOM.requestNode", json!({ "objectId": oid }))
574            .await?;
575        let node_id = desc
576            .get("nodeId")
577            .and_then(|v| v.as_i64())
578            .ok_or_else(|| Error::ProtocolError("requestNode missing nodeId".into()))?;
579        let r = self
580            .page
581            .session()
582            .send(
583                "DOM.setFileInputFiles",
584                json!({ "nodeId": node_id, "files": files }),
585            )
586            .await
587            .map(|_: Value| ());
588        self.release(&oid).await;
589        r
590    }
591
592    /// Tap (touch) the element. Approximated as a click on desktop CDP.
593    pub async fn tap(&self, options: Option<ClickOptions>) -> Result<()> {
594        self.click(options).await
595    }
596
597    /// Drag this element onto `target` via mouse motion.
598    pub async fn drag_to(&self, target: &Locator, _options: Option<DragToOptions>) -> Result<()> {
599        let oid = self.resolve().await?;
600        let target_oid = target.resolve().await?;
601        let (sx, sy) = element_center(&self.page, &oid).await?;
602        let (tx, ty) = element_center(&target.page, &target_oid).await?;
603        let r = drag_mouse(&self.page, sx, sy, tx, ty).await;
604        target.release(&target_oid).await;
605        self.release(&oid).await;
606        r
607    }
608
609    /// Highlight this element in the page using CDP's `Overlay` domain.
610    ///
611    /// Resolves the element, maps it to a DOM `nodeId` via `DOM.requestNode`,
612    /// then asks the browser to render a translucent box over it
613    /// (`Overlay.highlightNode`). Highlighting is purely cosmetic for
614    /// debugging: it should never fail an action, so any non-fatal CDP error
615    /// is tolerated and logged away.
616    pub async fn highlight(&self) -> Result<()> {
617        let oid = self.resolve().await?;
618        // Best-effort: the Overlay domain may not be enabled. Ignore failures.
619        let _ = self
620            .page
621            .session()
622            .send("Overlay.enable", json!({}))
623            .await;
624        let r = async {
625            // Resolve the remote object to a DOM nodeId.
626            let desc = self
627                .page
628                .session()
629                .send("DOM.requestNode", json!({ "objectId": oid }))
630                .await?;
631            let node_id = desc
632                .get("nodeId")
633                .and_then(|v| v.as_i64())
634                .ok_or_else(|| Error::ProtocolError("requestNode missing nodeId".into()))?;
635            // A reasonable default highlight config: a translucent orange fill
636            // with a contrasting border, matching DevTools' inspector style.
637            self.page
638                .session()
639                .send(
640                    "Overlay.highlightNode",
641                    json!({
642                        "highlightConfig": {
643                            "showInfo": true,
644                            "showStyles": false,
645                            "contentColor": { "r": 111, "g": 168, "b": 220, "a": 0.66 },
646                            "paddingColor": { "r": 147, "g": 196, "b": 125, "a": 0.55 },
647                            "borderColor": { "r": 255, "g": 229, "b": 76, "a": 0.66 },
648                            "marginColor": { "r": 246, "g": 178, "b": 107, "a": 0.66 }
649                        },
650                        "nodeId": node_id
651                    }),
652                )
653                .await
654                .map(|_: Value| ())
655        }
656        .await;
657        self.release(&oid).await;
658        // Highlight is cosmetic — swallow non-fatal errors but surface real ones
659        // (e.g. element-not-found) only via the resolve above.
660        let _ = r;
661        Ok(())
662    }
663
664    /// If this element is an `<iframe>`, return a [`FrameLocator`] scoped to
665    /// its content document.
666    ///
667    /// The iframe's content document is reached through its `contentDocument`,
668    /// so this is **same-origin only** (a cross-origin iframe's
669    /// `contentDocument` is `null` — use the frame's own target for those).
670    /// The returned locator is built from the resolved iframe element rather
671    /// than a selector, so it pins the specific element even if the page's DOM
672    /// later shifts.
673    pub async fn frame_locator(&self) -> Result<crate::frame_locator::FrameLocator> {
674        let oid = self.resolve().await?;
675        let fid = content_frame_id(&self.page, &oid).await;
676        self.release(&oid).await;
677        let fid = fid?;
678        if fid.is_none() {
679            return Err(Error::InvalidArgument(format!(
680                "element '{}' is not an iframe",
681                self.selector
682            )));
683        }
684        // FrameLocator descends through a chain of iframe selectors resolved in
685        // each frame's contentDocument. We resolved a concrete element, so
686        // build a FrameLocator scoped to this iframe via a selector matching
687        // it by the resolved frame id. Best-effort: if the frame id cannot be
688        // expressed as a selector the engine understands, queries inside the
689        // returned locator will report zero matches.
690        let frame_selector = match fid {
691            Some(fid) => format!("iframe >> internal:frame={fid}"),
692            None => "iframe".to_string(),
693        };
694        Ok(crate::frame_locator::FrameLocator::new(
695            self.page.clone(),
696            frame_selector,
697        ))
698    }
699
700    /// Drag this element and drop it onto `target` using CDP's drag events.
701    ///
702    /// Dispatches the HTML5 drag-and-drop event sequence
703    /// (`dragEnter` → `dragOver` → `drop`) on the target, preceded by a
704    /// `dragStart` on the source. This is a best-effort implementation: the
705    /// page must have a drop handler that honors synthetic `DataTransfer`
706    /// events. Apps relying on mouse-motion drag (rather than the DnD event
707    /// API) should use [`Self::drag_to`] instead.
708    pub async fn drop(&self, target: &Locator) -> Result<()> {
709        let src_oid = self.resolve().await?;
710        let _ = self
711            .page
712            .session()
713            .send("DOM.scrollIntoViewIfNeeded", json!({ "objectId": src_oid }))
714            .await;
715        let (sx, sy) = element_center(&self.page, &src_oid).await?;
716
717        let tgt_oid = target.resolve().await?;
718        let _ = self
719            .page
720            .session()
721            .send("DOM.scrollIntoViewIfNeeded", json!({ "objectId": tgt_oid }))
722            .await;
723        let (tx, ty) = element_center(&target.page, &tgt_oid).await?;
724
725        // CDP's Input.dispatchDragEvent accepts {type, x, y, data: {items,...}}
726        // and a drag event type. We synthesize a minimal empty data transfer.
727        let data = json!({
728            "items": [],
729            "files": [],
730        });
731        let mut out = Ok(());
732        // dragStart on the source.
733        if let Err(e) = self
734            .page
735            .session()
736            .send(
737                "Input.dispatchDragEvent",
738                json!({
739                    "type": "dragStart",
740                    "x": sx,
741                    "y": sy,
742                    "data": data
743                }),
744            )
745            .await
746        {
747            out = Err(e);
748        }
749        // dragEnter / dragOver / drop on the target (all at the target center).
750        for ev in ["dragEnter", "dragOver", "drop"] {
751            if out.is_err() {
752                break;
753            }
754            if let Err(e) = self
755                .page
756                .session()
757                .send(
758                    "Input.dispatchDragEvent",
759                    json!({
760                        "type": ev,
761                        "x": tx,
762                        "y": ty,
763                        "data": data
764                    }),
765                )
766                .await
767            {
768                out = Err(e);
769            }
770        }
771        target.release(&tgt_oid).await;
772        self.release(&src_oid).await;
773        out
774    }
775
776    // --- more info ---
777
778    pub async fn is_hidden(&self) -> Result<bool> {
779        Ok(!self.is_visible().await?)
780    }
781
782    pub async fn is_disabled(&self) -> Result<bool> {
783        let oid = self.resolve().await?;
784        let v = selectors::eval_object(
785            self.page.session(),
786            &oid,
787            "(el) => el.disabled === true",
788            Value::Null,
789        )
790        .await?;
791        self.release(&oid).await;
792        Ok(v.as_bool().unwrap_or(false))
793    }
794
795    pub async fn is_focused(&self) -> Result<bool> {
796        let oid = self.resolve().await?;
797        let v = selectors::eval_object(
798            self.page.session(),
799            &oid,
800            "(el) => el === document.activeElement",
801            Value::Null,
802        )
803        .await?;
804        self.release(&oid).await;
805        Ok(v.as_bool().unwrap_or(false))
806    }
807
808    /// All matching locators (one per resolved element).
809    pub async fn all(&self) -> Result<Vec<Locator>> {
810        let n = self.count().await?;
811        let mut out = Vec::with_capacity(n);
812        for i in 0..n {
813            let mut l = self.clone();
814            l.strict = false;
815            l.forced_index = Some(i);
816            out.push(l);
817        }
818        Ok(out)
819    }
820
821    pub async fn all_inner_texts(&self) -> Result<Vec<String>> {
822        let n = self.count().await?;
823        let mut out = Vec::with_capacity(n);
824        for i in 0..n {
825            if let Some(oid) = self.element_query(i).await? {
826                let v = selectors::eval_object(
827                    self.page.session(),
828                    &oid,
829                    "(el) => el.innerText",
830                    Value::Null,
831                )
832                .await
833                .ok();
834                let _ = self
835                    .page
836                    .session()
837                    .send("Runtime.releaseObject", json!({ "objectId": oid }))
838                    .await;
839                out.push(v.and_then(|x| x.as_str().map(String::from)).unwrap_or_default());
840            }
841        }
842        Ok(out)
843    }
844
845    pub async fn all_text_contents(&self) -> Result<Vec<String>> {
846        let n = self.count().await?;
847        let mut out = Vec::with_capacity(n);
848        for i in 0..n {
849            if let Some(oid) = self.element_query(i).await? {
850                let v = selectors::eval_object(
851                    self.page.session(),
852                    &oid,
853                    "(el) => el.textContent",
854                    Value::Null,
855                )
856                .await
857                .ok();
858                let _ = self
859                    .page
860                    .session()
861                    .send("Runtime.releaseObject", json!({ "objectId": oid }))
862                    .await;
863                out.push(v.and_then(|x| x.as_str().map(String::from)).unwrap_or_default());
864            }
865        }
866        Ok(out)
867    }
868
869    /// Capture a screenshot clipped to this element.
870    pub async fn screenshot(&self, opts: Option<ScreenshotOptions>) -> Result<Vec<u8>> {
871        let oid = self.resolve().await?;
872        let (x, y, w, h) = element_box(&self.page, &oid).await?;
873        self.release(&oid).await;
874
875        let opts = opts.unwrap_or_default();
876        let format = match opts.r#type.unwrap_or_default() {
877            crate::types::ScreenshotType::Png => "png",
878            crate::types::ScreenshotType::Jpeg => "jpeg",
879            crate::types::ScreenshotType::Webp => "webp",
880        };
881        let mut params = json!({
882            "format": format,
883            "clip": { "x": x, "y": y, "width": w, "height": h, "scale": 1 },
884        });
885        if opts.omit_background.unwrap_or(false) && format == "png" {
886            params["omitBackground"] = json!(true);
887        }
888        if let Some(q) = opts.quality {
889            if format == "jpeg" {
890                params["quality"] = json!(q);
891            }
892        }
893        let resp = self
894            .page
895            .session()
896            .send("Page.captureScreenshot", params)
897            .await?;
898        let data = resp
899            .get("data")
900            .and_then(|v| v.as_str())
901            .ok_or_else(|| Error::ProtocolError("screenshot missing data".into()))?;
902        let bytes = base64::engine::general_purpose::STANDARD
903            .decode(data)
904            .map_err(|e| Error::ProtocolError(format!("base64 decode: {e}")))?;
905        if let Some(path) = &opts.path {
906            tokio::fs::write(path, &bytes).await?;
907        }
908        Ok(bytes)
909    }
910
911    // --- composition ---
912
913    /// Filter matches by text presence (minimal).
914    pub fn filter(&self, options: FilterOptions) -> Locator {
915        let mut sel = self.selector.clone();
916        if let Some(t) = options.has_text {
917            sel.push_str(&format!(" >> text={t}"));
918        }
919        let mut l = self.clone();
920        l.selector = sel;
921        l
922    }
923
924    /// Chain this locator with another (both must match, in order).
925    pub fn and_(&self, other: &Locator) -> Locator {
926        let mut l = self.clone();
927        l.selector = format!("{} >> {}", self.selector, other.selector);
928        l
929    }
930
931    /// Union with another locator (either matches).
932    pub fn or_(&self, other: &Locator) -> Locator {
933        let mut l = self.clone();
934        l.selector = format!("{}, {}", self.selector, other.selector);
935        l
936    }
937
938    // --- locator-relative chaining ---
939
940    fn chain(&self, sub: impl Into<String>) -> Locator {
941        let mut l = self.clone();
942        l.selector = format!("{} >> {}", self.selector, sub.into());
943        l.forced_index = None;
944        l.strict = true;
945        l
946    }
947
948    /// Find a descendant matching `selector`.
949    pub fn locator(&self, selector: impl Into<String>) -> Locator {
950        self.chain(selector)
951    }
952
953    pub fn get_by_text(&self, text: &str, _exact: bool) -> Locator {
954        self.chain(format!("text={text}"))
955    }
956
957    pub fn get_by_label(&self, text: &str) -> Locator {
958        self.chain(format!("[aria-label=\"{}\"]", esc(text)))
959    }
960
961    pub fn get_by_placeholder(&self, text: &str) -> Locator {
962        self.chain(format!("[placeholder=\"{}\"]", esc(text)))
963    }
964
965    pub fn get_by_alt_text(&self, text: &str) -> Locator {
966        self.chain(format!("[alt=\"{}\"]", esc(text)))
967    }
968
969    pub fn get_by_title(&self, text: &str) -> Locator {
970        self.chain(format!("[title=\"{}\"]", esc(text)))
971    }
972
973    pub fn get_by_test_id(&self, test_id: &str) -> Locator {
974        self.chain(format!("[data-testid=\"{}\"]", esc(test_id)))
975    }
976
977    pub fn get_by_role(
978        &self,
979        role: crate::types::AriaRole,
980        opts: Option<crate::options::GetByRoleOptions>,
981    ) -> Locator {
982        let opts = opts.unwrap_or_default();
983        let mut sel = format!("role={}", role.as_str());
984        if let Some(name) = &opts.name {
985            sel.push_str(&format!("[name=\"{name}\"]"));
986        }
987        if opts.exact == Some(true) {
988            sel.push_str("[exact=\"true\"]");
989        }
990        self.chain(sel)
991    }
992
993    // --- aria snapshot ---
994
995    /// Capture an aria-snapshot (Playwright's YAML-ish accessibility-tree
996    /// format) rooted at this locator's element.
997    ///
998    /// Resolves the element to its `RemoteObjectId`, then asks CDP for the
999    /// partial accessibility tree rooted there (`fetchRelatives: false`) and
1000    /// serializes it via [`crate::aria_snapshot`].
1001    pub async fn aria_snapshot(&self) -> Result<String> {
1002        let oid = self.resolve().await?;
1003
1004        // Resolve the element's backend DOM node id, which is how AX nodes link
1005        // back to the DOM. We then fetch the full AX tree and render the
1006        // subtree rooted at the matching node. (CDP's `getPartialAXTree` with
1007        // `fetchRelatives: false` returns only the single node, with no
1008        // descendants — insufficient for a useful snapshot.)
1009        let desc = self
1010            .page
1011            .session()
1012            .send("DOM.describeNode", json!({ "objectId": oid }))
1013            .await;
1014        self.release(&oid).await;
1015        let desc = desc?;
1016        let backend_node_id = desc
1017            .get("node")
1018            .and_then(|n| n.get("backendNodeId"))
1019            .and_then(|v| v.as_i64())
1020            .ok_or_else(|| {
1021                Error::ProtocolError("DOM.describeNode missing backendNodeId".into())
1022            })?;
1023
1024        // Best-effort: some Chrome builds need the Accessibility domain enabled.
1025        let _ = self
1026            .page
1027            .session()
1028            .send("Accessibility.enable", json!({}))
1029            .await;
1030        let resp = self
1031            .page
1032            .session()
1033            .send("Accessibility.getFullAXTree", json!({}))
1034            .await?;
1035        let nodes = resp
1036            .get("nodes")
1037            .and_then(|v| v.as_array())
1038            .cloned()
1039            .unwrap_or_default();
1040        Ok(crate::aria_snapshot::serialize(&nodes, Some(backend_node_id)))
1041    }
1042}
1043
1044fn esc(s: &str) -> String {
1045    s.replace('\\', "\\\\").replace('"', "\\\"")
1046}
1047
1048// --- element-level action helpers ---
1049
1050/// Compute an element's content-box as `(x, y, width, height)` where `(x, y)`
1051/// is the top-left corner.
1052pub(crate) async fn element_box(page: &Page, object_id: &str) -> Result<(f64, f64, f64, f64)> {
1053    let resp = page
1054        .session()
1055        .send("DOM.getBoxModel", json!({ "objectId": object_id }))
1056        .await?;
1057    let model = resp
1058        .get("model")
1059        .ok_or_else(|| Error::ProtocolError("getBoxModel missing model".into()))?;
1060    let (x, y, w, h) = quad_box(model.get("content"))
1061        .ok_or_else(|| Error::ProtocolError("getBoxModel content quad malformed".into()))?;
1062    // Prefer the model's own dimensions when available.
1063    let mw = model.get("width").and_then(|v| v.as_f64());
1064    let mh = model.get("height").and_then(|v| v.as_f64());
1065    Ok((x, y, mw.unwrap_or(w), mh.unwrap_or(h)))
1066}
1067
1068/// Center of an element's content box.
1069pub(crate) async fn element_center(page: &Page, object_id: &str) -> Result<(f64, f64)> {
1070    let (x, y, w, h) = element_box(page, object_id).await?;
1071    Ok((x + w / 2.0, y + h / 2.0))
1072}
1073
1074/// Resolve the CDP content-frame id of an `<iframe>`/`<frame>` element.
1075/// Returns `Ok(Some(frame_id))` if the element is a frame with a content
1076/// document, `Ok(None)` if it is a frame but has no resolvable content frame,
1077/// and `Err` only if the node description itself fails. Callers decide whether
1078/// a missing frame id is an error.
1079pub(crate) async fn content_frame_id(page: &Page, object_id: &str) -> Result<Option<String>> {
1080    let desc = page
1081        .session()
1082        .send("DOM.describeNode", json!({ "objectId": object_id }))
1083        .await?;
1084    let node = desc
1085        .get("node")
1086        .ok_or_else(|| Error::ProtocolError("DOM.describeNode missing node".into()))?;
1087    let is_frame = node
1088        .get("localName")
1089        .and_then(|v| v.as_str())
1090        .map(|name| name.eq_ignore_ascii_case("iframe") || name.eq_ignore_ascii_case("frame"))
1091        .unwrap_or(false);
1092    if !is_frame {
1093        return Ok(None);
1094    }
1095    let fid = node
1096        .get("contentDocument")
1097        .and_then(|c| c.get("frameId"))
1098        .and_then(|v| v.as_str())
1099        .map(String::from);
1100    Ok(fid)
1101}
1102
1103/// Drag the mouse from `(sx, sy)` to `(tx, ty)`: press, move in 10 steps,
1104/// release. Shared by `Locator::drag_to` and `ElementHandle::drag_and_drop`.
1105/// Callers handle scrolling the source/target into view first.
1106pub(crate) async fn drag_mouse(
1107    page: &Page,
1108    sx: f64,
1109    sy: f64,
1110    tx: f64,
1111    ty: f64,
1112) -> Result<()> {
1113    page.session()
1114        .send(
1115            "Input.dispatchMouseEvent",
1116            json!({ "type": "mouseMoved", "x": sx, "y": sy, "button": "none", "buttons": 0 }),
1117        )
1118        .await?;
1119    page.session()
1120        .send(
1121            "Input.dispatchMouseEvent",
1122            json!({ "type": "mousePressed", "x": sx, "y": sy, "button": "left", "buttons": 1, "clickCount": 1 }),
1123        )
1124        .await?;
1125    let steps = 10;
1126    for i in 1..=steps {
1127        let x = sx + (tx - sx) * (i as f64) / (steps as f64);
1128        let y = sy + (ty - sy) * (i as f64) / (steps as f64);
1129        page.session()
1130            .send(
1131                "Input.dispatchMouseEvent",
1132                json!({ "type": "mouseMoved", "x": x, "y": y, "button": "none", "buttons": 1 }),
1133            )
1134            .await?;
1135    }
1136    page.session()
1137        .send(
1138            "Input.dispatchMouseEvent",
1139            json!({ "type": "mouseReleased", "x": tx, "y": ty, "button": "left", "buttons": 0, "clickCount": 1 }),
1140        )
1141        .await
1142        .map(|_: Value| ())
1143}
1144
1145/// Type each character of `text` as a `keyDown`/`keyUp` pair, optionally
1146/// sleeping `delay` between keystrokes. Reuses the same `Input.dispatchKeyEvent`
1147/// path as `Locator::press`. The caller is responsible for focusing the element
1148/// first.
1149pub(crate) async fn press_text(
1150    page: &Page,
1151    _object_id: &str,
1152    text: &str,
1153    delay: Option<Duration>,
1154) -> Result<()> {
1155    for ch in text.chars() {
1156        // A single logical key may be multiple UTF-16 units; CDP wants the
1157        // text payload in `text` and, for printable chars, the char as `key`.
1158        let key = ch.to_string();
1159        page.session()
1160            .send(
1161                "Input.dispatchKeyEvent",
1162                json!({
1163                    "type": "char",
1164                    "text": key,
1165                    "key": key,
1166                }),
1167            )
1168            .await
1169            .map(|_: Value| ())?;
1170        if let Some(d) = delay {
1171            tokio::time::sleep(d).await;
1172        }
1173    }
1174    Ok(())
1175}
1176
1177/// Set a checkbox/radio to `checked`, clicking only when the current state
1178/// differs. Shared by `Locator::set_checked` and `ElementHandle::set_checked`.
1179pub(crate) async fn set_checked_element(
1180    page: &Page,
1181    object_id: &str,
1182    checked: bool,
1183    opts: &CheckOptions,
1184) -> Result<()> {
1185    // Read current checked state from the element directly.
1186    let v = selectors::eval_object(
1187        page.session(),
1188        object_id,
1189        "(el) => el.checked === true",
1190        Value::Null,
1191    )
1192    .await?;
1193    let current = v.as_bool().unwrap_or(false);
1194    if current == checked {
1195        return Ok(());
1196    }
1197    let click_opts = ClickOptions {
1198        force: opts.force,
1199        position: opts.position,
1200        timeout: opts.timeout,
1201        trial: opts.trial,
1202        ..Default::default()
1203    };
1204    click_element(page, object_id, &click_opts).await
1205}
1206
1207/// Hover over an element: scroll it in, then move the mouse to its center
1208/// (or the configured offset). Shared by `Locator::hover` and `ElementHandle::hover`.
1209pub(crate) async fn hover_element(page: &Page, object_id: &str, opts: &HoverOptions) -> Result<()> {
1210    let _ = page
1211        .session()
1212        .send("DOM.scrollIntoViewIfNeeded", json!({ "objectId": object_id }))
1213        .await;
1214    let (bx, by, bw, bh) = element_box(page, object_id).await?;
1215    let (x, y) = match &opts.position {
1216        Some(p) => (bx + p.x, by + p.y),
1217        None => (bx + bw / 2.0, by + bh / 2.0),
1218    };
1219    page.session()
1220        .send(
1221            "Input.dispatchMouseEvent",
1222            json!({ "type": "mouseMoved", "x": x, "y": y, "button": "none", "buttons": 0 }),
1223        )
1224        .await
1225        .map(|_: Value| ())
1226}
1227
1228/// Press a single key against the currently-focused element. Shared by
1229/// `Locator::press` and `ElementHandle::press`.
1230pub(crate) async fn press_key(
1231    page: &Page,
1232    object_id: &str,
1233    key: &str,
1234    delay: Option<Duration>,
1235) -> Result<()> {
1236    let _ = page
1237        .session()
1238        .send("DOM.focus", json!({ "objectId": object_id }))
1239        .await;
1240    page.session()
1241        .send("Input.dispatchKeyEvent", json!({ "type": "keyDown", "key": key }))
1242        .await
1243        .map(|_: Value| ())?;
1244    if let Some(d) = delay {
1245        tokio::time::sleep(d).await;
1246    }
1247    page.session()
1248        .send("Input.dispatchKeyEvent", json!({ "type": "keyUp", "key": key }))
1249        .await
1250        .map(|_: Value| ())
1251}
1252
1253/// Select an `<option>` on a `<select>`. `arg` is one of `{Value}`/`{Label}`/
1254/// `{Index}`. Returns the now-selected option values. Shared by
1255/// `Locator::select_option` and `ElementHandle::select_option`.
1256pub(crate) async fn select_option_element(
1257    page: &Page,
1258    object_id: &str,
1259    arg: Value,
1260) -> Result<Vec<String>> {
1261    let v: Value = selectors::eval_object(
1262        page.session(),
1263        object_id,
1264        "(el, sel) => {
1265            const opts = Array.from(el.options || []);
1266            const matched = [];
1267            for (const o of opts) {
1268                const hit = sel.Value != null ? o.value === sel.Value
1269                        : sel.Label != null ? o.label === sel.Label
1270                        : sel.Index != null ? o.index === sel.Index : false;
1271                if (hit) { o.selected = true; matched.push(o.value); }
1272            }
1273            el.dispatchEvent(new Event('input', { bubbles: true }));
1274            el.dispatchEvent(new Event('change', { bubbles: true }));
1275            return matched;
1276        }",
1277        arg,
1278    )
1279    .await?;
1280    Ok(v.as_array()
1281        .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
1282        .unwrap_or_default())
1283}
1284
1285/// Wait for an element to be actionable (visible) unless skipping via `force`.
1286async fn wait_actionable(
1287    page: &Page,
1288    object_id: &str,
1289    timeout: Duration,
1290) -> Result<()> {
1291    let function = "(el) => self.__pwcdpInjected && self.__pwcdpInjected.elementState(el).visible";
1292    let deadline = tokio::time::Instant::now() + timeout;
1293    loop {
1294        let v = selectors::eval_object(page.session(), object_id, function, Value::Null).await?;
1295        if v.as_bool() == Some(true) {
1296            return Ok(());
1297        }
1298        if tokio::time::Instant::now() >= deadline {
1299            return Err(Error::Timeout(format!(
1300                "element not visible within {}ms",
1301                timeout.as_millis()
1302            )));
1303        }
1304        tokio::time::sleep(Duration::from_millis(100)).await;
1305    }
1306}
1307
1308fn modifier_key(m: &crate::types::KeyboardModifier) -> &'static str {
1309    use crate::types::KeyboardModifier::*;
1310    match m {
1311        Alt => "Alt",
1312        Control => "Control",
1313        ControlOrMeta => "Control",
1314        Meta => "Meta",
1315        Shift => "Shift",
1316    }
1317}
1318
1319/// Dispatch a mouse click (press + release) honoring the full `ClickOptions`.
1320/// Fixes the historical `buttons`-bitmask bug: press uses the button's bitmask,
1321/// release uses `0`.
1322pub(crate) async fn click_element(
1323    page: &Page,
1324    object_id: &str,
1325    opts: &ClickOptions,
1326) -> Result<()> {
1327    let _ = page
1328        .session()
1329        .send("DOM.scrollIntoViewIfNeeded", json!({ "objectId": object_id }))
1330        .await;
1331
1332    let force = opts.force.unwrap_or(false);
1333    let trial = opts.trial.unwrap_or(false);
1334    if !force {
1335        let timeout = opts
1336            .timeout
1337            .map(|ms| Duration::from_millis(ms as u64))
1338            .unwrap_or_else(|| page.default_timeout());
1339        wait_actionable(page, object_id, timeout).await?;
1340    }
1341
1342    let (bx, by, bw, bh) = element_box(page, object_id).await?;
1343    let (x, y) = match &opts.position {
1344        Some(p) => (bx + p.x, by + p.y),
1345        None => (bx + bw / 2.0, by + bh / 2.0),
1346    };
1347    let button = opts.button.unwrap_or_default();
1348    let count = opts.click_count.unwrap_or(1);
1349    let delay = opts.delay.map(|ms| Duration::from_millis(ms as u64));
1350
1351    // Hold modifiers.
1352    if let Some(mods) = &opts.modifiers {
1353        for m in mods {
1354            let _ = page
1355                .session()
1356                .send(
1357                    "Input.dispatchKeyEvent",
1358                    json!({ "type": "keyDown", "key": modifier_key(m) }),
1359                )
1360                .await;
1361        }
1362    }
1363
1364    let result = if trial {
1365        Ok(())
1366    } else {
1367        let mut out = Ok(());
1368        page.session()
1369            .send(
1370                "Input.dispatchMouseEvent",
1371                json!({ "type": "mouseMoved", "x": x, "y": y, "button": "none", "buttons": 0 }),
1372            )
1373            .await?;
1374        for i in 0..count {
1375            if let Err(e) = page
1376                .session()
1377                .send(
1378                    "Input.dispatchMouseEvent",
1379                    json!({ "type": "mousePressed", "x": x, "y": y, "button": button.as_str(), "buttons": button.bitmask(), "clickCount": i + 1 }),
1380                )
1381                .await
1382            {
1383                out = Err(e);
1384                break;
1385            }
1386            if let Err(e) = page
1387                .session()
1388                .send(
1389                    "Input.dispatchMouseEvent",
1390                    json!({ "type": "mouseReleased", "x": x, "y": y, "button": button.as_str(), "buttons": 0, "clickCount": i + 1 }),
1391                )
1392                .await
1393            {
1394                out = Err(e);
1395                break;
1396            }
1397            if let Some(d) = delay {
1398                if i + 1 < count {
1399                    tokio::time::sleep(d).await;
1400                }
1401            }
1402        }
1403        out
1404    };
1405
1406    // Release modifiers.
1407    if let Some(mods) = &opts.modifiers {
1408        for m in mods {
1409            let _ = page
1410                .session()
1411                .send(
1412                    "Input.dispatchKeyEvent",
1413                    json!({ "type": "keyUp", "key": modifier_key(m) }),
1414                )
1415                .await;
1416        }
1417    }
1418
1419    result
1420}
1421
1422fn quad_box(quad: Option<&Value>) -> Option<(f64, f64, f64, f64)> {
1423    let arr = quad.and_then(|v| v.as_array())?;
1424    let nums: Vec<f64> = arr.iter().filter_map(|v| v.as_f64()).collect();
1425    if nums.len() < 8 {
1426        return None;
1427    }
1428    let xs = [nums[0], nums[2], nums[4], nums[6]];
1429    let ys = [nums[1], nums[3], nums[5], nums[7]];
1430    let xmin = xs.iter().cloned().fold(f64::INFINITY, f64::min);
1431    let xmax = xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1432    let ymin = ys.iter().cloned().fold(f64::INFINITY, f64::min);
1433    let ymax = ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1434    Some((xmin, ymin, xmax - xmin, ymax - ymin))
1435}