Skip to main content

night_fury_core/domains/
query.rs

1use serde_json::Value;
2use tokio::sync::oneshot;
3
4use crate::error::NightFuryError;
5use crate::session::BrowserSession;
6use crate::worker::frame_helpers::frame_wrap_js;
7use crate::worker::helpers::{js_query_string, wait_for_selector};
8use crate::worker::WorkerState;
9
10// ---------------------------------------------------------------------------
11// Command enum
12// ---------------------------------------------------------------------------
13
14/// Commands for the query domain.
15#[non_exhaustive]
16pub enum QueryCmd {
17    GetText {
18        selector: String,
19        reply: oneshot::Sender<Result<String, String>>,
20    },
21    GetValue {
22        selector: String,
23        reply: oneshot::Sender<Result<String, String>>,
24    },
25    GetAttribute {
26        selector: String,
27        attribute: String,
28        reply: oneshot::Sender<Result<String, String>>,
29    },
30    GetHtml {
31        selector: String,
32        reply: oneshot::Sender<Result<String, String>>,
33    },
34    GetCount {
35        selector: String,
36        reply: oneshot::Sender<Result<usize, String>>,
37    },
38    GetBox {
39        selector: String,
40        reply: oneshot::Sender<Result<Value, String>>,
41    },
42    IsChecked {
43        selector: String,
44        reply: oneshot::Sender<Result<bool, String>>,
45    },
46    IsVisible {
47        selector: String,
48        reply: oneshot::Sender<Result<bool, String>>,
49    },
50    IsEnabled {
51        selector: String,
52        reply: oneshot::Sender<Result<bool, String>>,
53    },
54    WaitForSelector {
55        selector: String,
56        timeout_ms: u64,
57        reply: oneshot::Sender<Result<String, String>>,
58    },
59}
60
61// ---------------------------------------------------------------------------
62// Dispatch
63// ---------------------------------------------------------------------------
64
65impl QueryCmd {
66    pub(crate) async fn dispatch(self, state: &WorkerState) {
67        match self {
68            QueryCmd::GetText { selector, reply } => handle_get_text(state, selector, reply).await,
69            QueryCmd::GetValue { selector, reply } => {
70                handle_get_value(state, selector, reply).await
71            }
72            QueryCmd::GetAttribute {
73                selector,
74                attribute,
75                reply,
76            } => handle_get_attribute(state, selector, attribute, reply).await,
77            QueryCmd::GetHtml { selector, reply } => handle_get_html(state, selector, reply).await,
78            QueryCmd::GetCount { selector, reply } => {
79                handle_get_count(state, selector, reply).await
80            }
81            QueryCmd::GetBox { selector, reply } => handle_get_box(state, selector, reply).await,
82            QueryCmd::IsChecked { selector, reply } => {
83                handle_is_checked(state, selector, reply).await
84            }
85            QueryCmd::IsVisible { selector, reply } => {
86                handle_is_visible(state, selector, reply).await
87            }
88            QueryCmd::IsEnabled { selector, reply } => {
89                handle_is_enabled(state, selector, reply).await
90            }
91            QueryCmd::WaitForSelector {
92                selector,
93                timeout_ms,
94                reply,
95            } => {
96                let result = wait_for_selector(
97                    &state.tabs[state.active_tab].page,
98                    &selector,
99                    timeout_ms,
100                    &state.tabs[state.active_tab].active_frame.clone(),
101                )
102                .await;
103                let _ = reply.send(result);
104            }
105        }
106    }
107}
108
109// ---------------------------------------------------------------------------
110// Handlers
111// ---------------------------------------------------------------------------
112
113async fn handle_get_text(
114    state: &WorkerState,
115    selector: String,
116    reply: oneshot::Sender<Result<String, String>>,
117) {
118    let result = js_query_string(
119        &state.tabs[state.active_tab].page,
120        &selector,
121        "innerText",
122        "innerText",
123        &state.tabs[state.active_tab].active_frame.clone(),
124    )
125    .await;
126    let _ = reply.send(result);
127}
128
129async fn handle_get_value(
130    state: &WorkerState,
131    selector: String,
132    reply: oneshot::Sender<Result<String, String>>,
133) {
134    let result = js_query_string(
135        &state.tabs[state.active_tab].page,
136        &selector,
137        "value",
138        "value",
139        &state.tabs[state.active_tab].active_frame.clone(),
140    )
141    .await;
142    let _ = reply.send(result);
143}
144
145async fn handle_get_attribute(
146    state: &WorkerState,
147    selector: String,
148    attribute: String,
149    reply: oneshot::Sender<Result<String, String>>,
150) {
151    let sel_json = serde_json::to_string(&selector).unwrap_or_default();
152    let attr_json = serde_json::to_string(&attribute).unwrap_or_default();
153    let inner = format!(
154        "(function() {{ \
155            var el = document.querySelector({sel_json}); \
156            if (!el) return null; \
157            return el.getAttribute({attr_json}); \
158        }})()"
159    );
160    let js = frame_wrap_js(&state.tabs[state.active_tab].active_frame, &inner);
161    let result = state.tabs[state.active_tab]
162        .page
163        .evaluate_stealth(&js)
164        .await
165        .map_err(|e| format!("getAttribute eval failed: {e}"))
166        .and_then(|v| match v.as_ref() {
167            Some(serde_json::Value::String(s)) => Ok(s.clone()),
168            Some(serde_json::Value::Null) | None => {
169                Err(format!("attribute \"{attribute}\" not found on {selector}"))
170            }
171            Some(other) => Ok(other.to_string()),
172        });
173    let _ = reply.send(result);
174}
175
176async fn handle_get_html(
177    state: &WorkerState,
178    selector: String,
179    reply: oneshot::Sender<Result<String, String>>,
180) {
181    let result = async {
182        let active_frame = state.tabs[state.active_tab].active_frame.clone();
183        let page = &state.tabs[state.active_tab].page;
184        let sel_json = serde_json::to_string(&selector).unwrap_or_default();
185        let inner = format!(
186            "(function() {{ \
187                var el = document.querySelector({sel_json}); \
188                if (!el) return null; \
189                return el.innerHTML; \
190            }})()"
191        );
192        let js = frame_wrap_js(&active_frame, &inner);
193        let val = page
194            .evaluate_stealth(&js)
195            .await
196            .map_err(|e| format!("get_html eval failed: {e}"))?;
197        match val.as_ref() {
198            Some(serde_json::Value::String(s)) => Ok(s.clone()),
199            Some(serde_json::Value::Null) | None => Err(format!("Element not found: {selector}")),
200            _ => Err("Unexpected value from get_html".to_string()),
201        }
202    }
203    .await;
204    let _ = reply.send(result);
205}
206
207async fn handle_get_count(
208    state: &WorkerState,
209    selector: String,
210    reply: oneshot::Sender<Result<usize, String>>,
211) {
212    let result = async {
213        let active_frame = state.tabs[state.active_tab].active_frame.clone();
214        let page = &state.tabs[state.active_tab].page;
215        let sel_json = serde_json::to_string(&selector).unwrap_or_default();
216        let js = frame_wrap_js(
217            &active_frame,
218            &format!("document.querySelectorAll({sel_json}).length"),
219        );
220        let val = page
221            .evaluate_stealth(&js)
222            .await
223            .map_err(|e| format!("get_count eval failed: {e}"))?;
224        match val.as_ref() {
225            Some(serde_json::Value::Number(n)) => n
226                .as_u64()
227                .map(|v| v as usize)
228                .ok_or_else(|| "get_count returned non-integer".to_string()),
229            _ => Err("Unexpected value from get_count".to_string()),
230        }
231    }
232    .await;
233    let _ = reply.send(result);
234}
235
236async fn handle_get_box(
237    state: &WorkerState,
238    selector: String,
239    reply: oneshot::Sender<Result<Value, String>>,
240) {
241    let result = async {
242        let active_frame = state.tabs[state.active_tab].active_frame.clone();
243        let page = &state.tabs[state.active_tab].page;
244        let sel_json = serde_json::to_string(&selector).unwrap_or_default();
245        let inner = format!(
246            "(function() {{ \
247                var el = document.querySelector({sel_json}); \
248                if (!el) return null; \
249                var r = el.getBoundingClientRect(); \
250                return {{ x: r.x, y: r.y, width: r.width, height: r.height }}; \
251            }})()"
252        );
253        let js = frame_wrap_js(&active_frame, &inner);
254        let val = page
255            .evaluate_stealth(&js)
256            .await
257            .map_err(|e| format!("get_box eval failed: {e}"))?;
258        match val.as_ref() {
259            Some(v @ serde_json::Value::Object(_)) => Ok(v.clone()),
260            Some(serde_json::Value::Null) | None => Err(format!("Element not found: {selector}")),
261            _ => Err("Unexpected value from get_box".to_string()),
262        }
263    }
264    .await;
265    let _ = reply.send(result);
266}
267
268async fn handle_is_checked(
269    state: &WorkerState,
270    selector: String,
271    reply: oneshot::Sender<Result<bool, String>>,
272) {
273    let result = async {
274        let active_frame = state.tabs[state.active_tab].active_frame.clone();
275        let page = &state.tabs[state.active_tab].page;
276        let sel_json = serde_json::to_string(&selector).unwrap_or_default();
277        let inner = format!(
278            "(function() {{ \
279                var el = document.querySelector({sel_json}); \
280                if (!el) return null; \
281                return el.checked === true; \
282            }})()"
283        );
284        let js = frame_wrap_js(&active_frame, &inner);
285        let val = page
286            .evaluate_stealth(&js)
287            .await
288            .map_err(|e| format!("is_checked eval failed: {e}"))?;
289        match val.as_ref() {
290            Some(serde_json::Value::Bool(b)) => Ok(*b),
291            Some(serde_json::Value::Null) | None => Err(format!("Element not found: {selector}")),
292            _ => Err("Unexpected value from is_checked".to_string()),
293        }
294    }
295    .await;
296    let _ = reply.send(result);
297}
298
299async fn handle_is_visible(
300    state: &WorkerState,
301    selector: String,
302    reply: oneshot::Sender<Result<bool, String>>,
303) {
304    let result = async {
305        let active_frame = state.tabs[state.active_tab].active_frame.clone();
306        let page = &state.tabs[state.active_tab].page;
307        let sel_json = serde_json::to_string(&selector).unwrap_or_default();
308        let inner = format!(
309            "(function() {{ \
310                var el = document.querySelector({sel_json}); \
311                if (!el) return null; \
312                var r = el.getBoundingClientRect(); \
313                var style = window.getComputedStyle(el); \
314                return r.width > 0 && r.height > 0 \
315                    && style.visibility !== 'hidden' \
316                    && style.display !== 'none' \
317                    && parseFloat(style.opacity) > 0; \
318            }})()"
319        );
320        let js = frame_wrap_js(&active_frame, &inner);
321        let val = page
322            .evaluate_stealth(&js)
323            .await
324            .map_err(|e| format!("is_visible eval failed: {e}"))?;
325        match val.as_ref() {
326            Some(serde_json::Value::Bool(b)) => Ok(*b),
327            Some(serde_json::Value::Null) | None => Err(format!("Element not found: {selector}")),
328            _ => Err("Unexpected value from is_visible".to_string()),
329        }
330    }
331    .await;
332    let _ = reply.send(result);
333}
334
335async fn handle_is_enabled(
336    state: &WorkerState,
337    selector: String,
338    reply: oneshot::Sender<Result<bool, String>>,
339) {
340    let result = async {
341        let active_frame = state.tabs[state.active_tab].active_frame.clone();
342        let page = &state.tabs[state.active_tab].page;
343        let sel_json = serde_json::to_string(&selector).unwrap_or_default();
344        let inner = format!(
345            "(function() {{ \
346                var el = document.querySelector({sel_json}); \
347                if (!el) return null; \
348                return !el.disabled && el.getAttribute('aria-disabled') !== 'true'; \
349            }})()"
350        );
351        let js = frame_wrap_js(&active_frame, &inner);
352        let val = page
353            .evaluate_stealth(&js)
354            .await
355            .map_err(|e| format!("is_enabled eval failed: {e}"))?;
356        match val.as_ref() {
357            Some(serde_json::Value::Bool(b)) => Ok(*b),
358            Some(serde_json::Value::Null) | None => Err(format!("Element not found: {selector}")),
359            _ => Err("Unexpected value from is_enabled".to_string()),
360        }
361    }
362    .await;
363    let _ = reply.send(result);
364}
365
366// ---------------------------------------------------------------------------
367// Session API
368// ---------------------------------------------------------------------------
369
370impl BrowserSession {
371    /// Return the `innerText`, retrying on `ElementNotFound` until `timeout` expires.
372    pub async fn get_text_with_timeout(
373        &self,
374        selector: &str,
375        timeout: std::time::Duration,
376    ) -> Result<String, NightFuryError> {
377        let selector = selector.to_string();
378        self.retry_on_not_found(timeout, || self.get_text_inner(&selector))
379            .await
380    }
381
382    /// Return the `innerText` of an element identified by CSS selector.
383    pub async fn get_text(&self, selector: &str) -> Result<String, NightFuryError> {
384        if let Some(timeout) = self.auto_wait() {
385            return self.get_text_with_timeout(selector, timeout).await;
386        }
387        self.get_text_inner(selector).await
388    }
389
390    async fn get_text_inner(&self, selector: &str) -> Result<String, NightFuryError> {
391        send_cmd!(
392            self,
393            |tx| crate::cmd::BrowserCmd::Query(QueryCmd::GetText {
394                selector: selector.to_string(),
395                reply: tx,
396            }),
397            NightFuryError::ElementNotFound
398        )
399    }
400
401    /// Return the `.value` property, retrying on `ElementNotFound` until `timeout` expires.
402    pub async fn get_value_with_timeout(
403        &self,
404        selector: &str,
405        timeout: std::time::Duration,
406    ) -> Result<String, NightFuryError> {
407        let selector = selector.to_string();
408        self.retry_on_not_found(timeout, || self.get_value_inner(&selector))
409            .await
410    }
411
412    /// Return the `.value` property of an input/textarea/select element.
413    pub async fn get_value(&self, selector: &str) -> Result<String, NightFuryError> {
414        if let Some(timeout) = self.auto_wait() {
415            return self.get_value_with_timeout(selector, timeout).await;
416        }
417        self.get_value_inner(selector).await
418    }
419
420    async fn get_value_inner(&self, selector: &str) -> Result<String, NightFuryError> {
421        send_cmd!(
422            self,
423            |tx| crate::cmd::BrowserCmd::Query(QueryCmd::GetValue {
424                selector: selector.to_string(),
425                reply: tx,
426            }),
427            NightFuryError::ElementNotFound
428        )
429    }
430
431    /// Return an attribute value, retrying on `ElementNotFound` until `timeout` expires.
432    pub async fn get_attribute_with_timeout(
433        &self,
434        selector: &str,
435        attribute: &str,
436        timeout: std::time::Duration,
437    ) -> Result<String, NightFuryError> {
438        let selector = selector.to_string();
439        let attribute = attribute.to_string();
440        self.retry_on_not_found(timeout, || self.get_attribute_inner(&selector, &attribute))
441            .await
442    }
443
444    /// Return the value of a DOM attribute from an element identified by CSS selector.
445    pub async fn get_attribute(
446        &self,
447        selector: &str,
448        attribute: &str,
449    ) -> Result<String, NightFuryError> {
450        if let Some(timeout) = self.auto_wait() {
451            return self
452                .get_attribute_with_timeout(selector, attribute, timeout)
453                .await;
454        }
455        self.get_attribute_inner(selector, attribute).await
456    }
457
458    async fn get_attribute_inner(
459        &self,
460        selector: &str,
461        attribute: &str,
462    ) -> Result<String, NightFuryError> {
463        send_cmd!(
464            self,
465            |tx| crate::cmd::BrowserCmd::Query(QueryCmd::GetAttribute {
466                selector: selector.to_string(),
467                attribute: attribute.to_string(),
468                reply: tx,
469            }),
470            NightFuryError::ElementNotFound
471        )
472    }
473
474    /// Return the `innerHTML`, retrying on `ElementNotFound` until `timeout` expires.
475    pub async fn get_html_with_timeout(
476        &self,
477        selector: &str,
478        timeout: std::time::Duration,
479    ) -> Result<String, NightFuryError> {
480        let selector = selector.to_string();
481        self.retry_on_not_found(timeout, || self.get_html_inner(&selector))
482            .await
483    }
484
485    /// Return the `innerHTML` of an element.
486    pub async fn get_html(&self, selector: &str) -> Result<String, NightFuryError> {
487        if let Some(timeout) = self.auto_wait() {
488            return self.get_html_with_timeout(selector, timeout).await;
489        }
490        self.get_html_inner(selector).await
491    }
492
493    async fn get_html_inner(&self, selector: &str) -> Result<String, NightFuryError> {
494        send_cmd!(
495            self,
496            |tx| crate::cmd::BrowserCmd::Query(QueryCmd::GetHtml {
497                selector: selector.to_string(),
498                reply: tx,
499            }),
500            NightFuryError::ElementNotFound
501        )
502    }
503
504    /// Return the number of DOM elements matching a CSS selector.
505    pub async fn get_count(&self, selector: &str) -> Result<usize, NightFuryError> {
506        send_cmd!(
507            self,
508            |tx| crate::cmd::BrowserCmd::Query(QueryCmd::GetCount {
509                selector: selector.to_string(),
510                reply: tx,
511            }),
512            NightFuryError::OperationFailed
513        )
514    }
515
516    /// Return the bounding box, retrying on `ElementNotFound` until `timeout` expires.
517    pub async fn get_box_with_timeout(
518        &self,
519        selector: &str,
520        timeout: std::time::Duration,
521    ) -> Result<Value, NightFuryError> {
522        let selector = selector.to_string();
523        self.retry_on_not_found(timeout, || self.get_box_inner(&selector))
524            .await
525    }
526
527    /// Return the bounding box `{x, y, width, height}` of an element.
528    pub async fn get_box(&self, selector: &str) -> Result<Value, NightFuryError> {
529        if let Some(timeout) = self.auto_wait() {
530            return self.get_box_with_timeout(selector, timeout).await;
531        }
532        self.get_box_inner(selector).await
533    }
534
535    async fn get_box_inner(&self, selector: &str) -> Result<Value, NightFuryError> {
536        send_cmd!(
537            self,
538            |tx| crate::cmd::BrowserCmd::Query(QueryCmd::GetBox {
539                selector: selector.to_string(),
540                reply: tx,
541            }),
542            NightFuryError::ElementNotFound
543        )
544    }
545
546    /// Check if element is checked, retrying on `ElementNotFound` until `timeout` expires.
547    pub async fn is_checked_with_timeout(
548        &self,
549        selector: &str,
550        timeout: std::time::Duration,
551    ) -> Result<bool, NightFuryError> {
552        let selector = selector.to_string();
553        self.retry_on_not_found(timeout, || self.is_checked_inner(&selector))
554            .await
555    }
556
557    /// Return `true` if the checkbox/radio is checked, `false` if unchecked.
558    /// Returns `ElementNotFound` if the selector matches nothing.
559    pub async fn is_checked(&self, selector: &str) -> Result<bool, NightFuryError> {
560        if let Some(timeout) = self.auto_wait() {
561            return self.is_checked_with_timeout(selector, timeout).await;
562        }
563        self.is_checked_inner(selector).await
564    }
565
566    async fn is_checked_inner(&self, selector: &str) -> Result<bool, NightFuryError> {
567        send_cmd!(
568            self,
569            |tx| crate::cmd::BrowserCmd::Query(QueryCmd::IsChecked {
570                selector: selector.to_string(),
571                reply: tx,
572            }),
573            NightFuryError::ElementNotFound
574        )
575    }
576
577    /// Check if element is visible, retrying on `ElementNotFound` until `timeout` expires.
578    pub async fn is_visible_with_timeout(
579        &self,
580        selector: &str,
581        timeout: std::time::Duration,
582    ) -> Result<bool, NightFuryError> {
583        let selector = selector.to_string();
584        self.retry_on_not_found(timeout, || self.is_visible_inner(&selector))
585            .await
586    }
587
588    /// Return `true` if the element is visible (non-zero size, not hidden).
589    /// Returns `ElementNotFound` if the selector matches nothing.
590    pub async fn is_visible(&self, selector: &str) -> Result<bool, NightFuryError> {
591        if let Some(timeout) = self.auto_wait() {
592            return self.is_visible_with_timeout(selector, timeout).await;
593        }
594        self.is_visible_inner(selector).await
595    }
596
597    async fn is_visible_inner(&self, selector: &str) -> Result<bool, NightFuryError> {
598        send_cmd!(
599            self,
600            |tx| crate::cmd::BrowserCmd::Query(QueryCmd::IsVisible {
601                selector: selector.to_string(),
602                reply: tx,
603            }),
604            NightFuryError::ElementNotFound
605        )
606    }
607
608    /// Check if element is enabled, retrying on `ElementNotFound` until `timeout` expires.
609    pub async fn is_enabled_with_timeout(
610        &self,
611        selector: &str,
612        timeout: std::time::Duration,
613    ) -> Result<bool, NightFuryError> {
614        let selector = selector.to_string();
615        self.retry_on_not_found(timeout, || self.is_enabled_inner(&selector))
616            .await
617    }
618
619    /// Return `true` if the element is not disabled.
620    /// Returns `ElementNotFound` if the selector matches nothing.
621    pub async fn is_enabled(&self, selector: &str) -> Result<bool, NightFuryError> {
622        if let Some(timeout) = self.auto_wait() {
623            return self.is_enabled_with_timeout(selector, timeout).await;
624        }
625        self.is_enabled_inner(selector).await
626    }
627
628    async fn is_enabled_inner(&self, selector: &str) -> Result<bool, NightFuryError> {
629        send_cmd!(
630            self,
631            |tx| crate::cmd::BrowserCmd::Query(QueryCmd::IsEnabled {
632                selector: selector.to_string(),
633                reply: tx,
634            }),
635            NightFuryError::ElementNotFound
636        )
637    }
638
639    /// Wait until a CSS selector matches an element in the DOM.
640    ///
641    /// Polls every 100 ms. Returns `ElementNotFound` on timeout.
642    pub async fn wait_for_selector(
643        &self,
644        selector: &str,
645        timeout_ms: u64,
646    ) -> Result<String, NightFuryError> {
647        send_cmd!(
648            self,
649            |tx| crate::cmd::BrowserCmd::Query(QueryCmd::WaitForSelector {
650                selector: selector.to_string(),
651                timeout_ms,
652                reply: tx,
653            }),
654            NightFuryError::ElementNotFound
655        )
656    }
657}