playwright_rs/protocol/selectors.rs
1// Copyright 2026 Paul Adamson
2// Licensed under the Apache License, Version 2.0
3//
4// Selectors — custom selector engine registration
5//
6// Architecture Reference:
7// - Python: playwright-python/playwright/_impl/_selectors.py
8// - JavaScript: playwright/packages/playwright-core/src/client/selectors.ts
9// - Docs: https://playwright.dev/docs/api/class-selectors
10//
11// IMPORTANT: Unlike most Playwright objects, Selectors is NOT a ChannelOwner.
12// It does not have its own GUID or server-side representation. Instead, it is
13// a pure client-side coordinator that:
14// 1. Tracks registered selector engines and test ID attribute
15// 2. Applies state to all registered BrowserContext objects via their channels
16// 3. Stores engine definitions so they can be re-applied to new contexts
17//
18// This matches the Python and JavaScript implementations exactly.
19
20//! Selectors — register custom selector engines and configure test ID attribute.
21//!
22//! Selectors can be used to install custom selector engines. Custom selector engines
23//! are consulted when Playwright evaluates CSS, XPath, and other selectors.
24//!
25//! Unlike most Playwright objects, `Selectors` is a pure client-side coordinator;
26//! it does not correspond to a server-side protocol object. It keeps track of
27//! registered engines and the test ID attribute, and propagates changes to all
28//! active browser contexts.
29//!
30//! # Example
31//!
32//! ```ignore
33//! use playwright_rs::protocol::Playwright;
34//!
35//! #[tokio::main]
36//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
37//! let playwright = Playwright::launch().await?;
38//!
39//! // Access the shared Selectors instance
40//! let selectors = playwright.selectors();
41//!
42//! // Register a custom selector engine
43//! let script = r#"
44//! {
45//! query(root, selector) {
46//! return root.querySelector(selector);
47//! },
48//! queryAll(root, selector) {
49//! return Array.from(root.querySelectorAll(selector));
50//! }
51//! }
52//! "#;
53//! selectors.register("tag", script, None).await?;
54//!
55//! // Change the attribute used by get_by_test_id()
56//! selectors.set_test_id_attribute("data-custom-id").await?;
57//!
58//! playwright.shutdown().await?;
59//! Ok(())
60//! }
61//! ```
62//!
63//! See: <https://playwright.dev/docs/api/class-selectors>
64
65use crate::error::{Error, Result};
66use crate::server::channel::Channel;
67use parking_lot::Mutex;
68use std::collections::HashSet;
69use std::sync::Arc;
70
71/// A registered selector engine definition.
72#[derive(Clone, Debug)]
73struct SelectorEngine {
74 name: String,
75 script: String,
76 content_script: bool,
77}
78
79/// Inner shared state for `Selectors`.
80struct SelectorsInner {
81 /// Registered selector engine definitions (kept so they can be re-applied to new contexts).
82 engines: Vec<SelectorEngine>,
83 /// Names of registered engines (for duplicate detection).
84 engine_names: HashSet<String>,
85 /// Custom test ID attribute name, if overridden.
86 test_id_attribute: Option<String>,
87 /// Active browser contexts that need to receive selector updates.
88 contexts: Vec<Channel>,
89}
90
91/// Selectors — manages custom selector engines and test ID attribute configuration.
92///
93/// An instance of Selectors is available via [`crate::protocol::Playwright::selectors()`].
94///
95/// Selector engines registered here are applied to all browser contexts. Register
96/// engines **before** creating pages that will use them.
97///
98/// See: <https://playwright.dev/docs/api/class-selectors>
99#[derive(Clone)]
100pub struct Selectors {
101 inner: Arc<Mutex<SelectorsInner>>,
102}
103
104impl Selectors {
105 /// Creates a new, empty Selectors coordinator.
106 pub fn new() -> Self {
107 Self {
108 inner: Arc::new(Mutex::new(SelectorsInner {
109 engines: Vec::new(),
110 engine_names: HashSet::new(),
111 test_id_attribute: None,
112 contexts: Vec::new(),
113 })),
114 }
115 }
116
117 /// Registers a context's channel so it receives selector updates.
118 ///
119 /// Called by BrowserContext when it is created, so that:
120 /// 1. All previously registered engines are applied to it immediately.
121 /// 2. Future `register()` / `set_test_id_attribute()` calls reach it.
122 pub async fn add_context(&self, channel: Channel) -> Result<()> {
123 let (engines_snapshot, attr_snapshot) = {
124 let mut inner = self.inner.lock();
125 inner.contexts.push(channel.clone());
126 (inner.engines.clone(), inner.test_id_attribute.clone())
127 };
128
129 // Re-apply all previously registered engines to this new context.
130 for engine in &engines_snapshot {
131 let params = serde_json::json!({
132 "selectorEngine": {
133 "name": engine.name,
134 "source": engine.script,
135 "contentScript": engine.content_script,
136 }
137 });
138 channel
139 .send_no_result("registerSelectorEngine", params)
140 .await?;
141 }
142
143 // Apply the current test ID attribute, if any.
144 if let Some(attr) = attr_snapshot {
145 channel
146 .send_no_result(
147 "setTestIdAttributeName",
148 serde_json::json!({ "testIdAttributeName": attr }),
149 )
150 .await?;
151 }
152
153 Ok(())
154 }
155
156 /// Removes a context's channel when it is closed.
157 ///
158 /// Called by BrowserContext on close to avoid sending messages to dead channels.
159 pub fn remove_context(&self, channel: &Channel) {
160 let mut inner = self.inner.lock();
161 inner.contexts.retain(|c| c.guid() != channel.guid());
162 }
163
164 /// Registers a custom selector engine.
165 ///
166 /// The script must evaluate to an object with `query` and `queryAll` methods:
167 ///
168 /// ```text
169 /// {
170 /// query(root, selector) { return root.querySelector(selector); },
171 /// queryAll(root, selector) { return Array.from(root.querySelectorAll(selector)); }
172 /// }
173 /// ```
174 ///
175 /// After registration, use the engine with `page.locator("name=selector")`.
176 ///
177 /// # Arguments
178 ///
179 /// * `name` - Name to assign to the selector engine.
180 /// * `script` - JavaScript string that evaluates to a selector engine factory.
181 /// * `content_script` - Whether to run the engine in isolated content script mode.
182 /// Defaults to `false`.
183 ///
184 /// # Errors
185 ///
186 /// Returns error if:
187 /// - A selector engine with the same name is already registered
188 /// - Any context rejects the registration (invalid script, etc.)
189 ///
190 /// See: <https://playwright.dev/docs/api/class-selectors#selectors-register>
191 pub async fn register(
192 &self,
193 name: &str,
194 script: &str,
195 content_script: Option<bool>,
196 ) -> Result<()> {
197 let content_script = content_script.unwrap_or(false);
198
199 let channels_snapshot = {
200 let mut inner = self.inner.lock();
201
202 if inner.engine_names.contains(name) {
203 return Err(Error::ProtocolError(format!(
204 "Selector engine '{name}' is already registered"
205 )));
206 }
207
208 inner.engine_names.insert(name.to_string());
209 inner.engines.push(SelectorEngine {
210 name: name.to_string(),
211 script: script.to_string(),
212 content_script,
213 });
214
215 inner.contexts.clone()
216 };
217
218 // The protocol expects { "selectorEngine": { "name": ..., "source": ..., "contentScript": ... } }
219 let params = serde_json::json!({
220 "selectorEngine": {
221 "name": name,
222 "source": script,
223 "contentScript": content_script,
224 }
225 });
226
227 // Broadcast to all active contexts.
228 for channel in &channels_snapshot {
229 channel
230 .send_no_result("registerSelectorEngine", params.clone())
231 .await?;
232 }
233
234 Ok(())
235 }
236
237 /// Returns the current test ID attribute name used by `get_by_test_id()` locators.
238 ///
239 /// Defaults to `"data-testid"`.
240 pub fn test_id_attribute(&self) -> String {
241 self.inner
242 .lock()
243 .test_id_attribute
244 .clone()
245 .unwrap_or_else(|| "data-testid".to_string())
246 }
247
248 /// Sets the attribute used by `get_by_test_id()` locators.
249 ///
250 /// By default, Playwright uses `data-testid`. Calling this method changes the
251 /// attribute name for all current and future contexts.
252 ///
253 /// # Arguments
254 ///
255 /// * `attribute` - The attribute name to use as the test ID (e.g., `"data-custom-id"`).
256 ///
257 /// # Errors
258 ///
259 /// Returns error if any context rejects the update.
260 ///
261 /// See: <https://playwright.dev/docs/api/class-selectors#selectors-set-test-id-attribute>
262 pub async fn set_test_id_attribute(&self, attribute: &str) -> Result<()> {
263 let channels_snapshot = {
264 let mut inner = self.inner.lock();
265 inner.test_id_attribute = Some(attribute.to_string());
266 inner.contexts.clone()
267 };
268
269 let params = serde_json::json!({ "testIdAttributeName": attribute });
270
271 for channel in &channels_snapshot {
272 channel
273 .send_no_result("setTestIdAttributeName", params.clone())
274 .await?;
275 }
276
277 Ok(())
278 }
279}
280
281impl Default for Selectors {
282 fn default() -> Self {
283 Self::new()
284 }
285}
286
287impl std::fmt::Debug for Selectors {
288 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289 let inner = self.inner.lock();
290 f.debug_struct("Selectors")
291 .field("engines", &inner.engines)
292 .field("test_id_attribute", &inner.test_id_attribute)
293 .finish()
294 }
295}