playwright_rs/protocol/locator.rs
1//! Locator — lazy element selector with auto-waiting.
2//!
3//! Locators are the central piece of Playwright's auto-waiting and retry
4//! semantics. They represent *a way to find element(s)* at any given
5//! moment — not an element handle. Each action re-queries.
6//!
7//! Key characteristics:
8//! - Lazy: don't execute until an action is performed
9//! - Retryable: auto-wait for elements to match actionability checks
10//! - Chainable: can create sub-locators via `first()`, `last()`,
11//! `nth()`, `locator()`, `filter()`
12//!
13//! Architecture:
14//! - Locator is **not** a ChannelOwner; it's a lightweight wrapper
15//! - Stores a selector string + reference to its Frame + parent Page
16//! - Delegates all operations to Frame with `strict=true`
17//!
18//! # Example
19//!
20//! ```no_run
21//! use playwright_rs::Playwright;
22//!
23//! #[tokio::main]
24//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
25//! let pw = Playwright::launch().await?;
26//! let browser = pw.chromium().launch().await?;
27//! let page = browser.new_page().await?;
28//!
29//! page.set_content(
30//! r#"<button data-testid="submit" role="button">Submit</button>
31//! <ul><li class="item">A</li><li class="item">B</li></ul>"#,
32//! None,
33//! ).await?;
34//!
35//! // Basic locator + action
36//! page.locator("button").click(None).await?;
37//!
38//! // Robust locator from a fragile starting point: normalize() asks
39//! // Playwright for the canonical equivalent (test-id / role / text).
40//! let stable = page
41//! .locator("body button:nth-child(1)")
42//! .normalize()
43//! .await?;
44//! assert!(!stable.selector().is_empty());
45//!
46//! // Chain primitives: filter, count, nth
47//! let items = page.locator(".item");
48//! assert_eq!(items.count().await?, 2);
49//! items.nth(0).click(None).await?;
50//!
51//! browser.close().await?;
52//! Ok(())
53//! }
54//! ```
55//!
56//! See: <https://playwright.dev/docs/api/class-locator>
57
58use crate::error::Result;
59use crate::protocol::Frame;
60use serde::Deserialize;
61
62/// Trait for action option structs that have an optional timeout field.
63/// Used by `Locator::with_timeout` to inject the page's default timeout.
64pub(crate) trait HasTimeout {
65 fn timeout_ref(&self) -> &Option<f64>;
66 fn timeout_ref_mut(&mut self) -> &mut Option<f64>;
67}
68
69macro_rules! impl_has_timeout {
70 ($($ty:ty),+ $(,)?) => {
71 $(impl HasTimeout for $ty {
72 fn timeout_ref(&self) -> &Option<f64> { &self.timeout }
73 fn timeout_ref_mut(&mut self) -> &mut Option<f64> { &mut self.timeout }
74 })+
75 };
76}
77
78impl_has_timeout!(
79 crate::protocol::WaitForFunctionOptions,
80 crate::protocol::ClickOptions,
81 crate::protocol::FillOptions,
82 crate::protocol::PressOptions,
83 crate::protocol::CheckOptions,
84 crate::protocol::HoverOptions,
85 crate::protocol::SelectOptions,
86 crate::protocol::ScreenshotOptions,
87 crate::protocol::TapOptions,
88 crate::protocol::DragToOptions,
89 crate::protocol::DropOptions,
90 crate::protocol::WaitForOptions,
91);
92use std::sync::Arc;
93
94/// The bounding box of an element in pixels.
95///
96/// All values are measured relative to the top-left corner of the page.
97///
98/// See: <https://playwright.dev/docs/api/class-locator#locator-bounding-box>
99#[derive(Debug, Clone, PartialEq, Deserialize)]
100#[non_exhaustive]
101pub struct BoundingBox {
102 /// The x coordinate of the top-left corner of the element in pixels.
103 pub x: f64,
104 /// The y coordinate of the top-left corner of the element in pixels.
105 pub y: f64,
106 /// The width of the element in pixels.
107 pub width: f64,
108 /// The height of the element in pixels.
109 pub height: f64,
110}
111
112/// Escapes text for use in Playwright's internal selector engine.
113///
114/// JSON-stringifies the text and appends `i` (case-insensitive) or `s` (strict/exact).
115/// Matches the `escapeForTextSelector`/`escapeForAttributeSelector` in Playwright TypeScript.
116/// `s` as a JSON string literal, the quoting the selector engines read.
117fn json_quote(s: &str) -> String {
118 // Serializing a `str` cannot fail: it has no map keys or non-finite
119 // numbers to reject.
120 serde_json::to_string(s).expect("a str always serializes")
121}
122
123fn escape_for_selector(text: &str, exact: bool) -> String {
124 let suffix = if exact { "s" } else { "i" };
125 let escaped = json_quote(text);
126 format!("{}{}", escaped, suffix)
127}
128
129/// Builds the internal selector string for `get_by_text`.
130///
131/// - `exact=false` → `internal:text="text"i` (case-insensitive substring)
132/// - `exact=true` → `internal:text="text"s` (case-sensitive exact)
133pub(crate) fn get_by_text_selector(text: &str, exact: bool) -> String {
134 format!("internal:text={}", escape_for_selector(text, exact))
135}
136
137/// Builds the internal selector string for `get_by_label`.
138///
139/// - `exact=false` → `internal:label="text"i`
140/// - `exact=true` → `internal:label="text"s`
141pub(crate) fn get_by_label_selector(text: &str, exact: bool) -> String {
142 format!("internal:label={}", escape_for_selector(text, exact))
143}
144
145/// Builds the internal selector string for `get_by_placeholder`.
146///
147/// - `exact=false` → `internal:attr=[placeholder="text"i]`
148/// - `exact=true` → `internal:attr=[placeholder="text"s]`
149pub(crate) fn get_by_placeholder_selector(text: &str, exact: bool) -> String {
150 format!(
151 "internal:attr=[placeholder={}]",
152 escape_for_selector(text, exact)
153 )
154}
155
156/// Builds the internal selector string for `get_by_alt_text`.
157///
158/// - `exact=false` → `internal:attr=[alt="text"i]`
159/// - `exact=true` → `internal:attr=[alt="text"s]`
160pub(crate) fn get_by_alt_text_selector(text: &str, exact: bool) -> String {
161 format!("internal:attr=[alt={}]", escape_for_selector(text, exact))
162}
163
164/// Builds the internal selector string for `get_by_title`.
165///
166/// - `exact=false` → `internal:attr=[title="text"i]`
167/// - `exact=true` → `internal:attr=[title="text"s]`
168pub(crate) fn get_by_title_selector(text: &str, exact: bool) -> String {
169 format!("internal:attr=[title={}]", escape_for_selector(text, exact))
170}
171
172/// Builds the internal selector string for `get_by_test_id`.
173///
174/// Uses `data-testid` attribute by default (matching Playwright's default).
175/// Always uses exact matching (`s` suffix).
176pub(crate) fn get_by_test_id_selector(test_id: &str) -> String {
177 get_by_test_id_selector_with_attr(test_id, "data-testid")
178}
179
180/// Builds the internal selector string for `get_by_test_id` with a custom attribute.
181///
182/// Used when `playwright.selectors().set_test_id_attribute()` has been called.
183pub(crate) fn get_by_test_id_selector_with_attr(test_id: &str, attribute: &str) -> String {
184 format!(
185 "internal:testid=[{}={}]",
186 attribute,
187 escape_for_selector(test_id, true)
188 )
189}
190
191/// Escapes text for use in Playwright's attribute role selector.
192///
193/// Unlike `escape_for_selector` (which uses JSON encoding), this only escapes
194/// backslashes and double quotes, matching Playwright's `escapeForAttributeSelector`.
195fn escape_for_attribute_selector(text: &str, exact: bool) -> String {
196 let suffix = if exact { "s" } else { "i" };
197 let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
198 format!("\"{}\"{}", escaped, suffix)
199}
200
201/// Builds the internal selector string for `get_by_role`.
202///
203/// Format: `internal:role=<role>[prop1=val1][prop2=val2]...`
204///
205/// Properties are appended in Playwright's required order:
206/// checked, disabled, selected, expanded, include-hidden, level, name, pressed.
207pub(crate) fn get_by_role_selector(role: AriaRole, options: Option<GetByRoleOptions>) -> String {
208 let mut selector = format!("internal:role={}", role.as_str());
209
210 if let Some(opts) = options {
211 if let Some(checked) = opts.checked {
212 selector.push_str(&format!("[checked={}]", checked));
213 }
214 if let Some(disabled) = opts.disabled {
215 selector.push_str(&format!("[disabled={}]", disabled));
216 }
217 if let Some(selected) = opts.selected {
218 selector.push_str(&format!("[selected={}]", selected));
219 }
220 if let Some(expanded) = opts.expanded {
221 selector.push_str(&format!("[expanded={}]", expanded));
222 }
223 if let Some(include_hidden) = opts.include_hidden {
224 selector.push_str(&format!("[include-hidden={}]", include_hidden));
225 }
226 if let Some(level) = opts.level {
227 selector.push_str(&format!("[level={}]", level));
228 }
229 if let Some(name) = &opts.name {
230 let exact = opts.exact.unwrap_or(false);
231 selector.push_str(&format!(
232 "[name={}]",
233 escape_for_attribute_selector(name, exact)
234 ));
235 }
236 if let Some(description) = &opts.description {
237 let exact = opts.exact.unwrap_or(false);
238 selector.push_str(&format!(
239 "[description={}]",
240 escape_for_attribute_selector(description, exact)
241 ));
242 }
243 if let Some(pressed) = opts.pressed {
244 selector.push_str(&format!("[pressed={}]", pressed));
245 }
246 }
247
248 selector
249}
250
251/// ARIA roles for `get_by_role()` locator.
252///
253/// Represents WAI-ARIA roles used to locate elements by their accessibility role.
254/// Matches Playwright's `AriaRole` enum across all language bindings.
255///
256/// See: <https://playwright.dev/docs/api/class-page#page-get-by-role>
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258#[non_exhaustive]
259pub enum AriaRole {
260 Alert,
261 Alertdialog,
262 Application,
263 Article,
264 Banner,
265 Blockquote,
266 Button,
267 Caption,
268 Cell,
269 Checkbox,
270 Code,
271 Columnheader,
272 Combobox,
273 Complementary,
274 Contentinfo,
275 Definition,
276 Deletion,
277 Dialog,
278 Directory,
279 Document,
280 Emphasis,
281 Feed,
282 Figure,
283 Form,
284 Generic,
285 Grid,
286 Gridcell,
287 Group,
288 Heading,
289 Img,
290 Insertion,
291 Link,
292 List,
293 Listbox,
294 Listitem,
295 Log,
296 Main,
297 Marquee,
298 Math,
299 Meter,
300 Menu,
301 Menubar,
302 Menuitem,
303 Menuitemcheckbox,
304 Menuitemradio,
305 Navigation,
306 None,
307 Note,
308 Option,
309 Paragraph,
310 Presentation,
311 Progressbar,
312 Radio,
313 Radiogroup,
314 Region,
315 Row,
316 Rowgroup,
317 Rowheader,
318 Scrollbar,
319 Search,
320 Searchbox,
321 Separator,
322 Slider,
323 Spinbutton,
324 Status,
325 Strong,
326 Subscript,
327 Superscript,
328 Switch,
329 Tab,
330 Table,
331 Tablist,
332 Tabpanel,
333 Term,
334 Textbox,
335 Time,
336 Timer,
337 Toolbar,
338 Tooltip,
339 Tree,
340 Treegrid,
341 Treeitem,
342}
343
344impl AriaRole {
345 /// Returns the lowercase string representation used in selectors.
346 pub fn as_str(&self) -> &'static str {
347 match self {
348 Self::Alert => "alert",
349 Self::Alertdialog => "alertdialog",
350 Self::Application => "application",
351 Self::Article => "article",
352 Self::Banner => "banner",
353 Self::Blockquote => "blockquote",
354 Self::Button => "button",
355 Self::Caption => "caption",
356 Self::Cell => "cell",
357 Self::Checkbox => "checkbox",
358 Self::Code => "code",
359 Self::Columnheader => "columnheader",
360 Self::Combobox => "combobox",
361 Self::Complementary => "complementary",
362 Self::Contentinfo => "contentinfo",
363 Self::Definition => "definition",
364 Self::Deletion => "deletion",
365 Self::Dialog => "dialog",
366 Self::Directory => "directory",
367 Self::Document => "document",
368 Self::Emphasis => "emphasis",
369 Self::Feed => "feed",
370 Self::Figure => "figure",
371 Self::Form => "form",
372 Self::Generic => "generic",
373 Self::Grid => "grid",
374 Self::Gridcell => "gridcell",
375 Self::Group => "group",
376 Self::Heading => "heading",
377 Self::Img => "img",
378 Self::Insertion => "insertion",
379 Self::Link => "link",
380 Self::List => "list",
381 Self::Listbox => "listbox",
382 Self::Listitem => "listitem",
383 Self::Log => "log",
384 Self::Main => "main",
385 Self::Marquee => "marquee",
386 Self::Math => "math",
387 Self::Meter => "meter",
388 Self::Menu => "menu",
389 Self::Menubar => "menubar",
390 Self::Menuitem => "menuitem",
391 Self::Menuitemcheckbox => "menuitemcheckbox",
392 Self::Menuitemradio => "menuitemradio",
393 Self::Navigation => "navigation",
394 Self::None => "none",
395 Self::Note => "note",
396 Self::Option => "option",
397 Self::Paragraph => "paragraph",
398 Self::Presentation => "presentation",
399 Self::Progressbar => "progressbar",
400 Self::Radio => "radio",
401 Self::Radiogroup => "radiogroup",
402 Self::Region => "region",
403 Self::Row => "row",
404 Self::Rowgroup => "rowgroup",
405 Self::Rowheader => "rowheader",
406 Self::Scrollbar => "scrollbar",
407 Self::Search => "search",
408 Self::Searchbox => "searchbox",
409 Self::Separator => "separator",
410 Self::Slider => "slider",
411 Self::Spinbutton => "spinbutton",
412 Self::Status => "status",
413 Self::Strong => "strong",
414 Self::Subscript => "subscript",
415 Self::Superscript => "superscript",
416 Self::Switch => "switch",
417 Self::Tab => "tab",
418 Self::Table => "table",
419 Self::Tablist => "tablist",
420 Self::Tabpanel => "tabpanel",
421 Self::Term => "term",
422 Self::Textbox => "textbox",
423 Self::Time => "time",
424 Self::Timer => "timer",
425 Self::Toolbar => "toolbar",
426 Self::Tooltip => "tooltip",
427 Self::Tree => "tree",
428 Self::Treegrid => "treegrid",
429 Self::Treeitem => "treeitem",
430 }
431 }
432}
433
434/// Options for `get_by_role()` locator.
435///
436/// All fields are optional. When not specified, the property is not included
437/// in the role selector, meaning it matches any value.
438///
439/// See: <https://playwright.dev/docs/api/class-page#page-get-by-role>
440#[derive(Debug, Clone, Default)]
441#[non_exhaustive]
442pub struct GetByRoleOptions {
443 /// Whether the element is checked (for checkboxes, radio buttons).
444 pub checked: Option<bool>,
445 /// Whether the element is disabled.
446 pub disabled: Option<bool>,
447 /// Whether the element is selected (for options).
448 pub selected: Option<bool>,
449 /// Whether the element is expanded (for tree items, comboboxes).
450 pub expanded: Option<bool>,
451 /// Whether to include hidden elements.
452 pub include_hidden: Option<bool>,
453 /// The heading level (1-6, for heading role).
454 pub level: Option<u32>,
455 /// The accessible name of the element.
456 pub name: Option<String>,
457 /// The accessible description of the element (WAI-ARIA), matched in addition
458 /// to role/name. Honors `exact` like `name`.
459 pub description: Option<String>,
460 /// Whether `name`/`description` matching is exact (case-sensitive,
461 /// full-string). Default is false (case-insensitive substring).
462 pub exact: Option<bool>,
463 /// Whether the element is pressed (for toggle buttons).
464 pub pressed: Option<bool>,
465}
466
467impl GetByRoleOptions {
468 /// Match only checked / unchecked elements.
469 pub fn checked(mut self, checked: bool) -> Self {
470 self.checked = Some(checked);
471 self
472 }
473 /// Match only enabled / disabled elements.
474 pub fn disabled(mut self, disabled: bool) -> Self {
475 self.disabled = Some(disabled);
476 self
477 }
478 /// Match only selected / unselected elements.
479 pub fn selected(mut self, selected: bool) -> Self {
480 self.selected = Some(selected);
481 self
482 }
483 /// Match only expanded / collapsed elements.
484 pub fn expanded(mut self, expanded: bool) -> Self {
485 self.expanded = Some(expanded);
486 self
487 }
488 /// Include elements normally excluded from the accessibility tree.
489 pub fn include_hidden(mut self, include_hidden: bool) -> Self {
490 self.include_hidden = Some(include_hidden);
491 self
492 }
493 /// Match the aria-level (e.g. heading level).
494 pub fn level(mut self, level: u32) -> Self {
495 self.level = Some(level);
496 self
497 }
498 /// Match the accessible name.
499 pub fn name(mut self, name: impl Into<String>) -> Self {
500 self.name = Some(name.into());
501 self
502 }
503 /// Match the accessible description.
504 pub fn description(mut self, description: impl Into<String>) -> Self {
505 self.description = Some(description.into());
506 self
507 }
508 /// Whether `name`/`description` match exactly (case-sensitive whole string).
509 pub fn exact(mut self, exact: bool) -> Self {
510 self.exact = Some(exact);
511 self
512 }
513 /// Match only pressed / unpressed elements.
514 pub fn pressed(mut self, pressed: bool) -> Self {
515 self.pressed = Some(pressed);
516 self
517 }
518}
519
520/// Options for [`Locator::highlight()`].
521#[derive(Debug, Clone, Default)]
522#[non_exhaustive]
523pub struct HighlightOptions {
524 /// Extra inline CSS applied to the debug highlight overlay.
525 pub style: Option<String>,
526}
527
528impl HighlightOptions {
529 /// Extra inline CSS applied to the debug highlight overlay.
530 pub fn style(mut self, style: impl Into<String>) -> Self {
531 self.style = Some(style.into());
532 self
533 }
534}
535
536/// Options for [`Locator::filter()`].
537///
538/// Narrows an existing locator according to the specified criteria.
539/// All fields are optional; unset fields are ignored.
540///
541/// See: <https://playwright.dev/docs/api/class-locator#locator-filter>
542#[derive(Debug, Clone, Default)]
543#[non_exhaustive]
544pub struct FilterOptions {
545 /// Matches elements containing the specified text (case-insensitive substring by default).
546 pub has_text: Option<String>,
547 /// Matches elements that do **not** contain the specified text anywhere inside.
548 pub has_not_text: Option<String>,
549 /// Narrows to elements that contain a descendant matching this locator.
550 ///
551 /// The inner locator is queried relative to the outer locator's matched element,
552 /// not the document root.
553 pub has: Option<Locator>,
554 /// Narrows to elements that do **not** contain a descendant matching this locator.
555 pub has_not: Option<Locator>,
556 /// Narrows to elements that are visible (`true`) or hidden (`false`).
557 ///
558 /// [`Locator::visible`] is the shorthand for the `true` case.
559 pub visible: Option<bool>,
560}
561
562impl FilterOptions {
563 /// Keep only elements containing the given text (case-insensitive substring).
564 pub fn has_text(mut self, has_text: impl Into<String>) -> Self {
565 self.has_text = Some(has_text.into());
566 self
567 }
568 /// Keep only elements NOT containing the given text.
569 pub fn has_not_text(mut self, has_not_text: impl Into<String>) -> Self {
570 self.has_not_text = Some(has_not_text.into());
571 self
572 }
573 /// Keep only elements containing a match for the inner locator.
574 pub fn has(mut self, has: Locator) -> Self {
575 self.has = Some(has);
576 self
577 }
578 /// Keep only elements NOT containing a match for the inner locator.
579 pub fn has_not(mut self, has_not: Locator) -> Self {
580 self.has_not = Some(has_not);
581 self
582 }
583 /// Keep only visible (`true`) or only hidden (`false`) elements.
584 pub fn visible(mut self, visible: bool) -> Self {
585 self.visible = Some(visible);
586 self
587 }
588}
589
590/// What is left of a `timeout_ms` budget after `elapsed`, for the second
591/// step of a two-step action that shares one deadline. Playwright reads 0 as
592/// no timeout, so it stays 0; otherwise at least a millisecond is left, since
593/// a remainder of 0 would turn into no timeout at all.
594fn remaining_ms(timeout_ms: f64, elapsed: std::time::Duration) -> f64 {
595 if timeout_ms == 0.0 {
596 return 0.0;
597 }
598 (timeout_ms - elapsed.as_secs_f64() * 1000.0).max(1.0)
599}
600
601/// The selector [`Locator::within`] builds: `child` chained under `parent`,
602/// so relative parts of `child` such as `nth` resolve inside each element
603/// `parent` matches.
604fn within_selector(child: &str, parent: &str) -> String {
605 let quoted = json_quote(child);
606 format!("{parent} >> internal:chain={quoted}")
607}
608
609/// The selector `filter` builds: each narrowing appended in the order
610/// upstream's client appends it, since the engine applies them in order.
611///
612/// The `has` / `has_not` selectors are passed separately because a
613/// [`FilterOptions`] holds whole locators, and only their selectors matter
614/// here.
615fn filter_selector(
616 base: &str,
617 options: &FilterOptions,
618 has: Option<&str>,
619 has_not: Option<&str>,
620) -> String {
621 let mut selector = base.to_string();
622
623 if let Some(text) = &options.has_text {
624 let escaped = escape_for_selector(text, false);
625 selector = format!("{selector} >> internal:has-text={escaped}");
626 }
627
628 if let Some(text) = &options.has_not_text {
629 let escaped = escape_for_selector(text, false);
630 selector = format!("{selector} >> internal:has-not-text={escaped}");
631 }
632
633 if let Some(inner) = has {
634 let quoted = json_quote(inner);
635 selector = format!("{selector} >> internal:has={quoted}");
636 }
637
638 if let Some(inner) = has_not {
639 let quoted = json_quote(inner);
640 selector = format!("{selector} >> internal:has-not={quoted}");
641 }
642
643 if let Some(visible) = options.visible {
644 selector = format!("{selector} >> visible={visible}");
645 }
646
647 selector
648}
649
650/// Locator represents a way to find element(s) on the page at any given moment.
651///
652/// Locators are lazy - they don't execute queries until an action is performed.
653/// This enables auto-waiting and retry-ability for robust test automation.
654///
655/// # Examples
656///
657/// ```no_run
658/// use playwright_rs::protocol::{Playwright, SelectOption};
659///
660/// #[tokio::main]
661/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
662/// let playwright = Playwright::launch().await?;
663/// let browser = playwright.chromium().launch().await?;
664/// let page = browser.new_page().await?;
665///
666/// // Demonstrate set_checked() - checkbox interaction
667/// let _ = page.goto(
668/// "data:text/html,<input type='checkbox' id='cb'>",
669/// None
670/// ).await;
671/// let checkbox = page.locator("#cb");
672/// checkbox.set_checked(true, None).await?;
673/// assert!(checkbox.is_checked().await?);
674/// checkbox.set_checked(false, None).await?;
675/// assert!(!checkbox.is_checked().await?);
676///
677/// // Demonstrate select_option() - select by value, label, and index
678/// let _ = page.goto(
679/// "data:text/html,<select id='fruits'>\
680/// <option value='apple'>Apple</option>\
681/// <option value='banana'>Banana</option>\
682/// <option value='cherry'>Cherry</option>\
683/// </select>",
684/// None
685/// ).await;
686/// let select = page.locator("#fruits");
687/// select.select_option("banana", None).await?;
688/// assert_eq!(select.input_value(None).await?, "banana");
689/// select.select_option(SelectOption::Label("Apple".to_string()), None).await?;
690/// assert_eq!(select.input_value(None).await?, "apple");
691/// select.select_option(SelectOption::Index(2), None).await?;
692/// assert_eq!(select.input_value(None).await?, "cherry");
693///
694/// // Demonstrate select_option_multiple() - multi-select
695/// let _ = page.goto(
696/// "data:text/html,<select id='colors' multiple>\
697/// <option value='red'>Red</option>\
698/// <option value='green'>Green</option>\
699/// <option value='blue'>Blue</option>\
700/// <option value='yellow'>Yellow</option>\
701/// </select>",
702/// None
703/// ).await;
704/// let multi = page.locator("#colors");
705/// let selected = multi.select_option_multiple(&["red", "blue"], None).await?;
706/// assert_eq!(selected.len(), 2);
707/// assert!(selected.contains(&"red".to_string()));
708/// assert!(selected.contains(&"blue".to_string()));
709///
710/// // Demonstrate get_by_text() - find elements by text content
711/// let _ = page.goto(
712/// "data:text/html,<button>Submit</button><button>Submit Order</button>",
713/// None
714/// ).await;
715/// let all_submits = page.get_by_text("Submit", false);
716/// assert_eq!(all_submits.count().await?, 2); // case-insensitive substring
717/// let exact_submit = page.get_by_text("Submit", true);
718/// assert_eq!(exact_submit.count().await?, 1); // exact match only
719///
720/// // Demonstrate get_by_label, get_by_placeholder, get_by_test_id
721/// let _ = page.goto(
722/// "data:text/html,<label for='email'>Email</label>\
723/// <input id='email' placeholder='you@example.com' data-testid='email-input' />",
724/// None
725/// ).await;
726/// let by_label = page.get_by_label("Email", false);
727/// assert_eq!(by_label.count().await?, 1);
728/// let by_placeholder = page.get_by_placeholder("you@example.com", true);
729/// assert_eq!(by_placeholder.count().await?, 1);
730/// let by_test_id = page.get_by_test_id("email-input");
731/// assert_eq!(by_test_id.count().await?, 1);
732///
733/// // Demonstrate screenshot() - element screenshot
734/// let _ = page.goto(
735/// "data:text/html,<h1 id='title'>Hello World</h1>",
736/// None
737/// ).await;
738/// let heading = page.locator("#title");
739/// let screenshot = heading.screenshot(None).await?;
740/// assert!(!screenshot.is_empty());
741///
742/// browser.close().await?;
743/// Ok(())
744/// }
745/// ```
746///
747/// See: <https://playwright.dev/docs/api/class-locator>
748#[derive(Clone)]
749pub struct Locator {
750 frame: Arc<Frame>,
751 selector: String,
752 page: crate::protocol::Page,
753}
754
755impl Locator {
756 /// Creates a new Locator (internal use only)
757 ///
758 /// Use `page.locator()` or `frame.locator()` to create locators in application code.
759 pub(crate) fn new(frame: Arc<Frame>, selector: String, page: crate::protocol::Page) -> Self {
760 Self {
761 frame,
762 selector,
763 page,
764 }
765 }
766
767 /// Returns the selector string for this locator
768 pub fn selector(&self) -> &str {
769 &self.selector
770 }
771
772 /// Returns the underlying frame for this locator (crate-internal use only).
773 pub(crate) fn frame(&self) -> &Arc<Frame> {
774 &self.frame
775 }
776
777 /// Waits until `expression` returns a truthy value, with the matched
778 /// element passed as its first argument.
779 ///
780 /// The element is resolved with strict matching, so a selector matching
781 /// more than one element is an error rather than a silent pick of the
782 /// first. Returns `()` rather than a handle: the protocol omits the
783 /// result when a selector is supplied, so there is nothing to hand back.
784 ///
785 /// `WaitForFunctionOptions::polling_interval` has no effect on this
786 /// form: the driver polls element-scoped waits on its own backoff
787 /// schedule (roughly 100ms growing toward 1s) and reads the interval
788 /// only for the page-global form.
789 ///
790 /// # Errors
791 ///
792 /// Returns an error if the expression does not become truthy within the
793 /// timeout (the page default unless set here), or if the selector
794 /// matches nothing or more than one element. The timeout is enforced by
795 /// the driver, so it surfaces as a protocol error carrying the driver's
796 /// "Timeout ...ms exceeded" message, with the selector appended.
797 ///
798 /// See: <https://playwright.dev/docs/api/class-locator#locator-wait-for-function>
799 pub async fn wait_for_function(
800 &self,
801 expression: &str,
802 options: impl Into<Option<crate::protocol::WaitForFunctionOptions>>,
803 ) -> Result<()> {
804 let options = self.with_timeout(options.into());
805 self.frame
806 .wait_for_function_internal(expression, Some(&self.selector), options)
807 .await
808 .map(|_| ())
809 .map_err(|e| self.wrap_error_with_selector(e))
810 }
811
812 /// Serializes this locator as a screenshot `mask` entry — `{ frame, selector }`
813 /// with the frame sent as a channel reference — matching the protocol shape
814 /// the driver expects. Used by [`crate::protocol::ScreenshotOptions`].
815 pub(crate) fn mask_json(&self) -> serde_json::Value {
816 use crate::server::channel_owner::ChannelOwner as _;
817 serde_json::json!({
818 "frame": { "guid": self.frame.guid() },
819 "selector": self.selector,
820 })
821 }
822
823 /// Creates a [`FrameLocator`](crate::protocol::FrameLocator) scoped within this locator's subtree.
824 ///
825 /// The `selector` identifies an iframe element within the locator's scope.
826 ///
827 /// See: <https://playwright.dev/docs/api/class-locator#locator-frame-locator>
828 pub fn frame_locator(&self, selector: &str) -> crate::protocol::FrameLocator {
829 crate::protocol::FrameLocator::new(
830 Arc::clone(&self.frame),
831 format!("{} >> {}", self.selector, selector),
832 self.page.clone(),
833 )
834 }
835
836 /// Returns the Page this locator belongs to.
837 ///
838 /// Each locator is bound to the page that created it. Chained locators (via
839 /// `first()`, `last()`, `nth()`, `locator()`, `filter()`, etc.) all return
840 /// the same owning page. This matches the behavior of `locator.page` in
841 /// other Playwright language bindings.
842 ///
843 /// # Example
844 ///
845 /// ```no_run
846 /// # use playwright_rs::Playwright;
847 /// # #[tokio::main]
848 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
849 /// let playwright = Playwright::launch().await?;
850 /// let browser = playwright.chromium().launch().await?;
851 /// let page = browser.new_page().await?;
852 /// page.goto("https://example.com", None).await?;
853 ///
854 /// let locator = page.locator("h1");
855 /// let locator_page = locator.page()?;
856 /// assert_eq!(locator_page.url(), page.url());
857 /// # Ok(())
858 /// # }
859 /// ```
860 ///
861 /// See: <https://playwright.dev/docs/api/class-locator#locator-page>
862 pub fn page(&self) -> Result<crate::protocol::Page> {
863 Ok(self.page.clone())
864 }
865
866 /// Evaluate a JavaScript expression in the frame context.
867 ///
868 /// Used internally for injecting CSS (e.g., disabling animations) before screenshot assertions.
869 #[cfg(feature = "screenshot-diff")]
870 pub(crate) async fn evaluate_js<T: serde::Serialize>(
871 &self,
872 expression: &str,
873 _arg: Option<T>,
874 ) -> Result<()> {
875 self.frame
876 .frame_evaluate_expression(expression)
877 .await
878 .map_err(|e| self.wrap_error_with_selector(e))
879 }
880
881 /// Creates a locator for the first matching element.
882 ///
883 /// See: <https://playwright.dev/docs/api/class-locator#locator-first>
884 pub fn first(&self) -> Locator {
885 Locator::new(
886 Arc::clone(&self.frame),
887 format!("{} >> nth=0", self.selector),
888 self.page.clone(),
889 )
890 }
891
892 /// Creates a locator for the last matching element.
893 ///
894 /// See: <https://playwright.dev/docs/api/class-locator#locator-last>
895 pub fn last(&self) -> Locator {
896 Locator::new(
897 Arc::clone(&self.frame),
898 format!("{} >> nth=-1", self.selector),
899 self.page.clone(),
900 )
901 }
902
903 /// Creates a locator for the nth matching element (0-indexed).
904 ///
905 /// The index is `i32` on purpose: a negative value counts from the end,
906 /// as in Playwright, so `nth(-1)` is [`Locator::last`].
907 ///
908 /// See: <https://playwright.dev/docs/api/class-locator#locator-nth>
909 pub fn nth(&self, index: i32) -> Locator {
910 Locator::new(
911 Arc::clone(&self.frame),
912 format!("{} >> nth={}", self.selector, index),
913 self.page.clone(),
914 )
915 }
916
917 /// Returns a locator that matches elements containing the given text.
918 ///
919 /// By default, matching is case-insensitive and searches for a substring.
920 /// Set `exact` to `true` for case-sensitive exact matching.
921 ///
922 /// See: <https://playwright.dev/docs/api/class-locator#locator-get-by-text>
923 pub fn get_by_text(&self, text: &str, exact: bool) -> Locator {
924 self.locator(get_by_text_selector(text, exact))
925 }
926
927 /// Returns a locator that matches elements by their associated label text.
928 ///
929 /// Targets form controls (`input`, `textarea`, `select`) linked via `<label>`,
930 /// `aria-label`, or `aria-labelledby`.
931 ///
932 /// See: <https://playwright.dev/docs/api/class-locator#locator-get-by-label>
933 pub fn get_by_label(&self, text: &str, exact: bool) -> Locator {
934 self.locator(get_by_label_selector(text, exact))
935 }
936
937 /// Returns a locator that matches elements by their placeholder text.
938 ///
939 /// See: <https://playwright.dev/docs/api/class-locator#locator-get-by-placeholder>
940 pub fn get_by_placeholder(&self, text: &str, exact: bool) -> Locator {
941 self.locator(get_by_placeholder_selector(text, exact))
942 }
943
944 /// Returns a locator that matches elements by their alt text.
945 ///
946 /// Typically used for `<img>` elements.
947 ///
948 /// See: <https://playwright.dev/docs/api/class-locator#locator-get-by-alt-text>
949 pub fn get_by_alt_text(&self, text: &str, exact: bool) -> Locator {
950 self.locator(get_by_alt_text_selector(text, exact))
951 }
952
953 /// Returns a locator that matches elements by their title attribute.
954 ///
955 /// See: <https://playwright.dev/docs/api/class-locator#locator-get-by-title>
956 pub fn get_by_title(&self, text: &str, exact: bool) -> Locator {
957 self.locator(get_by_title_selector(text, exact))
958 }
959
960 /// Returns a locator that matches elements by their test ID attribute.
961 ///
962 /// By default, uses the `data-testid` attribute. Call
963 /// `playwright.selectors().set_test_id_attribute()` to change the attribute name.
964 ///
965 /// Always uses exact matching (case-sensitive).
966 ///
967 /// See: <https://playwright.dev/docs/api/class-locator#locator-get-by-test-id>
968 pub fn get_by_test_id(&self, test_id: &str) -> Locator {
969 use crate::server::channel_owner::ChannelOwner as _;
970 let attr = self.frame.connection().selectors().test_id_attribute();
971 self.locator(get_by_test_id_selector_with_attr(test_id, &attr))
972 }
973
974 /// Returns a locator that matches elements by their ARIA role.
975 ///
976 /// This is the recommended way to locate elements, as it matches the way
977 /// users and assistive technology perceive the page.
978 ///
979 /// See: <https://playwright.dev/docs/api/class-locator#locator-get-by-role>
980 pub fn get_by_role(&self, role: AriaRole, options: Option<GetByRoleOptions>) -> Locator {
981 self.locator(get_by_role_selector(role, options))
982 }
983
984 /// Creates a sub-locator within this locator's subtree.
985 ///
986 /// See: <https://playwright.dev/docs/api/class-locator#locator-locator>
987 pub fn locator(&self, selector: impl Into<String>) -> Locator {
988 Locator::new(
989 Arc::clone(&self.frame),
990 format!("{} >> {}", self.selector, selector.into()),
991 self.page.clone(),
992 )
993 }
994
995 /// Narrows this locator according to the filter options.
996 ///
997 /// Can be chained to apply multiple filters in sequence.
998 ///
999 /// # Example
1000 ///
1001 /// ```no_run
1002 /// use playwright_rs::{Playwright, FilterOptions};
1003 ///
1004 /// # #[tokio::main]
1005 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1006 /// let playwright = Playwright::launch().await?;
1007 /// let browser = playwright.chromium().launch().await?;
1008 /// let page = browser.new_page().await?;
1009 ///
1010 /// // Filter rows to those containing "Apple"
1011 /// let rows = page.locator("tr");
1012 /// let apple_row = rows.filter(FilterOptions::default().has_text("Apple"));
1013 /// # browser.close().await?;
1014 /// # Ok(())
1015 /// # }
1016 /// ```
1017 ///
1018 /// See: <https://playwright.dev/docs/api/class-locator#locator-filter>
1019 pub fn filter(&self, options: FilterOptions) -> Locator {
1020 let selector = filter_selector(
1021 &self.selector,
1022 &options,
1023 options.has.as_ref().map(|l| l.selector.as_str()),
1024 options.has_not.as_ref().map(|l| l.selector.as_str()),
1025 );
1026 Locator::new(Arc::clone(&self.frame), selector, self.page.clone())
1027 }
1028
1029 /// Returns a locator that matches only the visible elements of this one.
1030 ///
1031 /// This is the replacement Playwright recommends for the `:visible` CSS
1032 /// pseudo-class. `filter(FilterOptions::default().visible(false))` is the
1033 /// other half, matching only hidden elements.
1034 ///
1035 /// # Example
1036 ///
1037 /// ```no_run
1038 /// # use playwright_rs::Playwright;
1039 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1040 /// # let playwright = Playwright::launch().await?;
1041 /// # let browser = playwright.chromium().launch().await?;
1042 /// # let page = browser.new_page().await?;
1043 /// page.locator("button").visible().click(None).await?;
1044 /// # browser.close().await?;
1045 /// # Ok(())
1046 /// # }
1047 /// ```
1048 ///
1049 /// See: <https://playwright.dev/docs/api/class-locator#locator-visible>
1050 pub fn visible(&self) -> Locator {
1051 Locator::new(
1052 Arc::clone(&self.frame),
1053 format!("{} >> visible=true", self.selector),
1054 self.page.clone(),
1055 )
1056 }
1057
1058 /// Creates a locator matching elements that satisfy **both** this locator and `locator`.
1059 ///
1060 /// Note: named `and_` because `and` is a Rust keyword.
1061 ///
1062 /// # Example
1063 ///
1064 /// ```no_run
1065 /// use playwright_rs::Playwright;
1066 ///
1067 /// # #[tokio::main]
1068 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1069 /// let playwright = Playwright::launch().await?;
1070 /// let browser = playwright.chromium().launch().await?;
1071 /// let page = browser.new_page().await?;
1072 ///
1073 /// // Find a button that also has a specific title
1074 /// let button = page.locator("button");
1075 /// let titled = page.locator("[title='Subscribe']");
1076 /// let subscribe_btn = button.and_(&titled);
1077 /// # browser.close().await?;
1078 /// # Ok(())
1079 /// # }
1080 /// ```
1081 ///
1082 /// See: <https://playwright.dev/docs/api/class-locator#locator-and>
1083 pub fn and_(&self, locator: &Locator) -> Locator {
1084 let inner = json_quote(&locator.selector);
1085 Locator::new(
1086 Arc::clone(&self.frame),
1087 format!("{} >> internal:and={}", self.selector, inner),
1088 self.page.clone(),
1089 )
1090 }
1091
1092 /// Creates a locator matching the elements this locator finds inside each
1093 /// element `parent` matches, read in the natural order: "this button,
1094 /// within that dialog".
1095 ///
1096 /// Relative parts of this locator, such as [`nth`](Self::nth) or
1097 /// [`last`](Self::last), resolve inside each parent separately, so
1098 /// `cells.nth(2).within(&rows)` is the third cell of every row rather than
1099 /// the third cell of the table.
1100 ///
1101 /// Both locators should come from the same frame. That is not checked
1102 /// (as it is not for [`and_`](Self::and_) and [`or_`](Self::or_)): the
1103 /// result searches the frame `parent` belongs to, so a locator from
1104 /// another frame finds nothing there rather than raising an error.
1105 ///
1106 /// See: <https://playwright.dev/docs/api/class-locator#locator-within>
1107 pub fn within(&self, parent: &Locator) -> Locator {
1108 Locator::new(
1109 Arc::clone(&parent.frame),
1110 within_selector(&self.selector, &parent.selector),
1111 parent.page.clone(),
1112 )
1113 }
1114
1115 /// Creates a locator matching elements that satisfy **either** this locator or `locator`.
1116 ///
1117 /// Note: named `or_` because `or` is a Rust keyword.
1118 ///
1119 /// # Example
1120 ///
1121 /// ```no_run
1122 /// use playwright_rs::Playwright;
1123 ///
1124 /// # #[tokio::main]
1125 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1126 /// let playwright = Playwright::launch().await?;
1127 /// let browser = playwright.chromium().launch().await?;
1128 /// let page = browser.new_page().await?;
1129 ///
1130 /// // Find any element that is either a button or a link
1131 /// let buttons = page.locator("button");
1132 /// let links = page.locator("a");
1133 /// let interactive = buttons.or_(&links);
1134 /// # browser.close().await?;
1135 /// # Ok(())
1136 /// # }
1137 /// ```
1138 ///
1139 /// See: <https://playwright.dev/docs/api/class-locator#locator-or>
1140 pub fn or_(&self, locator: &Locator) -> Locator {
1141 let inner = json_quote(&locator.selector);
1142 Locator::new(
1143 Arc::clone(&self.frame),
1144 format!("{} >> internal:or={}", self.selector, inner),
1145 self.page.clone(),
1146 )
1147 }
1148
1149 /// Returns the number of elements matching this locator.
1150 ///
1151 /// See: <https://playwright.dev/docs/api/class-locator#locator-count>
1152 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector, count = tracing::field::Empty))]
1153 pub async fn count(&self) -> Result<usize> {
1154 let n = self
1155 .frame
1156 .locator_count(&self.selector)
1157 .await
1158 .map_err(|e| self.wrap_error_with_selector(e))?;
1159 tracing::Span::current().record("count", n);
1160 Ok(n)
1161 }
1162
1163 /// Returns an array of locators, one for each matching element.
1164 ///
1165 /// Note: `all()` does not wait for elements to match the locator,
1166 /// and instead immediately returns whatever is in the DOM.
1167 ///
1168 /// See: <https://playwright.dev/docs/api/class-locator#locator-all>
1169 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1170 pub async fn all(&self) -> Result<Vec<Locator>> {
1171 let count = self.count().await?;
1172 Ok((0..count).map(|i| self.nth(i as i32)).collect())
1173 }
1174
1175 /// Returns the text content of the element.
1176 ///
1177 /// See: <https://playwright.dev/docs/api/class-locator#locator-text-content>
1178 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1179 pub async fn text_content(&self) -> Result<Option<String>> {
1180 self.frame
1181 .locator_text_content(&self.selector)
1182 .await
1183 .map_err(|e| self.wrap_error_with_selector(e))
1184 }
1185
1186 /// Returns the inner text of the element (visible text).
1187 ///
1188 /// See: <https://playwright.dev/docs/api/class-locator#locator-inner-text>
1189 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1190 pub async fn inner_text(&self) -> Result<String> {
1191 self.frame
1192 .locator_inner_text(&self.selector)
1193 .await
1194 .map_err(|e| self.wrap_error_with_selector(e))
1195 }
1196
1197 /// Returns the inner HTML of the element.
1198 ///
1199 /// See: <https://playwright.dev/docs/api/class-locator#locator-inner-html>
1200 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1201 pub async fn inner_html(&self) -> Result<String> {
1202 self.frame
1203 .locator_inner_html(&self.selector)
1204 .await
1205 .map_err(|e| self.wrap_error_with_selector(e))
1206 }
1207
1208 /// Returns the value of the specified attribute.
1209 ///
1210 /// See: <https://playwright.dev/docs/api/class-locator#locator-get-attribute>
1211 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector, name = %name))]
1212 pub async fn get_attribute(&self, name: &str) -> Result<Option<String>> {
1213 self.frame
1214 .locator_get_attribute(&self.selector, name)
1215 .await
1216 .map_err(|e| self.wrap_error_with_selector(e))
1217 }
1218
1219 /// Returns whether the element is visible.
1220 ///
1221 /// See: <https://playwright.dev/docs/api/class-locator#locator-is-visible>
1222 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1223 pub async fn is_visible(&self) -> Result<bool> {
1224 self.frame
1225 .locator_is_visible(&self.selector)
1226 .await
1227 .map_err(|e| self.wrap_error_with_selector(e))
1228 }
1229
1230 /// Returns whether the element is enabled.
1231 ///
1232 /// See: <https://playwright.dev/docs/api/class-locator#locator-is-enabled>
1233 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1234 pub async fn is_enabled(&self) -> Result<bool> {
1235 self.frame
1236 .locator_is_enabled(&self.selector)
1237 .await
1238 .map_err(|e| self.wrap_error_with_selector(e))
1239 }
1240
1241 /// Returns whether the checkbox or radio button is checked.
1242 ///
1243 /// See: <https://playwright.dev/docs/api/class-locator#locator-is-checked>
1244 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1245 pub async fn is_checked(&self) -> Result<bool> {
1246 self.frame
1247 .locator_is_checked(&self.selector)
1248 .await
1249 .map_err(|e| self.wrap_error_with_selector(e))
1250 }
1251
1252 /// Returns whether the element is editable.
1253 ///
1254 /// See: <https://playwright.dev/docs/api/class-locator#locator-is-editable>
1255 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1256 pub async fn is_editable(&self) -> Result<bool> {
1257 self.frame
1258 .locator_is_editable(&self.selector)
1259 .await
1260 .map_err(|e| self.wrap_error_with_selector(e))
1261 }
1262
1263 /// Returns whether the element is hidden.
1264 ///
1265 /// See: <https://playwright.dev/docs/api/class-locator#locator-is-hidden>
1266 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1267 pub async fn is_hidden(&self) -> Result<bool> {
1268 self.frame
1269 .locator_is_hidden(&self.selector)
1270 .await
1271 .map_err(|e| self.wrap_error_with_selector(e))
1272 }
1273
1274 /// Returns whether the element is disabled.
1275 ///
1276 /// See: <https://playwright.dev/docs/api/class-locator#locator-is-disabled>
1277 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1278 pub async fn is_disabled(&self) -> Result<bool> {
1279 self.frame
1280 .locator_is_disabled(&self.selector)
1281 .await
1282 .map_err(|e| self.wrap_error_with_selector(e))
1283 }
1284
1285 /// Returns whether the element is focused (currently has focus).
1286 ///
1287 /// See: <https://playwright.dev/docs/api/class-locator#locator-is-focused>
1288 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1289 pub async fn is_focused(&self) -> Result<bool> {
1290 self.frame
1291 .locator_is_focused(&self.selector)
1292 .await
1293 .map_err(|e| self.wrap_error_with_selector(e))
1294 }
1295
1296 // Action methods
1297
1298 /// Clicks the element.
1299 ///
1300 /// See: <https://playwright.dev/docs/api/class-locator#locator-click>
1301 #[tracing::instrument(level = "info", skip_all, fields(selector = %self.selector))]
1302 pub async fn click(
1303 &self,
1304 options: impl Into<Option<crate::protocol::ClickOptions>>,
1305 ) -> Result<()> {
1306 let options = options.into();
1307 self.frame
1308 .locator_click(&self.selector, Some(self.with_timeout(options)))
1309 .await
1310 .map_err(|e| self.wrap_error_with_selector(e))
1311 }
1312
1313 /// Ensures an options struct has the page's default timeout when none is explicitly set.
1314 fn with_timeout<T: HasTimeout + Default>(&self, options: Option<T>) -> T {
1315 let mut opts = options.unwrap_or_default();
1316 if opts.timeout_ref().is_none() {
1317 *opts.timeout_ref_mut() = Some(self.page.default_timeout_ms());
1318 }
1319 opts
1320 }
1321
1322 /// Wraps an error with selector context for better error messages.
1323 fn wrap_error_with_selector(&self, error: crate::error::Error) -> crate::error::Error {
1324 match &error {
1325 crate::error::Error::ProtocolError(msg) => {
1326 // Add selector context to protocol errors (timeouts, etc.)
1327 crate::error::Error::ProtocolError(format!("{} [selector: {}]", msg, self.selector))
1328 }
1329 crate::error::Error::Timeout(msg) => {
1330 crate::error::Error::Timeout(format!("{} [selector: {}]", msg, self.selector))
1331 }
1332 _ => error, // Other errors pass through unchanged
1333 }
1334 }
1335
1336 /// Double clicks the element.
1337 ///
1338 /// See: <https://playwright.dev/docs/api/class-locator#locator-dblclick>
1339 #[tracing::instrument(level = "info", skip_all, fields(selector = %self.selector))]
1340 pub async fn dblclick(
1341 &self,
1342 options: impl Into<Option<crate::protocol::ClickOptions>>,
1343 ) -> Result<()> {
1344 let options = options.into();
1345 self.frame
1346 .locator_dblclick(&self.selector, Some(self.with_timeout(options)))
1347 .await
1348 .map_err(|e| self.wrap_error_with_selector(e))
1349 }
1350
1351 /// Fills the element with text.
1352 ///
1353 /// See: <https://playwright.dev/docs/api/class-locator#locator-fill>
1354 #[tracing::instrument(level = "info", skip_all, fields(selector = %self.selector))]
1355 pub async fn fill(
1356 &self,
1357 text: &str,
1358 options: impl Into<Option<crate::protocol::FillOptions>>,
1359 ) -> Result<()> {
1360 let options = options.into();
1361 self.frame
1362 .locator_fill(&self.selector, text, Some(self.with_timeout(options)))
1363 .await
1364 .map_err(|e| self.wrap_error_with_selector(e))
1365 }
1366
1367 /// Clears the element's value.
1368 ///
1369 /// See: <https://playwright.dev/docs/api/class-locator#locator-clear>
1370 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1371 pub async fn clear(
1372 &self,
1373 options: impl Into<Option<crate::protocol::FillOptions>>,
1374 ) -> Result<()> {
1375 let options = options.into();
1376 self.frame
1377 .locator_clear(&self.selector, Some(self.with_timeout(options)))
1378 .await
1379 .map_err(|e| self.wrap_error_with_selector(e))
1380 }
1381
1382 /// Presses a key on the element.
1383 ///
1384 /// See: <https://playwright.dev/docs/api/class-locator#locator-press>
1385 #[tracing::instrument(level = "info", skip_all, fields(selector = %self.selector))]
1386 pub async fn press(
1387 &self,
1388 key: &str,
1389 options: impl Into<Option<crate::protocol::PressOptions>>,
1390 ) -> Result<()> {
1391 let options = options.into();
1392 self.frame
1393 .locator_press(&self.selector, key, Some(self.with_timeout(options)))
1394 .await
1395 .map_err(|e| self.wrap_error_with_selector(e))
1396 }
1397
1398 /// Sets focus on the element.
1399 ///
1400 /// Calls the element's `focus()` method. Used to move keyboard focus to a
1401 /// specific element for subsequent keyboard interactions.
1402 ///
1403 /// See: <https://playwright.dev/docs/api/class-locator#locator-focus>
1404 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1405 pub async fn focus(&self) -> Result<()> {
1406 self.frame
1407 .locator_focus(&self.selector)
1408 .await
1409 .map_err(|e| self.wrap_error_with_selector(e))
1410 }
1411
1412 /// Removes focus from the element.
1413 ///
1414 /// Calls the element's `blur()` method. Moves keyboard focus away from the element.
1415 ///
1416 /// See: <https://playwright.dev/docs/api/class-locator#locator-blur>
1417 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1418 pub async fn blur(&self) -> Result<()> {
1419 self.frame
1420 .locator_blur(&self.selector)
1421 .await
1422 .map_err(|e| self.wrap_error_with_selector(e))
1423 }
1424
1425 /// Types `text` into the element character by character, as though it was typed
1426 /// on a real keyboard.
1427 ///
1428 /// Use this method when you need to simulate keystrokes with individual key events
1429 /// (e.g., for autocomplete widgets). For simply setting a field value, prefer
1430 /// [`Locator::fill()`].
1431 ///
1432 /// # Arguments
1433 ///
1434 /// * `text` - Text to type into the element
1435 /// * `options` - Optional [`PressSequentiallyOptions`](crate::protocol::PressSequentiallyOptions) (e.g., `delay` between key presses)
1436 ///
1437 /// See: <https://playwright.dev/docs/api/class-locator#locator-press-sequentially>
1438 #[tracing::instrument(level = "info", skip_all, fields(selector = %self.selector))]
1439 pub async fn press_sequentially(
1440 &self,
1441 text: &str,
1442 options: impl Into<Option<crate::protocol::PressSequentiallyOptions>>,
1443 ) -> Result<()> {
1444 let options = options.into();
1445 self.frame
1446 .locator_press_sequentially(&self.selector, text, options)
1447 .await
1448 .map_err(|e| self.wrap_error_with_selector(e))
1449 }
1450
1451 /// Returns the `innerText` values of all elements matching this locator.
1452 ///
1453 /// Unlike [`Locator::inner_text()`] (which uses strict mode and requires exactly one match),
1454 /// `all_inner_texts()` returns text from all matching elements.
1455 ///
1456 /// See: <https://playwright.dev/docs/api/class-locator#locator-all-inner-texts>
1457 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1458 pub async fn all_inner_texts(&self) -> Result<Vec<String>> {
1459 self.frame
1460 .locator_all_inner_texts(&self.selector)
1461 .await
1462 .map_err(|e| self.wrap_error_with_selector(e))
1463 }
1464
1465 /// Returns the `textContent` values of all elements matching this locator.
1466 ///
1467 /// Unlike [`Locator::text_content()`] (which uses strict mode and requires exactly one match),
1468 /// `all_text_contents()` returns text from all matching elements.
1469 ///
1470 /// See: <https://playwright.dev/docs/api/class-locator#locator-all-text-contents>
1471 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1472 pub async fn all_text_contents(&self) -> Result<Vec<String>> {
1473 self.frame
1474 .locator_all_text_contents(&self.selector)
1475 .await
1476 .map_err(|e| self.wrap_error_with_selector(e))
1477 }
1478
1479 /// Ensures the checkbox or radio button is checked.
1480 ///
1481 /// This method is idempotent - if already checked, does nothing.
1482 ///
1483 /// See: <https://playwright.dev/docs/api/class-locator#locator-check>
1484 #[tracing::instrument(level = "info", skip_all, fields(selector = %self.selector))]
1485 pub async fn check(
1486 &self,
1487 options: impl Into<Option<crate::protocol::CheckOptions>>,
1488 ) -> Result<()> {
1489 let options = options.into();
1490 self.frame
1491 .locator_check(&self.selector, Some(self.with_timeout(options)))
1492 .await
1493 .map_err(|e| self.wrap_error_with_selector(e))
1494 }
1495
1496 /// Ensures the checkbox is unchecked.
1497 ///
1498 /// This method is idempotent - if already unchecked, does nothing.
1499 ///
1500 /// See: <https://playwright.dev/docs/api/class-locator#locator-uncheck>
1501 #[tracing::instrument(level = "info", skip_all, fields(selector = %self.selector))]
1502 pub async fn uncheck(
1503 &self,
1504 options: impl Into<Option<crate::protocol::CheckOptions>>,
1505 ) -> Result<()> {
1506 let options = options.into();
1507 self.frame
1508 .locator_uncheck(&self.selector, Some(self.with_timeout(options)))
1509 .await
1510 .map_err(|e| self.wrap_error_with_selector(e))
1511 }
1512
1513 /// Sets the checkbox or radio button to the specified checked state.
1514 ///
1515 /// This is a convenience method that calls `check()` if `checked` is true,
1516 /// or `uncheck()` if `checked` is false.
1517 ///
1518 /// See: <https://playwright.dev/docs/api/class-locator#locator-set-checked>
1519 #[tracing::instrument(level = "info", skip_all, fields(selector = %self.selector))]
1520 pub async fn set_checked(
1521 &self,
1522 checked: bool,
1523 options: impl Into<Option<crate::protocol::CheckOptions>>,
1524 ) -> Result<()> {
1525 let options = options.into();
1526 if checked {
1527 self.check(options).await
1528 } else {
1529 self.uncheck(options).await
1530 }
1531 }
1532
1533 /// Hovers the mouse over the element.
1534 ///
1535 /// See: <https://playwright.dev/docs/api/class-locator#locator-hover>
1536 #[tracing::instrument(level = "info", skip_all, fields(selector = %self.selector))]
1537 pub async fn hover(
1538 &self,
1539 options: impl Into<Option<crate::protocol::HoverOptions>>,
1540 ) -> Result<()> {
1541 let options = options.into();
1542 self.frame
1543 .locator_hover(&self.selector, Some(self.with_timeout(options)))
1544 .await
1545 .map_err(|e| self.wrap_error_with_selector(e))
1546 }
1547
1548 /// Returns the value of the input, textarea, or select element.
1549 ///
1550 /// See: <https://playwright.dev/docs/api/class-locator#locator-input-value>
1551 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1552 pub async fn input_value(&self, _options: impl Into<Option<()>>) -> Result<String> {
1553 self.frame
1554 .locator_input_value(&self.selector)
1555 .await
1556 .map_err(|e| self.wrap_error_with_selector(e))
1557 }
1558
1559 /// Selects one or more options in a select element.
1560 ///
1561 /// Returns an array of option values that have been successfully selected.
1562 ///
1563 /// See: <https://playwright.dev/docs/api/class-locator#locator-select-option>
1564 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1565 pub async fn select_option(
1566 &self,
1567 value: impl Into<crate::protocol::SelectOption>,
1568 options: impl Into<Option<crate::protocol::SelectOptions>>,
1569 ) -> Result<Vec<String>> {
1570 let options = options.into();
1571 self.frame
1572 .locator_select_option(
1573 &self.selector,
1574 value.into(),
1575 Some(self.with_timeout(options)),
1576 )
1577 .await
1578 .map_err(|e| self.wrap_error_with_selector(e))
1579 }
1580
1581 /// Selects multiple options in a select element.
1582 ///
1583 /// Returns an array of option values that have been successfully selected.
1584 ///
1585 /// See: <https://playwright.dev/docs/api/class-locator#locator-select-option>
1586 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1587 pub async fn select_option_multiple(
1588 &self,
1589 values: &[impl Into<crate::protocol::SelectOption> + Clone],
1590 options: impl Into<Option<crate::protocol::SelectOptions>>,
1591 ) -> Result<Vec<String>> {
1592 let options = options.into();
1593 let select_options: Vec<crate::protocol::SelectOption> =
1594 values.iter().map(|v| v.clone().into()).collect();
1595 self.frame
1596 .locator_select_option_multiple(
1597 &self.selector,
1598 select_options,
1599 Some(self.with_timeout(options)),
1600 )
1601 .await
1602 .map_err(|e| self.wrap_error_with_selector(e))
1603 }
1604
1605 /// Sets the file path(s) to upload to a file input element.
1606 ///
1607 /// See: <https://playwright.dev/docs/api/class-locator#locator-set-input-files>
1608 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1609 pub async fn set_input_files(
1610 &self,
1611 file: &std::path::PathBuf,
1612 _options: impl Into<Option<()>>,
1613 ) -> Result<()> {
1614 self.frame
1615 .locator_set_input_files(&self.selector, file)
1616 .await
1617 .map_err(|e| self.wrap_error_with_selector(e))
1618 }
1619
1620 /// Sets multiple file paths to upload to a file input element.
1621 ///
1622 /// See: <https://playwright.dev/docs/api/class-locator#locator-set-input-files>
1623 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1624 pub async fn set_input_files_multiple(
1625 &self,
1626 files: &[&std::path::PathBuf],
1627 _options: impl Into<Option<()>>,
1628 ) -> Result<()> {
1629 self.frame
1630 .locator_set_input_files_multiple(&self.selector, files)
1631 .await
1632 .map_err(|e| self.wrap_error_with_selector(e))
1633 }
1634
1635 /// Sets a file to upload using FilePayload (explicit name, mimeType, buffer).
1636 ///
1637 /// See: <https://playwright.dev/docs/api/class-locator#locator-set-input-files>
1638 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1639 pub async fn set_input_files_payload(
1640 &self,
1641 file: crate::protocol::FilePayload,
1642 _options: impl Into<Option<()>>,
1643 ) -> Result<()> {
1644 self.frame
1645 .locator_set_input_files_payload(&self.selector, file)
1646 .await
1647 .map_err(|e| self.wrap_error_with_selector(e))
1648 }
1649
1650 /// Sets multiple files to upload using FilePayload.
1651 ///
1652 /// See: <https://playwright.dev/docs/api/class-locator#locator-set-input-files>
1653 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1654 pub async fn set_input_files_payload_multiple(
1655 &self,
1656 files: &[crate::protocol::FilePayload],
1657 _options: impl Into<Option<()>>,
1658 ) -> Result<()> {
1659 self.frame
1660 .locator_set_input_files_payload_multiple(&self.selector, files)
1661 .await
1662 .map_err(|e| self.wrap_error_with_selector(e))
1663 }
1664
1665 /// Dispatches a DOM event on the element.
1666 ///
1667 /// Unlike clicking or typing, `dispatch_event` directly sends the event without
1668 /// performing any actionability checks. It still waits for the element to be present
1669 /// in the DOM.
1670 ///
1671 /// # Arguments
1672 ///
1673 /// * `type_` - The event type to dispatch, e.g. `"click"`, `"focus"`, `"myevent"`.
1674 /// * `event_init` - Optional event initializer properties (e.g. `{"detail": "value"}` for
1675 /// `CustomEvent`). Corresponds to the second argument of `new Event(type, init)`.
1676 ///
1677 /// # Errors
1678 ///
1679 /// Returns an error if:
1680 /// - The element is not found within the timeout
1681 /// - The protocol call fails
1682 ///
1683 /// See: <https://playwright.dev/docs/api/class-locator#locator-dispatch-event>
1684 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1685 pub async fn dispatch_event(
1686 &self,
1687 type_: &str,
1688 event_init: Option<serde_json::Value>,
1689 ) -> Result<()> {
1690 self.frame
1691 .locator_dispatch_event(&self.selector, type_, event_init)
1692 .await
1693 .map_err(|e| self.wrap_error_with_selector(e))
1694 }
1695
1696 /// Returns the bounding box of the element, or `None` if the element is not visible.
1697 ///
1698 /// The bounding box is in pixels, relative to the top-left corner of the page.
1699 /// Returns `None` when the element has `display: none` or is otherwise not part of
1700 /// the layout.
1701 ///
1702 /// # Errors
1703 ///
1704 /// Returns an error if:
1705 /// - The element is not found within the timeout
1706 /// - The protocol call fails
1707 ///
1708 /// See: <https://playwright.dev/docs/api/class-locator#locator-bounding-box>
1709 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1710 pub async fn bounding_box(&self) -> Result<Option<BoundingBox>> {
1711 self.frame
1712 .locator_bounding_box(&self.selector)
1713 .await
1714 .map_err(|e| self.wrap_error_with_selector(e))
1715 }
1716
1717 /// Scrolls the element into view if it is not already visible in the viewport.
1718 ///
1719 /// This is an alias for calling `element.scrollIntoView()` in the browser.
1720 ///
1721 /// # Errors
1722 ///
1723 /// Returns an error if:
1724 /// - The element is not found within the timeout
1725 /// - The protocol call fails
1726 ///
1727 /// See: <https://playwright.dev/docs/api/class-locator#locator-scroll-into-view-if-needed>
1728 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1729 pub async fn scroll_into_view_if_needed(&self) -> Result<()> {
1730 self.frame
1731 .locator_scroll_into_view_if_needed(&self.selector)
1732 .await
1733 .map_err(|e| self.wrap_error_with_selector(e))
1734 }
1735
1736 /// Takes a screenshot of the element and returns the image bytes.
1737 ///
1738 /// This method uses strict mode - it will fail if the selector matches multiple elements.
1739 /// Use `first()`, `last()`, or `nth()` to refine the selector to a single element.
1740 ///
1741 /// See: <https://playwright.dev/docs/api/class-locator#locator-screenshot>
1742 #[tracing::instrument(level = "info", skip_all, fields(selector = %self.selector, bytes_len = tracing::field::Empty))]
1743 pub async fn screenshot(
1744 &self,
1745 options: impl Into<Option<crate::protocol::ScreenshotOptions>>,
1746 ) -> Result<Vec<u8>> {
1747 let mut options = self.with_timeout(options.into());
1748 let timeout = options.timeout.unwrap_or(crate::DEFAULT_TIMEOUT_MS);
1749 let started = std::time::Instant::now();
1750 // Wait for the element as every locator action does, then take the
1751 // screenshot within what is left of the same deadline.
1752 let element = self
1753 .frame
1754 .locator_wait_for_element(&self.selector, timeout)
1755 .await
1756 .map_err(|e| self.wrap_error_with_selector(e))?;
1757 options.timeout = Some(remaining_ms(timeout, started.elapsed()));
1758 let captured = element.screenshot(Some(options)).await;
1759 // The wait created the handle; a failed dispose only leaves it to live
1760 // as long as the page.
1761 let _ = crate::server::channel_owner::ChannelOwner::channel(element.as_ref())
1762 .send_no_result("dispose", serde_json::json!({}))
1763 .await;
1764 let bytes = captured.map_err(|e| self.wrap_error_with_selector(e))?;
1765 tracing::Span::current().record("bytes_len", bytes.len());
1766 Ok(bytes)
1767 }
1768
1769 /// Performs a touch-tap on the element.
1770 ///
1771 /// This method dispatches a `touchstart` and `touchend` event on the element.
1772 /// For touch support to work, the browser context must be created with
1773 /// `has_touch: true`.
1774 ///
1775 /// # Arguments
1776 ///
1777 /// * `options` - Optional [`TapOptions`](crate::protocol::TapOptions) (force, modifiers, position, timeout, trial)
1778 ///
1779 /// # Errors
1780 ///
1781 /// Returns an error if:
1782 /// - The element is not found within the timeout
1783 /// - Actionability checks fail (unless `force: true`)
1784 /// - The browser context was not created with `has_touch: true`
1785 ///
1786 /// See: <https://playwright.dev/docs/api/class-locator#locator-tap>
1787 #[tracing::instrument(level = "info", skip_all, fields(selector = %self.selector))]
1788 pub async fn tap(&self, options: impl Into<Option<crate::protocol::TapOptions>>) -> Result<()> {
1789 let options = options.into();
1790 self.frame
1791 .locator_tap(&self.selector, Some(self.with_timeout(options)))
1792 .await
1793 .map_err(|e| self.wrap_error_with_selector(e))
1794 }
1795
1796 /// Drags this element to the `target` element.
1797 ///
1798 /// Both this locator and `target` must resolve to elements in the same frame.
1799 /// Playwright performs a series of mouse events (move, press, move to target, release)
1800 /// to simulate the drag, so it drives real pointer-event chains, including
1801 /// UIs that call `setPointerCapture` in their `pointerdown` handler.
1802 ///
1803 /// `source_position` and `target_position` are offsets from the respective
1804 /// element's top-left corner. Setting `target_position` with a containing
1805 /// element (a canvas or stage) as `target` turns this into a drag to a
1806 /// coordinate rather than onto an element, which also covers "drag by a
1807 /// delta": compute the drop point from the source's position within the
1808 /// container. Prefer this over a held-button
1809 /// [`Mouse::move_to`](crate::protocol::Mouse::move_to) sequence, which can
1810 /// hang on headless Linux (see the note on that method).
1811 ///
1812 /// # Arguments
1813 ///
1814 /// * `target` - The locator of the element to drag onto
1815 /// * `options` - Optional [`DragToOptions`](crate::protocol::DragToOptions) (force, no_wait_after, timeout, trial,
1816 /// source_position, target_position)
1817 ///
1818 /// # Example
1819 ///
1820 /// ```no_run
1821 /// # use playwright_rs::{Playwright, DragToOptions, Position};
1822 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1823 /// # let pw = Playwright::launch().await?;
1824 /// # let page = pw.chromium().launch().await?.new_page().await?;
1825 /// let handle = page.locator(".crop-handle");
1826 /// let stage = page.locator("#stage");
1827 ///
1828 /// // Drop the handle 180px right, 120px down from the stage's top-left corner.
1829 /// let opts = DragToOptions::builder()
1830 /// .target_position(Position { x: 180.0, y: 120.0 })
1831 /// .build();
1832 /// handle.drag_to(&stage, Some(opts)).await?;
1833 /// # Ok(())
1834 /// # }
1835 /// ```
1836 ///
1837 /// # Errors
1838 ///
1839 /// Returns an error if:
1840 /// - Either element is not found within the timeout
1841 /// - Actionability checks fail (unless `force: true`)
1842 /// - The protocol call fails
1843 ///
1844 /// See: <https://playwright.dev/docs/api/class-locator#locator-drag-to>
1845 #[tracing::instrument(level = "info", skip_all, fields(selector = %self.selector))]
1846 pub async fn drag_to(
1847 &self,
1848 target: &Locator,
1849 options: impl Into<Option<crate::protocol::DragToOptions>>,
1850 ) -> Result<()> {
1851 let options = options.into();
1852 self.frame
1853 .locator_drag_to(
1854 &self.selector,
1855 &target.selector,
1856 Some(self.with_timeout(options)),
1857 )
1858 .await
1859 .map_err(|e| self.wrap_error_with_selector(e))
1860 }
1861
1862 /// Drops files and/or data onto this element (external drag-and-drop).
1863 ///
1864 /// Simulates dragging files or data from outside the page onto the element,
1865 /// such as an upload drop zone, by dispatching `dragenter`/`dragover`/`drop`
1866 /// with a synthetic `DataTransfer`. Set `files` and/or `data` on the
1867 /// [`DropOptions`](crate::protocol::DropOptions). This is distinct from
1868 /// [`drag_to`](Self::drag_to), which drags one element onto another within
1869 /// the page.
1870 ///
1871 /// # Arguments
1872 ///
1873 /// * `options` - [`DropOptions`](crate::protocol::DropOptions) carrying the
1874 /// files / data to drop, plus optional `position` and `timeout`.
1875 ///
1876 /// # Errors
1877 ///
1878 /// Returns an error if:
1879 /// - The element is not found within the timeout
1880 /// - Actionability checks fail
1881 /// - The protocol call fails (e.g. neither files nor data were provided)
1882 ///
1883 /// See: <https://playwright.dev/docs/api/class-locator#locator-drop>
1884 #[tracing::instrument(level = "info", skip_all, fields(selector = %self.selector))]
1885 pub async fn drop(&self, options: crate::protocol::DropOptions) -> Result<()> {
1886 self.frame
1887 .locator_drop(&self.selector, self.with_timeout(Some(options)))
1888 .await
1889 .map_err(|e| self.wrap_error_with_selector(e))
1890 }
1891
1892 /// Waits until the element satisfies the given state condition.
1893 ///
1894 /// If no state is specified, waits for the element to be `visible` (the default).
1895 ///
1896 /// This method is useful for waiting for lazy-rendered elements or elements that
1897 /// appear/disappear based on user interaction or async data loading.
1898 ///
1899 /// # Arguments
1900 ///
1901 /// * `options` - Optional [`WaitForOptions`](crate::protocol::WaitForOptions) specifying the `state` to wait for
1902 /// (`Visible`, `Hidden`, `Attached`, or `Detached`) and a `timeout` in milliseconds.
1903 ///
1904 /// # Errors
1905 ///
1906 /// Returns an error if the element does not satisfy the expected state within the timeout.
1907 ///
1908 /// See: <https://playwright.dev/docs/api/class-locator#locator-wait-for>
1909 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
1910 pub async fn wait_for(
1911 &self,
1912 options: impl Into<Option<crate::protocol::WaitForOptions>>,
1913 ) -> Result<()> {
1914 let options = options.into();
1915 self.frame
1916 .locator_wait_for(&self.selector, Some(self.with_timeout(options)))
1917 .await
1918 .map_err(|e| self.wrap_error_with_selector(e))
1919 }
1920
1921 /// Evaluates a JavaScript expression in the scope of the matched element.
1922 ///
1923 /// The element is passed as the first argument to the expression. The expression
1924 /// can be any JavaScript function or expression that returns a JSON-serializable value.
1925 ///
1926 /// # Arguments
1927 ///
1928 /// * `expression` - JavaScript expression or function, e.g. `"(el) => el.textContent"`
1929 /// * `arg` - Optional argument passed as the second argument to the function
1930 ///
1931 /// # Errors
1932 ///
1933 /// Returns an error if:
1934 /// - The element is not found within the timeout
1935 /// - The JavaScript expression throws an error
1936 /// - The return value is not JSON-serializable
1937 ///
1938 /// # Example
1939 ///
1940 /// ```no_run
1941 /// use playwright_rs::Playwright;
1942 ///
1943 /// # #[tokio::main]
1944 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1945 /// let playwright = Playwright::launch().await?;
1946 /// let browser = playwright.chromium().launch().await?;
1947 /// let page = browser.new_page().await?;
1948 /// let _ = page.goto("data:text/html,<h1>Hello</h1>", None).await;
1949 ///
1950 /// let heading = page.locator("h1");
1951 /// let text: String = heading.evaluate("(el) => el.textContent", None::<()>).await?;
1952 /// assert_eq!(text, "Hello");
1953 ///
1954 /// // With an argument
1955 /// let result: String = heading
1956 /// .evaluate("(el, suffix) => el.textContent + suffix", Some("!"))
1957 /// .await?;
1958 /// assert_eq!(result, "Hello!");
1959 /// # browser.close().await?;
1960 /// # Ok(())
1961 /// # }
1962 /// ```
1963 ///
1964 /// See: <https://playwright.dev/docs/api/class-locator#locator-evaluate>
1965 #[tracing::instrument(level = "info", skip_all, fields(selector = %self.selector))]
1966 pub async fn evaluate<R, T>(&self, expression: &str, arg: Option<T>) -> Result<R>
1967 where
1968 R: serde::de::DeserializeOwned,
1969 T: serde::Serialize,
1970 {
1971 let raw = self
1972 .frame
1973 .locator_evaluate(&self.selector, expression, arg)
1974 .await
1975 .map_err(|e| self.wrap_error_with_selector(e))?;
1976 serde_json::from_value(raw).map_err(|e| {
1977 crate::error::Error::ProtocolError(format!(
1978 "evaluate result deserialization failed: {}",
1979 e
1980 ))
1981 })
1982 }
1983
1984 /// Evaluates a JavaScript expression in the scope of all elements matching this locator.
1985 ///
1986 /// The array of all matched elements is passed as the first argument to the expression.
1987 /// Unlike [`evaluate()`](Self::evaluate), this does not use strict mode — all matching
1988 /// elements are collected and passed as an array.
1989 ///
1990 /// # Arguments
1991 ///
1992 /// * `expression` - JavaScript function that receives an array of elements
1993 /// * `arg` - Optional argument passed as the second argument to the function
1994 ///
1995 /// # Errors
1996 ///
1997 /// Returns an error if:
1998 /// - The JavaScript expression throws an error
1999 /// - The return value is not JSON-serializable
2000 ///
2001 /// # Example
2002 ///
2003 /// ```no_run
2004 /// use playwright_rs::Playwright;
2005 ///
2006 /// # #[tokio::main]
2007 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2008 /// let playwright = Playwright::launch().await?;
2009 /// let browser = playwright.chromium().launch().await?;
2010 /// let page = browser.new_page().await?;
2011 /// let _ = page.goto(
2012 /// "data:text/html,<li class='item'>A</li><li class='item'>B</li>",
2013 /// None
2014 /// ).await;
2015 ///
2016 /// let items = page.locator(".item");
2017 /// let texts: Vec<String> = items
2018 /// .evaluate_all("(elements) => elements.map(e => e.textContent)", None::<()>)
2019 /// .await?;
2020 /// assert_eq!(texts, vec!["A", "B"]);
2021 /// # browser.close().await?;
2022 /// # Ok(())
2023 /// # }
2024 /// ```
2025 ///
2026 /// See: <https://playwright.dev/docs/api/class-locator#locator-evaluate-all>
2027 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
2028 pub async fn evaluate_all<R, T>(&self, expression: &str, arg: Option<T>) -> Result<R>
2029 where
2030 R: serde::de::DeserializeOwned,
2031 T: serde::Serialize,
2032 {
2033 let raw = self
2034 .frame
2035 .locator_evaluate_all(&self.selector, expression, arg)
2036 .await
2037 .map_err(|e| self.wrap_error_with_selector(e))?;
2038 serde_json::from_value(raw).map_err(|e| {
2039 crate::error::Error::ProtocolError(format!(
2040 "evaluate_all result deserialization failed: {}",
2041 e
2042 ))
2043 })
2044 }
2045
2046 /// Returns the ARIA accessibility tree snapshot as a YAML string.
2047 ///
2048 /// The snapshot describes the accessible roles, names, and properties of the matched
2049 /// element and its descendants. This is useful for writing stable accessibility assertions
2050 /// that are independent of CSS classes or DOM structure.
2051 ///
2052 /// # Errors
2053 ///
2054 /// Returns an error if:
2055 /// - The element is not found within the timeout
2056 /// - The protocol call fails
2057 ///
2058 /// See: <https://playwright.dev/docs/api/class-locator#locator-aria-snapshot>
2059 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
2060 pub async fn aria_snapshot(
2061 &self,
2062 options: impl Into<Option<crate::protocol::AriaSnapshotOptions>>,
2063 ) -> Result<String> {
2064 let options = options.into();
2065 self.frame
2066 .locator_aria_snapshot(&self.selector, options.as_ref())
2067 .await
2068 .map_err(|e| self.wrap_error_with_selector(e))
2069 }
2070
2071 /// The accessibility tree of the matched element, as JSON rather than
2072 /// the YAML markup [`aria_snapshot`](Self::aria_snapshot) returns, so a
2073 /// caller can walk it instead of parsing text.
2074 ///
2075 /// Takes the same options; `mode`, `depth`, and `boxes` all apply.
2076 ///
2077 /// # Errors
2078 ///
2079 /// Returns an error if the element is not found within the timeout, or
2080 /// if the driver rejects the request.
2081 ///
2082 /// See: <https://playwright.dev/docs/api/class-locator#locator-aria-snapshot-json>
2083 pub async fn aria_snapshot_json(
2084 &self,
2085 options: impl Into<Option<crate::protocol::AriaSnapshotOptions>>,
2086 ) -> Result<serde_json::Value> {
2087 let options = options.into();
2088 self.frame
2089 .locator_aria_snapshot_json(&self.selector, options.as_ref())
2090 .await
2091 .map_err(|e| self.wrap_error_with_selector(e))
2092 }
2093
2094 /// Returns a new locator whose selector has been resolved to a
2095 /// best-practices canonical form — preferring test-ids, then ARIA
2096 /// roles, then accessible text. The resolved locator points at the
2097 /// same element(s) as `self` but uses a more robust selector that
2098 /// is less coupled to CSS classes or DOM structure. Useful as a
2099 /// building block for codegen helpers that want the "most stable
2100 /// selector for this element" primitive.
2101 ///
2102 /// See the module-level example for usage.
2103 ///
2104 /// # Errors
2105 ///
2106 /// Returns an error if:
2107 /// - No element matches the original selector
2108 /// - The protocol call fails
2109 ///
2110 /// See: <https://playwright.dev/docs/api/class-locator#locator-normalize>
2111 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
2112 pub async fn normalize(&self) -> Result<Locator> {
2113 let resolved = self
2114 .frame
2115 .frame_resolve_selector(&self.selector)
2116 .await
2117 .map_err(|e| self.wrap_error_with_selector(e))?;
2118 Ok(Locator {
2119 frame: Arc::clone(&self.frame),
2120 selector: resolved,
2121 page: self.page.clone(),
2122 })
2123 }
2124
2125 /// Returns a new Locator with an attached description for traces and error messages.
2126 ///
2127 /// The description does not affect element matching — it is purely informational,
2128 /// appearing in trace viewer labels and error messages to make them more readable.
2129 ///
2130 /// Appends `>> internal:describe="description"` to the selector, matching
2131 /// playwright-python's behavior exactly.
2132 ///
2133 /// See: <https://playwright.dev/docs/api/class-locator#locator-describe>
2134 pub fn describe(&self, description: &str) -> Locator {
2135 let escaped = json_quote(description);
2136 Locator::new(
2137 Arc::clone(&self.frame),
2138 format!("{} >> internal:describe={}", self.selector, escaped),
2139 self.page.clone(),
2140 )
2141 }
2142
2143 /// Highlights the matched element in the browser for visual debugging.
2144 ///
2145 /// Draws a colored overlay over the element for a short period. This is a
2146 /// debugging tool and has no effect on test assertions or element state.
2147 ///
2148 /// # Errors
2149 ///
2150 /// Returns an error if:
2151 /// - The element is not found within the timeout
2152 /// - The protocol call fails
2153 ///
2154 /// See: <https://playwright.dev/docs/api/class-locator#locator-highlight>
2155 #[tracing::instrument(level = "debug", skip_all, fields(selector = %self.selector))]
2156 pub async fn highlight(&self, options: impl Into<Option<HighlightOptions>>) -> Result<()> {
2157 let options = options.into();
2158 let style = options.and_then(|o| o.style);
2159 self.frame
2160 .locator_highlight(&self.selector, style.as_deref())
2161 .await
2162 .map_err(|e| self.wrap_error_with_selector(e))
2163 }
2164
2165 /// Returns a [`FrameLocator`](crate::protocol::FrameLocator) for the content of an
2166 /// `<iframe>` element matched by this locator.
2167 ///
2168 /// This is a client-side operation — it creates a `FrameLocator` scoped to the matched
2169 /// iframe element, allowing you to interact with elements inside the iframe using the
2170 /// standard `FrameLocator` API.
2171 ///
2172 /// Equivalent to `page.frame_locator(selector)`, but starting from an existing `Locator`.
2173 ///
2174 /// See: <https://playwright.dev/docs/api/class-locator#locator-content-frame>
2175 pub fn content_frame(&self) -> crate::protocol::FrameLocator {
2176 crate::protocol::FrameLocator::new(
2177 Arc::clone(&self.frame),
2178 self.selector.clone(),
2179 self.page.clone(),
2180 )
2181 }
2182}
2183
2184impl std::fmt::Debug for Locator {
2185 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2186 f.debug_struct("Locator")
2187 .field("selector", &self.selector)
2188 .finish()
2189 }
2190}
2191
2192#[cfg(test)]
2193mod tests {
2194 use super::*;
2195
2196 #[test]
2197 fn test_escape_for_selector_case_insensitive() {
2198 assert_eq!(escape_for_selector("hello", false), "\"hello\"i");
2199 }
2200
2201 #[test]
2202 fn test_escape_for_selector_exact() {
2203 assert_eq!(escape_for_selector("hello", true), "\"hello\"s");
2204 }
2205
2206 #[test]
2207 fn test_escape_for_selector_with_quotes() {
2208 assert_eq!(
2209 escape_for_selector("say \"hi\"", false),
2210 "\"say \\\"hi\\\"\"i"
2211 );
2212 }
2213
2214 #[test]
2215 fn test_get_by_text_selector_case_insensitive() {
2216 assert_eq!(
2217 get_by_text_selector("Click me", false),
2218 "internal:text=\"Click me\"i"
2219 );
2220 }
2221
2222 #[test]
2223 fn test_get_by_text_selector_exact() {
2224 assert_eq!(
2225 get_by_text_selector("Click me", true),
2226 "internal:text=\"Click me\"s"
2227 );
2228 }
2229
2230 #[test]
2231 fn test_get_by_label_selector() {
2232 assert_eq!(
2233 get_by_label_selector("Email", false),
2234 "internal:label=\"Email\"i"
2235 );
2236 }
2237
2238 #[test]
2239 fn test_get_by_placeholder_selector() {
2240 assert_eq!(
2241 get_by_placeholder_selector("Enter name", false),
2242 "internal:attr=[placeholder=\"Enter name\"i]"
2243 );
2244 }
2245
2246 #[test]
2247 fn test_get_by_alt_text_selector() {
2248 assert_eq!(
2249 get_by_alt_text_selector("Logo", true),
2250 "internal:attr=[alt=\"Logo\"s]"
2251 );
2252 }
2253
2254 #[test]
2255 fn test_get_by_title_selector() {
2256 assert_eq!(
2257 get_by_title_selector("Help", false),
2258 "internal:attr=[title=\"Help\"i]"
2259 );
2260 }
2261
2262 #[test]
2263 fn test_get_by_test_id_selector() {
2264 assert_eq!(
2265 get_by_test_id_selector("submit-btn"),
2266 "internal:testid=[data-testid=\"submit-btn\"s]"
2267 );
2268 }
2269
2270 #[test]
2271 fn test_escape_for_attribute_selector_case_insensitive() {
2272 assert_eq!(
2273 escape_for_attribute_selector("Submit", false),
2274 "\"Submit\"i"
2275 );
2276 }
2277
2278 #[test]
2279 fn test_escape_for_attribute_selector_exact() {
2280 assert_eq!(escape_for_attribute_selector("Submit", true), "\"Submit\"s");
2281 }
2282
2283 #[test]
2284 fn test_escape_for_attribute_selector_escapes_quotes() {
2285 assert_eq!(
2286 escape_for_attribute_selector("Say \"hello\"", false),
2287 "\"Say \\\"hello\\\"\"i"
2288 );
2289 }
2290
2291 #[test]
2292 fn test_escape_for_attribute_selector_escapes_backslashes() {
2293 assert_eq!(
2294 escape_for_attribute_selector("path\\to", true),
2295 "\"path\\\\to\"s"
2296 );
2297 }
2298
2299 #[test]
2300 fn test_get_by_role_selector_role_only() {
2301 assert_eq!(
2302 get_by_role_selector(AriaRole::Button, None),
2303 "internal:role=button"
2304 );
2305 }
2306
2307 #[test]
2308 fn test_get_by_role_selector_with_name() {
2309 let opts = GetByRoleOptions::default().name("Submit");
2310 assert_eq!(
2311 get_by_role_selector(AriaRole::Button, Some(opts)),
2312 "internal:role=button[name=\"Submit\"i]"
2313 );
2314 }
2315
2316 #[test]
2317 fn test_filter_options_setters() {
2318 let opts = FilterOptions::default()
2319 .has_text("a")
2320 .has_not_text("b")
2321 .visible(false);
2322 assert_eq!(opts.has_text.as_deref(), Some("a"));
2323 assert_eq!(opts.has_not_text.as_deref(), Some("b"));
2324 assert_eq!(opts.visible, Some(false));
2325 assert_eq!(FilterOptions::default().visible, None);
2326 }
2327
2328 #[test]
2329 fn a_shared_deadline_leaves_the_remainder_and_keeps_zero_as_no_timeout() {
2330 use std::time::Duration;
2331 assert_eq!(remaining_ms(5_000.0, Duration::from_millis(1_500)), 3_500.0);
2332 assert_eq!(remaining_ms(5_000.0, Duration::from_millis(6_000)), 1.0);
2333 assert_eq!(remaining_ms(0.0, Duration::from_millis(1_500)), 0.0);
2334 }
2335
2336 #[test]
2337 fn within_selector_chains_the_child_under_the_parent() {
2338 assert_eq!(
2339 within_selector("internal:role=cell >> nth=1", "internal:role=row"),
2340 r#"internal:role=row >> internal:chain="internal:role=cell >> nth=1""#
2341 );
2342 assert_eq!(
2343 within_selector(r#"text="Save""#, "dialog"),
2344 r#"dialog >> internal:chain="text=\"Save\"""#
2345 );
2346 }
2347
2348 #[test]
2349 fn filter_selector_appends_each_narrowing_in_order() {
2350 let options = FilterOptions::default()
2351 .has_text("Apple")
2352 .has_not_text("Pear")
2353 .visible(true);
2354
2355 assert_eq!(
2356 filter_selector("tr", &options, Some("td.price"), Some("td.sold")),
2357 "tr >> internal:has-text=\"Apple\"i >> internal:has-not-text=\"Pear\"i \
2358 >> internal:has=\"td.price\" >> internal:has-not=\"td.sold\" >> visible=true"
2359 );
2360 }
2361
2362 #[test]
2363 fn filter_selector_leaves_the_base_alone_when_nothing_is_set() {
2364 assert_eq!(
2365 filter_selector("tr", &FilterOptions::default(), None, None),
2366 "tr"
2367 );
2368 }
2369
2370 #[test]
2371 fn filter_selector_spells_the_hidden_half() {
2372 let options = FilterOptions::default().visible(false);
2373 assert_eq!(
2374 filter_selector(".item", &options, None, None),
2375 ".item >> visible=false"
2376 );
2377 }
2378
2379 #[test]
2380 fn test_get_by_role_selector_with_description() {
2381 let opts = GetByRoleOptions::default().description("Close dialog");
2382 assert_eq!(
2383 get_by_role_selector(AriaRole::Button, Some(opts)),
2384 "internal:role=button[description=\"Close dialog\"i]"
2385 );
2386 let exact = GetByRoleOptions::default().description("Close").exact(true);
2387 assert_eq!(
2388 get_by_role_selector(AriaRole::Button, Some(exact)),
2389 "internal:role=button[description=\"Close\"s]"
2390 );
2391 }
2392
2393 #[test]
2394 fn test_get_by_role_selector_with_name_exact() {
2395 let opts = GetByRoleOptions::default().name("Submit").exact(true);
2396 assert_eq!(
2397 get_by_role_selector(AriaRole::Button, Some(opts)),
2398 "internal:role=button[name=\"Submit\"s]"
2399 );
2400 }
2401
2402 #[test]
2403 fn test_get_by_role_selector_with_checked() {
2404 let opts = GetByRoleOptions::default().checked(true);
2405 assert_eq!(
2406 get_by_role_selector(AriaRole::Checkbox, Some(opts)),
2407 "internal:role=checkbox[checked=true]"
2408 );
2409 }
2410
2411 #[test]
2412 fn test_get_by_role_selector_with_level() {
2413 let opts = GetByRoleOptions::default().level(2);
2414 assert_eq!(
2415 get_by_role_selector(AriaRole::Heading, Some(opts)),
2416 "internal:role=heading[level=2]"
2417 );
2418 }
2419
2420 #[test]
2421 fn test_get_by_role_selector_with_disabled() {
2422 let opts = GetByRoleOptions::default().disabled(true);
2423 assert_eq!(
2424 get_by_role_selector(AriaRole::Button, Some(opts)),
2425 "internal:role=button[disabled=true]"
2426 );
2427 }
2428
2429 #[test]
2430 fn test_get_by_role_selector_with_selected() {
2431 let opts = GetByRoleOptions::default().selected(true);
2432 assert_eq!(
2433 get_by_role_selector(AriaRole::Option, Some(opts)),
2434 "internal:role=option[selected=true]"
2435 );
2436 }
2437
2438 #[test]
2439 fn test_get_by_role_selector_with_expanded() {
2440 let opts = GetByRoleOptions::default().expanded(true);
2441 assert_eq!(
2442 get_by_role_selector(AriaRole::Button, Some(opts)),
2443 "internal:role=button[expanded=true]"
2444 );
2445 }
2446
2447 #[test]
2448 fn test_get_by_role_selector_include_hidden() {
2449 let opts = GetByRoleOptions::default().include_hidden(true);
2450 assert_eq!(
2451 get_by_role_selector(AriaRole::Button, Some(opts)),
2452 "internal:role=button[include-hidden=true]"
2453 );
2454 }
2455
2456 #[test]
2457 fn test_get_by_role_selector_property_order() {
2458 // All properties: checked, disabled, selected, expanded, include-hidden, level, name, pressed
2459 let opts = GetByRoleOptions::default()
2460 .pressed(true)
2461 .name("OK")
2462 .checked(false)
2463 .disabled(true);
2464 assert_eq!(
2465 get_by_role_selector(AriaRole::Button, Some(opts)),
2466 "internal:role=button[checked=false][disabled=true][name=\"OK\"i][pressed=true]"
2467 );
2468 }
2469
2470 #[test]
2471 fn test_get_by_role_selector_name_with_special_chars() {
2472 let opts = GetByRoleOptions::default()
2473 .name("Click \"here\" now")
2474 .exact(true);
2475 assert_eq!(
2476 get_by_role_selector(AriaRole::Link, Some(opts)),
2477 "internal:role=link[name=\"Click \\\"here\\\" now\"s]"
2478 );
2479 }
2480
2481 #[test]
2482 fn test_aria_role_as_str() {
2483 assert_eq!(AriaRole::Button.as_str(), "button");
2484 assert_eq!(AriaRole::Heading.as_str(), "heading");
2485 assert_eq!(AriaRole::Link.as_str(), "link");
2486 assert_eq!(AriaRole::Checkbox.as_str(), "checkbox");
2487 assert_eq!(AriaRole::Alert.as_str(), "alert");
2488 assert_eq!(AriaRole::Navigation.as_str(), "navigation");
2489 assert_eq!(AriaRole::Progressbar.as_str(), "progressbar");
2490 assert_eq!(AriaRole::Treeitem.as_str(), "treeitem");
2491 }
2492
2493 #[test]
2494 fn highlight_options_style_setter_writes_the_field() {
2495 let opts = HighlightOptions::default().style("outline: 2px solid red");
2496 assert_eq!(opts.style.as_deref(), Some("outline: 2px solid red"));
2497 }
2498}