1#[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 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#[derive(Debug, Clone, Default)]
156pub struct TextOptions {
157 pub exact: bool,
159}
160
161#[derive(Debug, Clone)]
163pub enum Selector {
164 Css(String),
166
167 Text {
169 text: String,
170 exact: bool,
171 },
172
173 Role {
175 role: AriaRole,
176 name: Option<String>,
177 },
178
179 TestId(String),
181
182 Label(String),
184
185 Placeholder(String),
187
188 Chained(Box<Selector>, Box<Selector>),
190
191 Nth {
193 base: Box<Selector>,
194 index: i32, },
196}
197
198impl Selector {
199 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 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
312pub(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
323fn 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 _ => "", }
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}