Skip to main content

browser_commander/high_level/
universal_logic.rs

1//! Universal high-level functions following DRY principles.
2//!
3//! These are pure functions that work with any browser automation engine.
4
5use crate::core::engine::{EngineAdapter, EngineError};
6use crate::core::navigation::is_navigation_error;
7use std::time::Duration;
8
9/// Wait for a URL condition to be met.
10///
11/// # Arguments
12///
13/// * `adapter` - The engine adapter to use
14/// * `target_url` - The target URL to wait for
15/// * `description` - Description for logging
16/// * `polling_interval` - Interval between checks
17/// * `custom_check` - Optional custom check function
18///
19/// # Returns
20///
21/// `true` if the target URL was reached, `false` otherwise
22pub async fn wait_for_url_condition<F>(
23    adapter: &dyn EngineAdapter,
24    target_url: &str,
25    _description: Option<&str>,
26    polling_interval: Duration,
27    custom_check: Option<F>,
28) -> Result<bool, EngineError>
29where
30    F: Fn(&str) -> bool,
31{
32    loop {
33        // Run custom check if provided
34        if let Some(ref check) = custom_check {
35            let current_url = adapter.url().await?;
36            if check(&current_url) {
37                return Ok(true);
38            }
39        }
40
41        // Check if target URL reached
42        let current_url = match adapter.url().await {
43            Ok(url) => url,
44            Err(e) if is_navigation_error(&e.to_string()) => {
45                // Navigation error during check - continue waiting
46                tokio::time::sleep(polling_interval).await;
47                continue;
48            }
49            Err(e) => return Err(e),
50        };
51
52        if current_url.starts_with(target_url) {
53            return Ok(true);
54        }
55
56        tokio::time::sleep(polling_interval).await;
57    }
58}
59
60/// Install a click detection listener on the page.
61///
62/// This installs a JavaScript event listener that sets a session storage
63/// flag when a specific button is clicked.
64///
65/// # Arguments
66///
67/// * `adapter` - The engine adapter to use
68/// * `button_text` - The button text to detect
69/// * `storage_key` - The session storage key to set
70///
71/// # Returns
72///
73/// `true` if the listener was installed, `false` on navigation error
74pub async fn install_click_listener(
75    adapter: &dyn EngineAdapter,
76    button_text: &str,
77    storage_key: &str,
78) -> Result<bool, EngineError> {
79    let script = format!(
80        r#"
81        (function() {{
82            document.addEventListener('click', (event) => {{
83                let element = event.target;
84                while (element && element !== document.body) {{
85                    const elementText = element.textContent?.trim() || '';
86                    if (elementText === '{}' ||
87                        ((element.tagName === 'A' || element.tagName === 'BUTTON') &&
88                         elementText.includes('{}'))) {{
89                        console.log('[Click Listener] Detected click on {} button!');
90                        window.sessionStorage.setItem('{}', 'true');
91                        break;
92                    }}
93                    element = element.parentElement;
94                }}
95            }}, true);
96        }})()
97        "#,
98        button_text.replace('\'', "\\'"),
99        button_text.replace('\'', "\\'"),
100        button_text.replace('\'', "\\'"),
101        storage_key.replace('\'', "\\'")
102    );
103
104    match adapter.evaluate(&script).await {
105        Ok(_) => Ok(true),
106        Err(e) if is_navigation_error(&e.to_string()) => Ok(false),
107        Err(e) => Err(e),
108    }
109}
110
111/// Check and clear a session storage flag.
112///
113/// # Arguments
114///
115/// * `adapter` - The engine adapter to use
116/// * `storage_key` - The session storage key to check
117///
118/// # Returns
119///
120/// `true` if the flag was set (and has been cleared), `false` otherwise
121pub async fn check_and_clear_flag(
122    adapter: &dyn EngineAdapter,
123    storage_key: &str,
124) -> Result<bool, EngineError> {
125    let script = format!(
126        r#"
127        (function() {{
128            const flag = window.sessionStorage.getItem('{}');
129            if (flag === 'true') {{
130                window.sessionStorage.removeItem('{}');
131                return true;
132            }}
133            return false;
134        }})()
135        "#,
136        storage_key.replace('\'', "\\'"),
137        storage_key.replace('\'', "\\'")
138    );
139
140    match adapter.evaluate(&script).await {
141        Ok(value) => Ok(value.as_bool().unwrap_or(false)),
142        Err(e) if is_navigation_error(&e.to_string()) => Ok(false),
143        Err(e) => Err(e),
144    }
145}
146
147/// Find a toggle button using multiple strategies.
148///
149/// First tries data-qa selectors, then falls back to text search.
150///
151/// # Arguments
152///
153/// * `adapter` - The engine adapter to use
154/// * `data_qa_selectors` - List of data-qa selectors to try
155/// * `text_to_find` - Text to search for as fallback
156/// * `element_types` - Element types to search for text
157///
158/// # Returns
159///
160/// The selector that found elements, or `None` if not found
161pub async fn find_toggle_button(
162    adapter: &dyn EngineAdapter,
163    data_qa_selectors: &[&str],
164    text_to_find: Option<&str>,
165    element_types: &[&str],
166) -> Result<Option<String>, EngineError> {
167    // Try data-qa selectors first
168    for selector in data_qa_selectors {
169        let count = adapter.count(selector).await?;
170        if count > 0 {
171            return Ok(Some(selector.to_string()));
172        }
173    }
174
175    // Fallback to text search
176    if let Some(text) = text_to_find {
177        for element_type in element_types {
178            // Build an XPath selector for the element type with text
179            let xpath = format!(
180                "//{}[contains(text(), '{}')]",
181                element_type,
182                text.replace('\'', "\\'")
183            );
184
185            // Try to evaluate and count matches
186            let script = format!(
187                r#"
188                (function() {{
189                    const result = document.evaluate('{}', document, null,
190                        XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
191                    return result.snapshotLength;
192                }})()
193                "#,
194                xpath.replace('\'', "\\'")
195            );
196
197            match adapter.evaluate(&script).await {
198                Ok(value) => {
199                    if let Some(count) = value.as_u64() {
200                        if count > 0 {
201                            return Ok(Some(xpath));
202                        }
203                    }
204                }
205                Err(_) => continue,
206            }
207        }
208    }
209
210    Ok(None)
211}
212
213#[cfg(test)]
214mod tests {
215    #[allow(unused_imports)]
216    use super::*;
217
218    #[test]
219    fn install_click_listener_script_escapes_quotes() {
220        // Just verify the function signature and that it compiles
221        // Actual testing requires a real browser
222        let _button_text = "Click me";
223        let _storage_key = "button_clicked";
224    }
225
226    #[test]
227    fn check_and_clear_flag_script_escapes_quotes() {
228        // Just verify the function signature
229        let _storage_key = "test_key";
230    }
231}