Skip to main content

llm_browser_testkit/
selectors.rs

1//! CSS selector sanitization and validation for LLM-generated selectors.
2//!
3//! The LLM sometimes returns garbage instead of a CSS selector (explanations,
4//! JSON objects, `:not(*)`, `null`, stray quotes or code fences). These
5//! helpers normalize the response and reject values that can never match an
6//! element, so step errors show the LLM's actual output instead of a cryptic
7//! "element :not(*) not found".
8
9/// Strips common LLM response noise from a raw selector string:
10/// surrounding whitespace, code fences (with optional language tag), and
11/// outer single/double/backtick quotes.
12///
13/// Returns an empty string when nothing meaningful remains.
14#[must_use]
15pub fn sanitize_selector(raw: &str) -> String {
16    let mut s = raw.trim().to_owned();
17    // ```css ... ``` / ``` ... ``` code fences
18    if let Some(rest) = s.strip_prefix("```") {
19        let rest = rest.strip_suffix("```").unwrap_or(rest);
20        s = rest.trim().to_owned();
21        if let Some((_, after_lang)) = s.split_once('\n') {
22            s = after_lang.trim().to_owned();
23        }
24    }
25    // Backtick-wrapped: `selector`
26    if s.len() >= 2 && s.starts_with('`') && s.ends_with('`') {
27        s = s[1..s.len() - 1].to_owned();
28    }
29    // Quote-wrapped: "selector" or 'selector'
30    if s.len() >= 2
31        && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
32    {
33        s = s[1..s.len() - 1].to_owned();
34    }
35    s.trim().to_owned()
36}
37
38/// Returns `true` when a sanitized selector can never match a real element,
39/// i.e. the LLM clearly did not produce a selector at all.
40///
41/// Used to fail fast with a readable error instead of handing the garbage to
42/// the browser's `querySelector`.
43#[must_use]
44pub fn selector_is_useless(sel: &str) -> bool {
45    let s = sel.trim();
46    if s.is_empty() {
47        return true;
48    }
49    let lower = s.to_lowercase();
50    matches!(
51        lower.as_str(),
52        "*" | ":not(*)" | "null" | "undefined" | "none" | "n/a" | "na" | "nil"
53    ) || lower.starts_with(":not(")
54        || lower.starts_with('{')
55        || lower.starts_with("json")
56        || s.contains('\n')
57}
58
59/// Basic syntactic sanity check for a selector before it is executed.
60///
61/// Returns a human-readable reason when the selector is obviously malformed
62/// (unbalanced brackets/parens/quotes), or `Ok` otherwise. This is a cheap
63/// pre-flight only — the browser's `querySelector` remains the authority.
64///
65/// # Errors
66///
67/// Returns a description of the malformed part when the selector has
68/// unbalanced brackets, parentheses, or quotes.
69pub fn validate_selector(sel: &str) -> Result<(), String> {
70    let mut parens = 0i32;
71    let mut brackets = 0i32;
72    let mut in_quote: Option<char> = None;
73    for c in sel.chars() {
74        if let Some(q) = in_quote {
75            if c == q {
76                in_quote = None;
77            }
78            continue;
79        }
80        match c {
81            '"' | '\'' => in_quote = Some(c),
82            '(' => parens += 1,
83            ')' => parens -= 1,
84            '[' => brackets += 1,
85            ']' => brackets -= 1,
86            _ => {}
87        }
88        if parens < 0 || brackets < 0 {
89            return Err(format!("unbalanced '{c}' in selector: {sel:?}"));
90        }
91    }
92    if in_quote.is_some() {
93        return Err(format!("unterminated quote in selector: {sel:?}"));
94    }
95    if parens != 0 || brackets != 0 {
96        return Err(format!("unbalanced brackets in selector: {sel:?}"));
97    }
98    Ok(())
99}
100
101/// Builds the JavaScript snippet that checks whether a selector matches at
102/// least one element on the current page.
103#[must_use]
104pub fn selector_matches_js(selector: &str) -> String {
105    let escaped = selector.replace('\\', "\\\\").replace('\'', "\\'");
106    format!("document.querySelector('{escaped}') !== null")
107}
108
109#[cfg(test)]
110mod tests {
111    use super::{sanitize_selector, selector_is_useless, validate_selector};
112
113    #[test]
114    fn test_sanitize_trims_whitespace() {
115        assert_eq!(sanitize_selector("  #login  "), "#login");
116    }
117
118    #[test]
119    fn test_sanitize_strips_code_fence() {
120        assert_eq!(
121            sanitize_selector("```css\nbutton.btn--primary\n```"),
122            "button.btn--primary"
123        );
124    }
125
126    #[test]
127    fn test_sanitize_strips_quotes() {
128        assert_eq!(sanitize_selector("\"button#send\""), "button#send");
129        assert_eq!(
130            sanitize_selector("'input[name=email]'"),
131            "input[name=email]"
132        );
133        assert_eq!(sanitize_selector("`a.login-link`"), "a.login-link");
134    }
135
136    #[test]
137    fn test_sanitize_empty() {
138        assert_eq!(sanitize_selector("```\n\n```"), "");
139        assert_eq!(sanitize_selector("   "), "");
140    }
141
142    #[test]
143    fn test_sanitize_quoted_fenced() {
144        assert_eq!(sanitize_selector("```css\n\"div.card\"\n```"), "div.card");
145    }
146
147    #[test]
148    fn test_useless_selectors() {
149        for bad in [
150            "",
151            "   ",
152            "*",
153            ":not(*)",
154            ":not(anything)",
155            "null",
156            "undefined",
157            "none",
158            "N/A",
159            "{",
160            "{\"a\":1}",
161            "json {\"a\":1}",
162        ] {
163            assert!(selector_is_useless(bad), "expected {bad:?} to be useless");
164        }
165    }
166
167    #[test]
168    fn test_useful_selectors() {
169        for good in [
170            "#id",
171            "button.btn",
172            "input[name=email]",
173            ":is(a, button)",
174            "a > span.x",
175            "div[aria-label='New chat']",
176        ] {
177            assert!(!selector_is_useless(good), "expected {good:?} to be useful");
178        }
179    }
180
181    #[test]
182    fn test_validate_balanced() {
183        assert!(validate_selector("div[aria-label='x']:nth-child(2)").is_ok());
184        assert!(validate_selector("a:has(> span)").is_ok());
185    }
186
187    #[test]
188    fn test_validate_unbalanced() {
189        assert!(validate_selector("div[aria-label").is_err());
190        assert!(validate_selector("div:nth-child(2").is_err());
191        assert!(validate_selector("div:not(").is_err());
192        assert!(validate_selector("div[aria-label='x]").is_err());
193    }
194}