Skip to main content

playwright_cdp/
selectors.rs

1//! Selector-engine injection and element resolution.
2//!
3//! The engine JS ([`INJECTED_SCRIPT`]) is registered on `self.__pwcdpInjected`
4//! and exposes `querySelectorAll(root, selector)`, `querySelector`, and
5//! `elementState(el)`. Resolution here goes through `Runtime.callFunctionOn` and
6//! returns CDP `RemoteObjectId`s; we resolve a single node at a time to avoid
7//! materializing (and leaking) handles for every match.
8
9use crate::cdp::session::CdpSession;
10use crate::error::{Error, Result};
11use serde_json::{json, Value};
12
13/// The injected selector-engine bundle, embedded at compile time.
14pub const INJECTED_SCRIPT: &str = include_str!("../assets/injected_script.js");
15
16/// Count elements matching `selector` in the given execution context.
17pub async fn count(session: &CdpSession, ctx: Option<i64>, selector: &str) -> Result<usize> {
18    let v = eval_context(
19        session,
20        ctx,
21        "(sel) => self.__pwcdpInjected.querySelectorAll(document, sel).length",
22        json!(selector),
23    )
24    .await?;
25    v.as_u64()
26        .map(|n| n as usize)
27        .ok_or_else(|| Error::ProtocolError(format!("selector count returned non-number: {v}")))
28}
29
30/// Resolve a single element (by index) to its `RemoteObjectId`, or `None` if
31/// no element exists at that index.
32pub async fn element_at(
33    session: &CdpSession,
34    ctx: Option<i64>,
35    selector: &str,
36    index: usize,
37) -> Result<Option<String>> {
38    eval_context_handle(
39        session,
40        ctx,
41        "(arg) => { const e = self.__pwcdpInjected.querySelectorAll(document, arg.sel); return arg.index < e.length ? e[arg.index] : undefined; }",
42        json!({ "sel": selector, "index": index }),
43    )
44    .await
45}
46
47/// Descend an ordered chain of same-origin iframes to reach the deepest
48/// frame's `contentDocument`. `frame_chain` lists iframe selectors outermost
49/// first; each is resolved in the previous frame's document (the first in the
50/// top `document`). Returns the `RemoteObjectId` of the deepest content
51/// document, or `None` if any hop is missing or cross-origin.
52#[allow(dead_code)]
53pub(crate) async fn frame_chain_document_handle(
54    session: &CdpSession,
55    ctx: Option<i64>,
56    frame_chain: &[String],
57) -> Result<Option<String>> {
58    eval_context_handle(
59        session,
60        ctx,
61        "(arg) => { let root = document; for (const fs of arg.chain) { const f = self.__pwcdpInjected.querySelectorAll(root, fs)[0]; root = f && f.contentDocument; if (!root) return undefined; } return root; }",
62        json!({ "chain": frame_chain }),
63    )
64    .await
65}
66
67/// Like [`count`], but scoped to the `contentDocument` reached by descending
68/// `frame_chain` (ordered same-origin iframe selectors, outermost first).
69/// Returns `0` if any iframe in the chain is missing or cross-origin.
70pub(crate) async fn count_in(
71    session: &CdpSession,
72    ctx: Option<i64>,
73    frame_chain: &[String],
74    selector: &str,
75) -> Result<usize> {
76    let v = eval_context(
77        session,
78        ctx,
79        "(arg) => { let root = document; for (const fs of arg.chain) { const f = self.__pwcdpInjected.querySelectorAll(root, fs)[0]; root = f && f.contentDocument; if (!root) return 0; } return self.__pwcdpInjected.querySelectorAll(root, arg.sel).length; }",
80        json!({ "chain": frame_chain, "sel": selector }),
81    )
82    .await?;
83    v.as_u64()
84        .map(|n| n as usize)
85        .ok_or_else(|| Error::ProtocolError(format!("selector count returned non-number: {v}")))
86}
87
88/// Like [`element_at`], but scoped to the `contentDocument` reached by
89/// descending `frame_chain` (ordered same-origin iframe selectors, outermost
90/// first). Returns `None` if the chain is unreachable or no element exists at
91/// `index`.
92pub(crate) async fn element_at_in(
93    session: &CdpSession,
94    ctx: Option<i64>,
95    frame_chain: &[String],
96    selector: &str,
97    index: usize,
98) -> Result<Option<String>> {
99    eval_context_handle(
100        session,
101        ctx,
102        "(arg) => { let root = document; for (const fs of arg.chain) { const f = self.__pwcdpInjected.querySelectorAll(root, fs)[0]; root = f && f.contentDocument; if (!root) return undefined; } const e = self.__pwcdpInjected.querySelectorAll(root, arg.sel); return arg.index < e.length ? e[arg.index] : undefined; }",
103        json!({ "chain": frame_chain, "sel": selector, "index": index }),
104    )
105    .await
106}
107
108// --- low-level evaluation wrappers ------------------------------------------
109//
110// When a `ctx` (execution-context id) is supplied, evaluation runs in that
111// context via `Runtime.evaluate { contextId }`. This is required for
112// child-frame scoping: each frame has its own main-world context, and the
113// top-level page's default context sees only its own document.
114//
115// When `ctx` is `None` we omit `contextId`, letting CDP pick the page's default
116// main-world context. This is the legacy path and what most page-level helpers
117// used before per-frame contexts were tracked.
118
119/// Evaluate a `(arg) => ...` function in `ctx`'s execution context (or the
120/// page's default context if `ctx` is `None`), returning by value.
121pub(crate) async fn eval_context(
122    session: &CdpSession,
123    ctx: Option<i64>,
124    function: &str,
125    arg: Value,
126) -> Result<Value> {
127    let expr = format!("({})({})", function, serde_json::to_string(&arg)?);
128    let mut params = json!({
129        "expression": expr,
130        "returnByValue": true,
131        "awaitPromise": true,
132    });
133    if let Some(id) = ctx {
134        params["contextId"] = json!(id);
135    }
136    let resp = session.send("Runtime.evaluate", params).await?;
137    if let Some(exc) = resp.get("exceptionDetails") {
138        let msg = exc
139            .get("exception")
140            .and_then(|e| e.get("description"))
141            .and_then(|v| v.as_str())
142            .unwrap_or("evaluation threw");
143        return Err(Error::ProtocolError(format!("eval error: {msg}")));
144    }
145    Ok(resp
146        .get("result")
147        .and_then(|r| r.get("value"))
148        .cloned()
149        .unwrap_or(Value::Null))
150}
151
152/// Evaluate a `(arg) => ...` function in `ctx`'s execution context (or the
153/// page's default context if `ctx` is `None`), returning a `RemoteObjectId` for
154/// the returned object (or `None` for null/undefined).
155pub(crate) async fn eval_context_handle(
156    session: &CdpSession,
157    ctx: Option<i64>,
158    function: &str,
159    arg: Value,
160) -> Result<Option<String>> {
161    let expr = format!("({})({})", function, serde_json::to_string(&arg)?);
162    let mut params = json!({
163        "expression": expr,
164        "returnByValue": false,
165        "awaitPromise": true,
166    });
167    if let Some(id) = ctx {
168        params["contextId"] = json!(id);
169    }
170    let resp = session.send("Runtime.evaluate", params).await?;
171    if let Some(exc) = resp.get("exceptionDetails") {
172        let msg = exc
173            .get("exception")
174            .and_then(|e| e.get("description"))
175            .and_then(|v| v.as_str())
176            .unwrap_or("evaluation threw");
177        return Err(Error::ProtocolError(format!("eval error: {msg}")));
178    }
179    Ok(resp
180        .get("result")
181        .and_then(|r| r.get("objectId"))
182        .and_then(|v| v.as_str())
183        .map(|s| s.to_string()))
184}
185
186/// Evaluate an `(element, arg) => ...` function against an element handle, by value.
187///
188/// The element is passed as the first explicit argument (via its objectId) so
189/// arrow functions work — `callFunctionOn` otherwise binds the element to
190/// `this`, which arrow functions ignore.
191pub(crate) async fn eval_object(
192    session: &CdpSession,
193    object_id: &str,
194    function: &str,
195    arg: Value,
196) -> Result<Value> {
197    let params = json!({
198        "objectId": object_id,
199        "functionDeclaration": function,
200        "arguments": [{ "objectId": object_id }, { "value": arg }],
201        "returnByValue": true,
202        "awaitPromise": true,
203    });
204    let result = call(session, params).await?;
205    Ok(result.get("value").cloned().unwrap_or(Value::Null))
206}
207
208/// Run `Runtime.callFunctionOn` and return the `result` object.
209async fn call(session: &CdpSession, params: Value) -> Result<Value> {
210    let resp = session.send("Runtime.callFunctionOn", params).await?;
211    // CDP nests the remote object under "result".
212    Ok(resp.get("result").cloned().unwrap_or(Value::Null))
213}