Skip to main content

playwright_cdp/
element_handle.rs

1//! `ElementHandle` — a persistent handle to a resolved DOM element (a CDP
2//! `RemoteObjectId`).
3//!
4//! Unlike [`Locator`](crate::Locator), which resolves lazily on each action,
5//! an `ElementHandle` pins one specific element. Remote objects are GC'd by the
6//! browser when their execution context is destroyed (e.g. on navigation); call
7//! [`ElementHandle::dispose`] to release eagerly.
8
9use crate::error::{Error, Result};
10use crate::frame::Frame;
11use crate::locator::{
12    click_element, content_frame_id, drag_mouse, element_box, element_center, hover_element,
13    press_key, press_text, select_option_element, set_checked_element,
14};
15use crate::options::{
16    CheckOptions, ClickOptions, DragToOptions, FillOptions, HoverOptions, PressOptions,
17    PressSequentiallyOptions, ScreenshotOptions, SelectOption, SelectOptions,
18};
19use crate::page::Page;
20use crate::selectors;
21use crate::types::BoundingBox;
22use base64::Engine;
23use serde_json::{json, Value};
24use std::time::Duration;
25
26/// A handle to a resolved element.
27#[derive(Clone)]
28pub struct ElementHandle {
29    page: Page,
30    object_id: String,
31}
32
33/// An element state that [`ElementHandle::wait_for_element_state`] can poll for.
34///
35/// Mirrors Playwright's `ElementState` (a subset). `Stable` means the element's
36/// bounding box has stopped changing between polls.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum ElementState {
39    /// The element is visible (non-zero box, not `display:none`/`visibility:hidden`).
40    Visible,
41    /// The element is hidden.
42    Hidden,
43    /// The element is enabled (not `[disabled]`).
44    Enabled,
45    /// The element is editable (`contenteditable` or a mutable form field).
46    Editable,
47    /// The element's bounding box is not changing between successive samples.
48    Stable,
49}
50
51impl ElementHandle {
52    pub(crate) fn new(page: Page, object_id: String) -> Self {
53        Self { page, object_id }
54    }
55
56    /// The underlying CDP remote object id.
57    pub fn object_id(&self) -> &str {
58        &self.object_id
59    }
60
61    /// The owning page.
62    pub fn page(&self) -> Page {
63        self.page.clone()
64    }
65
66    pub async fn click(&self, options: Option<ClickOptions>) -> Result<()> {
67        let opts = options.unwrap_or_default();
68        click_element(&self.page, &self.object_id, &opts).await
69    }
70
71    pub async fn fill(&self, text: &str, _opts: Option<FillOptions>) -> Result<()> {
72        selectors::eval_object(
73            self.page.session(),
74            &self.object_id,
75            "(el, text) => { el.focus(); el.value = text; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); }",
76            json!(text),
77        )
78        .await
79        .map(|_| ())
80    }
81
82    /// Clear an input/textarea (fill with the empty string).
83    pub async fn clear(&self, options: Option<FillOptions>) -> Result<()> {
84        self.fill("", options).await
85    }
86
87    /// Type each character of `text` sequentially with an optional delay.
88    /// Dispatches one `char` key event per character (so per-keystroke handlers
89    /// fire). The element is focused first.
90    pub async fn type_(&self, text: &str, opts: Option<PressSequentiallyOptions>) -> Result<()> {
91        let delay = opts
92            .and_then(|o| o.delay)
93            .map(|ms| Duration::from_millis(ms as u64));
94        let _ = self
95            .page
96            .session()
97            .send("DOM.focus", json!({ "objectId": self.object_id }))
98            .await;
99        press_text(&self.page, &self.object_id, text, delay).await
100    }
101
102    /// Press a single key (focus + keyDown/keyUp).
103    pub async fn press(&self, key: &str, options: Option<PressOptions>) -> Result<()> {
104        let delay = options
105            .and_then(|o| o.delay)
106            .map(|ms| Duration::from_millis(ms as u64));
107        press_key(&self.page, &self.object_id, key, delay).await
108    }
109
110    /// Double-click (clickCount 2).
111    pub async fn dblclick(&self, options: Option<ClickOptions>) -> Result<()> {
112        let mut opts = options.unwrap_or_default();
113        opts.click_count = Some(opts.click_count.unwrap_or(2));
114        click_element(&self.page, &self.object_id, &opts).await
115    }
116
117    /// Hover over the element.
118    pub async fn hover(&self, options: Option<HoverOptions>) -> Result<()> {
119        let opts = options.unwrap_or_default();
120        hover_element(&self.page, &self.object_id, &opts).await
121    }
122
123    /// Tap (touch) the element. Approximated as a click on desktop CDP.
124    pub async fn tap(&self, options: Option<ClickOptions>) -> Result<()> {
125        self.click(options).await
126    }
127
128    /// Check a checkbox/radio if not already checked.
129    pub async fn check(&self, options: Option<CheckOptions>) -> Result<()> {
130        self.set_checked(true, options).await
131    }
132
133    /// Uncheck a checkbox if checked.
134    pub async fn uncheck(&self, options: Option<CheckOptions>) -> Result<()> {
135        self.set_checked(false, options).await
136    }
137
138    /// Set the checked state of a checkbox/radio, clicking only if needed.
139    pub async fn set_checked(&self, checked: bool, options: Option<CheckOptions>) -> Result<()> {
140        let opts = options.unwrap_or_default();
141        set_checked_element(&self.page, &self.object_id, checked, &opts).await
142    }
143
144    /// Select an `<option>` by value/label/index. Returns the now-selected values.
145    pub async fn select_option(
146        &self,
147        value: impl Into<SelectOption>,
148        _options: Option<SelectOptions>,
149    ) -> Result<Vec<String>> {
150        let value = value.into();
151        let arg: Value = match value {
152            SelectOption::Value(v) => json!({ "Value": v }),
153            SelectOption::Label(v) => json!({ "Label": v }),
154            SelectOption::Index(i) => json!({ "Index": i }),
155        };
156        select_option_element(&self.page, &self.object_id, arg).await
157    }
158
159    /// Drag this element onto `target` via mouse motion.
160    pub async fn drag_and_drop(
161        &self,
162        target: &ElementHandle,
163        _options: Option<DragToOptions>,
164    ) -> Result<()> {
165        let _ = self
166            .page
167            .session()
168            .send("DOM.scrollIntoViewIfNeeded", json!({ "objectId": self.object_id }))
169            .await;
170        let (sx, sy) = element_center(&self.page, &self.object_id).await?;
171        let _ = target
172            .page
173            .session()
174            .send("DOM.scrollIntoViewIfNeeded", json!({ "objectId": target.object_id }))
175            .await;
176        let (tx, ty) = element_center(&target.page, &target.object_id).await?;
177        drag_mouse(&self.page, sx, sy, tx, ty).await
178    }
179
180    pub async fn focus(&self) -> Result<()> {
181        self.page
182            .session()
183            .send("DOM.focus", json!({ "objectId": self.object_id }))
184            .await
185            .map(|_: Value| ())
186    }
187
188    pub async fn blur(&self) -> Result<()> {
189        selectors::eval_object(self.page.session(), &self.object_id, "(el) => { el.blur(); }", Value::Null)
190            .await
191            .map(|_| ())
192    }
193
194    pub async fn scroll_into_view_if_needed(&self) -> Result<()> {
195        self.page
196            .session()
197            .send("DOM.scrollIntoViewIfNeeded", json!({ "objectId": self.object_id }))
198            .await
199            .map(|_: Value| ())
200    }
201
202    pub async fn dispatch_event(&self, type_: &str, init: Option<Value>) -> Result<()> {
203        let init = init.unwrap_or(Value::Null);
204        selectors::eval_object(
205            self.page.session(),
206            &self.object_id,
207            "(el, args) => { el.dispatchEvent(new Event(args.type, args.init || undefined)); }",
208            json!({ "type": type_, "init": init }),
209        )
210        .await
211        .map(|_| ())
212    }
213
214    pub async fn text_content(&self) -> Result<Option<String>> {
215        self.read_str_opt("(el) => el.textContent").await
216    }
217
218    pub async fn inner_text(&self) -> Result<String> {
219        Ok(self.read_str_opt("(el) => el.innerText").await?.unwrap_or_default())
220    }
221
222    pub async fn inner_html(&self) -> Result<String> {
223        Ok(self.read_str_opt("(el) => el.innerHTML").await?.unwrap_or_default())
224    }
225
226    pub async fn get_attribute(&self, name: &str) -> Result<Option<String>> {
227        let v = selectors::eval_object(
228            self.page.session(),
229            &self.object_id,
230            "(el, name) => el.getAttribute(name)",
231            json!(name),
232        )
233        .await?;
234        Ok(v.as_str().map(String::from))
235    }
236
237    /// Evaluate `expression` with this element bound as the first argument.
238    pub async fn evaluate<R: serde::de::DeserializeOwned>(
239        &self,
240        expression: &str,
241        arg: Option<Value>,
242    ) -> Result<R> {
243        let function = format!("(el, arg) => {{ return ({expression}); }}");
244        let v = selectors::eval_object(self.page.session(), &self.object_id, &function, arg.unwrap_or(Value::Null))
245            .await?;
246        serde_json::from_value::<R>(v).map_err(Error::from)
247    }
248
249    /// Evaluate `page_function` with this element bound as the first argument,
250    /// returning a [`JSHandle`](crate::js_handle::JSHandle) to the resulting
251    /// remote object (by reference, not by value).
252    ///
253    /// `page_function` is wrapped as `(el, arg) => { return (<page_function>); }`
254    /// and run via `Runtime.callFunctionOn` against this handle's `objectId`
255    /// (`returnByValue: false`), mirroring [`ElementHandle::evaluate`].
256    pub async fn evaluate_handle(
257        &self,
258        page_function: &str,
259        arg: Option<Value>,
260    ) -> Result<crate::js_handle::JSHandle> {
261        let function = format!("(el, arg) => {{ return ({page_function}); }}");
262        let params = json!({
263            "objectId": self.object_id,
264            "functionDeclaration": function,
265            "arguments": [{ "value": arg.unwrap_or(Value::Null) }],
266            "returnByValue": false,
267            "awaitPromise": true,
268        });
269        let resp = self.page.session().send("Runtime.callFunctionOn", params).await?;
270        if let Some(exc) = resp.get("exceptionDetails") {
271            let msg = exc
272                .get("exception")
273                .and_then(|e| e.get("description"))
274                .and_then(|v| v.as_str())
275                .unwrap_or("evaluation threw");
276            return Err(Error::ProtocolError(format!("eval error: {msg}")));
277        }
278        let result_oid = resp
279            .get("result")
280            .and_then(|r| r.get("objectId"))
281            .and_then(|v| v.as_str())
282            .ok_or_else(|| {
283                Error::ProtocolError(
284                    "callFunctionOn did not return a remote object (no objectId)".into(),
285                )
286            })?
287            .to_string();
288        Ok(crate::js_handle::JSHandle::new(self.page.session_arc(), result_oid))
289    }
290
291    async fn read_str_opt(&self, function: &str) -> Result<Option<String>> {
292        let v = selectors::eval_object(self.page.session(), &self.object_id, function, Value::Null).await?;
293        Ok(v.as_str().map(String::from))
294    }
295
296    async fn state(&self, field: &str) -> Result<bool> {
297        let function = format!(
298            "(el) => self.__pwcdpInjected && self.__pwcdpInjected.elementState(el).{field}"
299        );
300        let v = selectors::eval_object(self.page.session(), &self.object_id, &function, Value::Null).await?;
301        Ok(v.as_bool().unwrap_or(false))
302    }
303
304    pub async fn is_visible(&self) -> Result<bool> {
305        self.state("visible").await
306    }
307    pub async fn is_enabled(&self) -> Result<bool> {
308        self.state("enabled").await
309    }
310    pub async fn is_checked(&self) -> Result<bool> {
311        self.state("checked").await
312    }
313    pub async fn is_editable(&self) -> Result<bool> {
314        self.state("editable").await
315    }
316    pub async fn is_hidden(&self) -> Result<bool> {
317        Ok(!self.is_visible().await?)
318    }
319
320    pub async fn bounding_box(&self) -> Result<Option<BoundingBox>> {
321        match element_box(&self.page, &self.object_id).await {
322            Ok((x, y, w, h)) => Ok(Some(BoundingBox { x, y, width: w, height: h })),
323            Err(_) => Ok(None),
324        }
325    }
326
327    pub async fn screenshot(&self, opts: Option<ScreenshotOptions>) -> Result<Vec<u8>> {
328        let (x, y, w, h) = element_box(&self.page, &self.object_id).await?;
329        let opts = opts.unwrap_or_default();
330        let format = match opts.r#type.unwrap_or_default() {
331            crate::types::ScreenshotType::Png => "png",
332            crate::types::ScreenshotType::Jpeg => "jpeg",
333            crate::types::ScreenshotType::Webp => "webp",
334        };
335        let mut params = json!({
336            "format": format,
337            "clip": { "x": x, "y": y, "width": w, "height": h, "scale": 1 },
338        });
339        if opts.omit_background.unwrap_or(false) && format == "png" {
340            params["omitBackground"] = json!(true);
341        }
342        let resp = self.page.session().send("Page.captureScreenshot", params).await?;
343        let data = resp
344            .get("data")
345            .and_then(|v| v.as_str())
346            .ok_or_else(|| Error::ProtocolError("screenshot missing data".into()))?;
347        let bytes = base64::engine::general_purpose::STANDARD
348            .decode(data)
349            .map_err(|e| Error::ProtocolError(format!("base64 decode: {e}")))?;
350        if let Some(path) = &opts.path {
351            tokio::fs::write(path, &bytes).await?;
352        }
353        Ok(bytes)
354    }
355
356    /// Set files on an `<input type=file>` by server-side path(s).
357    pub async fn set_input_files(&self, files: &[&str]) -> Result<()> {
358        let desc = self
359            .page
360            .session()
361            .send("DOM.requestNode", json!({ "objectId": self.object_id }))
362            .await?;
363        let node_id = desc
364            .get("nodeId")
365            .and_then(|v| v.as_i64())
366            .ok_or_else(|| Error::ProtocolError("requestNode missing nodeId".into()))?;
367        self.page
368            .session()
369            .send("DOM.setFileInputFiles", json!({ "nodeId": node_id, "files": files }))
370            .await
371            .map(|_: Value| ())
372    }
373
374    /// Release the remote object eagerly (otherwise browser GCs it on navigation).
375    pub async fn dispose(&self) -> Result<()> {
376        let _ = self
377            .page
378            .session()
379            .send("Runtime.releaseObject", json!({ "objectId": self.object_id }))
380            .await;
381        Ok(())
382    }
383
384    /// If this element is an `<iframe>`, return its content [`Frame`]; else `None`.
385    ///
386    /// The frame id is read from the DOM node's `contentDocument.frameId`.
387    /// Best-effort: returns `None` if the element is not a frame or the content
388    /// frame cannot be resolved (e.g. cross-origin, where `contentDocument` is
389    /// inaccessible from the embedding context).
390    pub async fn content_frame(&self) -> Result<Option<Frame>> {
391        let fid = content_frame_id(&self.page, &self.object_id).await?;
392        Ok(fid.map(|id| Frame::new(self.page.clone(), id)))
393    }
394
395    /// The [`Frame`] that owns this element.
396    ///
397    /// Resolves the element's owner execution context via the remote object's
398    /// frame (DOM.describeNode → frameId on the owning document), falling back
399    /// to the page's main frame. Best-effort.
400    pub async fn owner_frame(&self) -> Result<Option<Frame>> {
401        // The node's owning document carries the frame it lives in.
402        let desc = self
403            .page
404            .session()
405            .send("DOM.describeNode", json!({ "objectId": self.object_id }))
406            .await?;
407        // Walk up to the document node, which carries a frameId.
408        let frame_id = desc
409            .get("node")
410            .and_then(|n| n.get("frameId"))
411            .and_then(|v| v.as_str())
412            .map(String::from)
413            .or_else(|| {
414                // contentDocument on an iframe points at the inner frame; for a
415                // plain element the owner is the document's frame.
416                desc.get("node")
417                    .and_then(|n| n.get("owner"))
418                    .and_then(|o| o.get("frameId"))
419                    .and_then(|v| v.as_str())
420                    .map(String::from)
421            })
422            .or_else(|| self.page.main_frame_id());
423        Ok(frame_id.map(|id| Frame::new(self.page.clone(), id)))
424    }
425
426    /// Poll until this element reaches the requested [`ElementState`], using the
427    /// page's default timeout.
428    ///
429    /// `Stable` is detected by sampling the bounding box twice and checking that
430    /// it did not move; the other states query the injected element-state helper.
431    pub async fn wait_for_element_state(&self, state: ElementState) -> Result<()> {
432        let timeout = self.page.default_timeout();
433        let deadline = tokio::time::Instant::now() + timeout;
434        match state {
435            ElementState::Visible | ElementState::Hidden
436            | ElementState::Enabled | ElementState::Editable => {
437                let field = match state {
438                    ElementState::Visible => "visible",
439                    ElementState::Hidden => "hidden",
440                    ElementState::Enabled => "enabled",
441                    ElementState::Editable => "editable",
442                    ElementState::Stable => "visible",
443                };
444                loop {
445                    let v = self.state(field).await?;
446                    if v {
447                        return Ok(());
448                    }
449                    if tokio::time::Instant::now() >= deadline {
450                        return Err(Error::Timeout(format!(
451                            "wait_for_element_state({state:?}) timed out after {}ms",
452                            timeout.as_millis()
453                        )));
454                    }
455                    tokio::time::sleep(Duration::from_millis(100)).await;
456                }
457            }
458            ElementState::Stable => {
459                // Sample the bounding box twice; stable when it stops moving.
460                let mut prev = self.bounding_box().await?;
461                loop {
462                    tokio::time::sleep(Duration::from_millis(100)).await;
463                    let cur = self.bounding_box().await?;
464                    let stable = match (prev, cur) {
465                        (Some(a), Some(b)) => {
466                            (a.x - b.x).abs() < 0.5
467                                && (a.y - b.y).abs() < 0.5
468                                && (a.width - b.width).abs() < 0.5
469                                && (a.height - b.height).abs() < 0.5
470                        }
471                        (None, None) => true,
472                        _ => false,
473                    };
474                    if stable {
475                        return Ok(());
476                    }
477                    prev = cur;
478                    if tokio::time::Instant::now() >= deadline {
479                        return Err(Error::Timeout(format!(
480                            "wait_for_element_state(Stable) timed out after {}ms",
481                            timeout.as_millis()
482                        )));
483                    }
484                }
485            }
486        }
487    }
488
489    /// Always `1` — an `ElementHandle` pins exactly one element. Provided for
490    /// API parity with [`Locator::count`](crate::Locator::count); a disposed or
491    /// GC'd handle will still report `1` (use [`Self::is_visible`] to check it).
492    pub async fn count(&self) -> Result<usize> {
493        Ok(1)
494    }
495}