viewpoint_core/page/locator/
selector.rs

1//! Selector types for element location strategies.
2
3/// ARIA roles for accessibility-based element selection.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum AriaRole {
6    Alert,
7    AlertDialog,
8    Application,
9    Article,
10    Banner,
11    Button,
12    Cell,
13    Checkbox,
14    ColumnHeader,
15    Combobox,
16    Complementary,
17    ContentInfo,
18    Definition,
19    Dialog,
20    Directory,
21    Document,
22    Feed,
23    Figure,
24    Form,
25    Grid,
26    GridCell,
27    Group,
28    Heading,
29    Img,
30    Link,
31    List,
32    ListBox,
33    ListItem,
34    Log,
35    Main,
36    Marquee,
37    Math,
38    Menu,
39    MenuBar,
40    MenuItem,
41    MenuItemCheckbox,
42    MenuItemRadio,
43    Navigation,
44    None,
45    Note,
46    Option,
47    Presentation,
48    ProgressBar,
49    Radio,
50    RadioGroup,
51    Region,
52    Row,
53    RowGroup,
54    RowHeader,
55    ScrollBar,
56    Search,
57    SearchBox,
58    Separator,
59    Slider,
60    SpinButton,
61    Status,
62    Switch,
63    Tab,
64    Table,
65    TabList,
66    TabPanel,
67    Term,
68    TextBox,
69    Timer,
70    Toolbar,
71    Tooltip,
72    Tree,
73    TreeGrid,
74    TreeItem,
75}
76
77impl AriaRole {
78    /// Get the role name as a string.
79    pub fn as_str(&self) -> &'static str {
80        match self {
81            AriaRole::Alert => "alert",
82            AriaRole::AlertDialog => "alertdialog",
83            AriaRole::Application => "application",
84            AriaRole::Article => "article",
85            AriaRole::Banner => "banner",
86            AriaRole::Button => "button",
87            AriaRole::Cell => "cell",
88            AriaRole::Checkbox => "checkbox",
89            AriaRole::ColumnHeader => "columnheader",
90            AriaRole::Combobox => "combobox",
91            AriaRole::Complementary => "complementary",
92            AriaRole::ContentInfo => "contentinfo",
93            AriaRole::Definition => "definition",
94            AriaRole::Dialog => "dialog",
95            AriaRole::Directory => "directory",
96            AriaRole::Document => "document",
97            AriaRole::Feed => "feed",
98            AriaRole::Figure => "figure",
99            AriaRole::Form => "form",
100            AriaRole::Grid => "grid",
101            AriaRole::GridCell => "gridcell",
102            AriaRole::Group => "group",
103            AriaRole::Heading => "heading",
104            AriaRole::Img => "img",
105            AriaRole::Link => "link",
106            AriaRole::List => "list",
107            AriaRole::ListBox => "listbox",
108            AriaRole::ListItem => "listitem",
109            AriaRole::Log => "log",
110            AriaRole::Main => "main",
111            AriaRole::Marquee => "marquee",
112            AriaRole::Math => "math",
113            AriaRole::Menu => "menu",
114            AriaRole::MenuBar => "menubar",
115            AriaRole::MenuItem => "menuitem",
116            AriaRole::MenuItemCheckbox => "menuitemcheckbox",
117            AriaRole::MenuItemRadio => "menuitemradio",
118            AriaRole::Navigation => "navigation",
119            AriaRole::None => "none",
120            AriaRole::Note => "note",
121            AriaRole::Option => "option",
122            AriaRole::Presentation => "presentation",
123            AriaRole::ProgressBar => "progressbar",
124            AriaRole::Radio => "radio",
125            AriaRole::RadioGroup => "radiogroup",
126            AriaRole::Region => "region",
127            AriaRole::Row => "row",
128            AriaRole::RowGroup => "rowgroup",
129            AriaRole::RowHeader => "rowheader",
130            AriaRole::ScrollBar => "scrollbar",
131            AriaRole::Search => "search",
132            AriaRole::SearchBox => "searchbox",
133            AriaRole::Separator => "separator",
134            AriaRole::Slider => "slider",
135            AriaRole::SpinButton => "spinbutton",
136            AriaRole::Status => "status",
137            AriaRole::Switch => "switch",
138            AriaRole::Tab => "tab",
139            AriaRole::Table => "table",
140            AriaRole::TabList => "tablist",
141            AriaRole::TabPanel => "tabpanel",
142            AriaRole::Term => "term",
143            AriaRole::TextBox => "textbox",
144            AriaRole::Timer => "timer",
145            AriaRole::Toolbar => "toolbar",
146            AriaRole::Tooltip => "tooltip",
147            AriaRole::Tree => "tree",
148            AriaRole::TreeGrid => "treegrid",
149            AriaRole::TreeItem => "treeitem",
150        }
151    }
152}
153
154/// Options for text-based locators.
155#[derive(Debug, Clone, Default)]
156pub struct TextOptions {
157    /// Whether to match exact text.
158    pub exact: bool,
159}
160
161/// Selector for finding elements.
162#[derive(Debug, Clone)]
163pub enum Selector {
164    /// CSS selector.
165    Css(String),
166
167    /// Text content selector.
168    Text {
169        text: String,
170        exact: bool,
171    },
172
173    /// ARIA role selector.
174    Role {
175        role: AriaRole,
176        name: Option<String>,
177    },
178
179    /// Test ID selector (data-testid attribute).
180    TestId(String),
181
182    /// Label selector (for form controls).
183    Label(String),
184
185    /// Placeholder selector (for inputs).
186    Placeholder(String),
187
188    /// Chained selector (parent >> child).
189    Chained(Box<Selector>, Box<Selector>),
190
191    /// Nth element selector.
192    Nth {
193        base: Box<Selector>,
194        index: i32, // Negative for last (-1 = last)
195    },
196}
197
198impl Selector {
199    /// Convert selector to a JavaScript expression that returns element(s).
200    pub fn to_js_expression(&self) -> String {
201        match self {
202            Selector::Css(css) => {
203                format!(
204                    "document.querySelectorAll({})",
205                    js_string_literal(css)
206                )
207            }
208
209            Selector::Text { text, exact } => {
210                if *exact {
211                    format!(
212                        r"Array.from(document.querySelectorAll('*')).filter(el => el.textContent?.trim() === {})",
213                        js_string_literal(text)
214                    )
215                } else {
216                    format!(
217                        r"Array.from(document.querySelectorAll('*')).filter(el => el.textContent?.includes({}))",
218                        js_string_literal(text)
219                    )
220                }
221            }
222
223            Selector::Role { role, name } => {
224                let role_str = role.as_str();
225                match name {
226                    Some(n) => format!(
227                        r#"Array.from(document.querySelectorAll('[role="{}"]')).concat(Array.from(document.querySelectorAll('{}'))).filter(el => (el.getAttribute('aria-label') || el.textContent?.trim()) === {})"#,
228                        role_str,
229                        implicit_role_selector(*role),
230                        js_string_literal(n)
231                    ),
232                    None => format!(
233                        r#"Array.from(document.querySelectorAll('[role="{}"]')).concat(Array.from(document.querySelectorAll('{}')))"#,
234                        role_str,
235                        implicit_role_selector(*role)
236                    ),
237                }
238            }
239
240            Selector::TestId(id) => {
241                format!(
242                    "document.querySelectorAll('[data-testid={}]')",
243                    js_string_literal(id)
244                )
245            }
246
247            Selector::Label(label) => {
248                format!(
249                    r"(function() {{
250                        const labels = Array.from(document.querySelectorAll('label'));
251                        const matching = labels.filter(l => l.textContent?.trim() === {});
252                        return matching.flatMap(l => {{
253                            if (l.htmlFor) return Array.from(document.querySelectorAll('#' + l.htmlFor));
254                            return Array.from(l.querySelectorAll('input, textarea, select'));
255                        }});
256                    }})()",
257                    js_string_literal(label)
258                )
259            }
260
261            Selector::Placeholder(placeholder) => {
262                format!(
263                    "document.querySelectorAll('[placeholder={}]')",
264                    js_string_literal(placeholder)
265                )
266            }
267
268            Selector::Chained(parent, child) => {
269                format!(
270                    r"(function() {{
271                        const parents = {};
272                        const results = [];
273                        for (const parent of parents) {{
274                            const children = parent.querySelectorAll ? 
275                                Array.from(parent.querySelectorAll('*')) : [];
276                            const childSelector = {};
277                            for (const child of childSelector) {{
278                                if (parent.contains(child)) results.push(child);
279                            }}
280                        }}
281                        return results;
282                    }})()",
283                    parent.to_js_expression(),
284                    child.to_js_expression()
285                )
286            }
287
288            Selector::Nth { base, index } => {
289                let base_expr = base.to_js_expression();
290                if *index >= 0 {
291                    format!(
292                        r"(function() {{
293                            const elements = Array.from({base_expr});
294                            return elements[{index}] ? [elements[{index}]] : [];
295                        }})()"
296                    )
297                } else {
298                    // Negative index: -1 = last, -2 = second to last, etc.
299                    format!(
300                        r"(function() {{
301                            const elements = Array.from({base_expr});
302                            const idx = elements.length + {index};
303                            return idx >= 0 && elements[idx] ? [elements[idx]] : [];
304                        }})()"
305                    )
306                }
307            }
308        }
309    }
310}
311
312/// Escape a string for use in JavaScript.
313pub(crate) fn js_string_literal(s: &str) -> String {
314    let escaped = s
315        .replace('\\', "\\\\")
316        .replace('\'', "\\'")
317        .replace('\n', "\\n")
318        .replace('\r', "\\r")
319        .replace('\t', "\\t");
320    format!("'{escaped}'")
321}
322
323/// Get CSS selector for elements with implicit ARIA roles.
324fn implicit_role_selector(role: AriaRole) -> &'static str {
325    match role {
326        AriaRole::Button => "button, input[type='button'], input[type='submit'], input[type='reset']",
327        AriaRole::Link => "a[href]",
328        AriaRole::Heading => "h1, h2, h3, h4, h5, h6",
329        AriaRole::ListItem => "li",
330        AriaRole::List => "ul, ol",
331        AriaRole::TextBox => "input[type='text'], input:not([type]), textarea",
332        AriaRole::Checkbox => "input[type='checkbox']",
333        AriaRole::Radio => "input[type='radio']",
334        AriaRole::Combobox => "select",
335        AriaRole::Img => "img",
336        AriaRole::Navigation => "nav",
337        AriaRole::Main => "main",
338        AriaRole::Banner => "header",
339        AriaRole::ContentInfo => "footer",
340        AriaRole::Form => "form",
341        AriaRole::Search => "[role='search']",
342        AriaRole::Table => "table",
343        AriaRole::Row => "tr",
344        AriaRole::Cell => "td",
345        AriaRole::ColumnHeader => "th",
346        _ => "", // No implicit role mapping
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn test_css_selector_js() {
356        let selector = Selector::Css("button.submit".to_string());
357        let js = selector.to_js_expression();
358        assert!(js.contains("querySelectorAll"));
359        assert!(js.contains("button.submit"));
360    }
361
362    #[test]
363    fn test_text_selector_exact_js() {
364        let selector = Selector::Text {
365            text: "Hello".to_string(),
366            exact: true,
367        };
368        let js = selector.to_js_expression();
369        assert!(js.contains("textContent"));
370        assert!(js.contains("=== 'Hello'"));
371    }
372
373    #[test]
374    fn test_text_selector_partial_js() {
375        let selector = Selector::Text {
376            text: "Hello".to_string(),
377            exact: false,
378        };
379        let js = selector.to_js_expression();
380        assert!(js.contains("includes"));
381    }
382
383    #[test]
384    fn test_role_selector_js() {
385        let selector = Selector::Role {
386            role: AriaRole::Button,
387            name: Some("Submit".to_string()),
388        };
389        let js = selector.to_js_expression();
390        assert!(js.contains("role=\"button\""));
391        assert!(js.contains("Submit"));
392    }
393
394    #[test]
395    fn test_testid_selector_js() {
396        let selector = Selector::TestId("my-button".to_string());
397        let js = selector.to_js_expression();
398        assert!(js.contains("data-testid"));
399        assert!(js.contains("my-button"));
400    }
401
402    #[test]
403    fn test_js_string_escaping() {
404        let result = js_string_literal("it's a \"test\"\nwith newlines");
405        assert_eq!(result, "'it\\'s a \"test\"\\nwith newlines'");
406    }
407}