Skip to main content

eoka_server/
browser.rs

1use crate::{annotate, observe, snapshot, spa};
2
3pub use crate::spa::{RouterType, SpaRouterInfo};
4pub use crate::target::{BBox, LivePattern, Resolved, Target};
5
6use std::collections::HashSet;
7use std::fmt;
8
9use crate::eoka::{BoundingBox, MouseButton, Page, Result};
10
11pub use crate::eoka::{Browser, Error, StealthConfig};
12
13#[derive(Debug, Clone)]
14pub struct InteractiveElement {
15    pub index: usize,
16    pub tag: String,
17    pub role: Option<String>,
18    pub text: String,
19    pub placeholder: Option<String>,
20    pub input_type: Option<String>,
21    pub selector: String,
22    pub checked: bool,
23    pub disabled: bool,
24    pub visible: bool,
25    pub value: Option<String>,
26    pub bbox: BoundingBox,
27    pub fingerprint: u64,
28}
29
30impl InteractiveElement {
31    pub fn compute_fingerprint(
32        tag: &str,
33        text: &str,
34        role: Option<&str>,
35        input_type: Option<&str>,
36        placeholder: Option<&str>,
37        selector: &str,
38    ) -> u64 {
39        use std::collections::hash_map::DefaultHasher;
40        use std::hash::{Hash, Hasher};
41        let mut hasher = DefaultHasher::new();
42        tag.hash(&mut hasher);
43        text.hash(&mut hasher);
44        role.hash(&mut hasher);
45        input_type.hash(&mut hasher);
46        placeholder.hash(&mut hasher);
47        selector.hash(&mut hasher);
48        hasher.finish()
49    }
50}
51
52impl fmt::Display for InteractiveElement {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        write!(f, "[{}] <{}", self.index, self.tag)?;
55        if let Some(ref t) = self.input_type {
56            if t != "text" {
57                write!(f, " type=\"{}\"", t)?;
58            }
59        }
60        f.write_str(">")?;
61        if self.checked {
62            f.write_str(" [checked]")?;
63        }
64        if !self.text.is_empty() {
65            write!(f, " \"{}\"", self.text)?;
66        }
67        if let Some(ref v) = self.value {
68            write!(f, " value=\"{}\"", v)?;
69        }
70        if let Some(ref p) = self.placeholder {
71            write!(f, " placeholder=\"{}\"", p)?;
72        }
73        if let Some(ref r) = self.role {
74            let redundant = (r == "button" && self.tag == "button")
75                || (r == "link" && self.tag == "a")
76                || (r == "menuitem" && self.tag == "a");
77            if !redundant {
78                write!(f, " role=\"{}\"", r)?;
79            }
80        }
81        Ok(())
82    }
83}
84
85#[derive(Debug, Clone)]
86pub struct ObserveConfig {
87    pub viewport_only: bool,
88}
89
90impl Default for ObserveConfig {
91    fn default() -> Self {
92        Self {
93            viewport_only: true,
94        }
95    }
96}
97
98#[derive(Debug)]
99pub struct ObserveDiff {
100    pub added: Vec<usize>,
101    pub removed: usize,
102    pub total: usize,
103}
104
105impl fmt::Display for ObserveDiff {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        if self.added.is_empty() && self.removed == 0 {
108            write!(f, "no changes ({} elements)", self.total)
109        } else {
110            let mut need_sep = false;
111            if !self.added.is_empty() {
112                write!(f, "+{} added", self.added.len())?;
113                need_sep = true;
114            }
115            if self.removed > 0 {
116                if need_sep {
117                    write!(f, ", ")?;
118                }
119                write!(f, "-{} removed", self.removed)?;
120            }
121            write!(f, " ({} total)", self.total)
122        }
123    }
124}
125
126pub struct Session {
127    operations: crate::operations::OperationManager,
128    action_options: eoka::ActionOptions,
129    browser: Browser,
130    page: Page,
131    elements: Vec<InteractiveElement>,
132    config: ObserveConfig,
133}
134
135impl Session {
136    pub async fn launch() -> Result<Self> {
137        let browser = Browser::launch().await?;
138        let page = browser.new_page("about:blank").await?;
139        Ok(Self {
140            operations: Default::default(),
141            action_options: Default::default(),
142            browser,
143            page,
144            elements: Vec::new(),
145            config: ObserveConfig::default(),
146        })
147    }
148
149    pub async fn launch_with_config(stealth: StealthConfig) -> Result<Self> {
150        let browser = Browser::launch_with_config(stealth).await?;
151        let page = browser.new_page("about:blank").await?;
152        Ok(Self {
153            operations: Default::default(),
154            action_options: Default::default(),
155            browser,
156            page,
157            elements: Vec::new(),
158            config: ObserveConfig::default(),
159        })
160    }
161
162    pub fn set_action_options(&mut self, options: eoka::ActionOptions) {
163        self.action_options = options;
164    }
165    pub fn operations(&self) -> &crate::operations::OperationManager {
166        &self.operations
167    }
168    pub async fn perform(
169        &self,
170        request: eoka_protocol::ActionPerformArgs,
171    ) -> anyhow::Result<eoka_protocol::OperationRecord> {
172        self.operations
173            .submit(
174                self.page.clone(),
175                request.operation_id,
176                request.action,
177                request.options,
178            )
179            .await
180    }
181    async fn perform_and_wait(&self, action: eoka::Action) -> Result<()> {
182        self.perform_with_options(action, self.action_options.clone())
183            .await
184    }
185    async fn perform_with_options(
186        &self,
187        action: eoka::Action,
188        options: eoka::ActionOptions,
189    ) -> Result<()> {
190        let id = uuid::Uuid::new_v4().to_string();
191        let record = self
192            .operations
193            .submit(self.page.clone(), id.clone(), action, options)
194            .await
195            .map_err(|e| Error::transport(format!("Operation {id}: {e}")))?;
196        let record = if matches!(record.state, eoka_protocol::OperationState::Running) {
197            self.operations
198                .wait(&id, 86_400_000)
199                .await
200                .map_err(|e| Error::transport(format!("Operation {id}: {e}")))?
201        } else {
202            record
203        };
204        if record.outcome == eoka::ActionOutcome::VerifiedComplete {
205            return Ok(());
206        }
207        if let Some(receipt) = record.receipt {
208            return Err(Error::Action {
209                code: record.error_code.unwrap_or_else(|| "action_failed".into()),
210                receipt: Box::new(receipt),
211                source: Box::new(Error::transport(format!("Inspect operation {id}"))),
212            });
213        }
214        Err(Error::transport(format!(
215            "Operation {id}: {:?}",
216            record.outcome
217        )))
218    }
219
220    pub fn set_observe_config(&mut self, config: ObserveConfig) {
221        self.config = config;
222    }
223
224    pub fn page(&self) -> &Page {
225        &self.page
226    }
227
228    pub fn browser(&self) -> &Browser {
229        &self.browser
230    }
231
232    pub async fn ax_snapshot(&self, include_all: bool) -> anyhow::Result<snapshot::SnapshotResult> {
233        snapshot::snapshot(&self.page, include_all).await
234    }
235
236    pub async fn observe(&mut self) -> Result<&[InteractiveElement]> {
237        self.elements = observe::observe(&self.page, self.config.viewport_only).await?;
238        Ok(&self.elements)
239    }
240
241    pub async fn screenshot(&mut self) -> Result<Vec<u8>> {
242        if self.elements.is_empty() {
243            self.observe().await?;
244        }
245        annotate::annotated_screenshot(&self.page, &self.elements).await
246    }
247
248    pub fn element_list(&self) -> String {
249        let mut out = String::with_capacity(self.elements.len() * 40);
250        for el in &self.elements {
251            out.push_str(&el.to_string());
252            out.push('\n');
253        }
254        out
255    }
256
257    pub fn get(&self, index: usize) -> Option<&InteractiveElement> {
258        self.elements.get(index)
259    }
260
261    pub fn elements(&self) -> &[InteractiveElement] {
262        &self.elements
263    }
264
265    pub fn len(&self) -> usize {
266        self.elements.len()
267    }
268
269    pub fn is_empty(&self) -> bool {
270        self.elements.is_empty()
271    }
272
273    pub fn find_by_text(&self, needle: &str) -> Option<usize> {
274        let needle_lower = needle.to_lowercase();
275        self.elements
276            .iter()
277            .find(|e| e.text.to_lowercase().contains(&needle_lower))
278            .map(|e| e.index)
279    }
280
281    pub fn find_all_by_text(&self, needle: &str) -> Vec<usize> {
282        let needle_lower = needle.to_lowercase();
283        self.elements
284            .iter()
285            .filter(|e| e.text.to_lowercase().contains(&needle_lower))
286            .map(|e| e.index)
287            .collect()
288    }
289
290    pub async fn observe_diff(&mut self) -> Result<ObserveDiff> {
291        let old_selectors: HashSet<String> =
292            self.elements.iter().map(|e| e.selector.clone()).collect();
293
294        self.elements = observe::observe(&self.page, self.config.viewport_only).await?;
295
296        let new_selectors: HashSet<&str> =
297            self.elements.iter().map(|e| e.selector.as_str()).collect();
298
299        let added: Vec<usize> = self
300            .elements
301            .iter()
302            .filter(|e| !old_selectors.contains(&e.selector))
303            .map(|e| e.index)
304            .collect();
305
306        let removed = old_selectors
307            .iter()
308            .filter(|s| !new_selectors.contains(s.as_str()))
309            .count();
310
311        Ok(ObserveDiff {
312            added,
313            removed,
314            total: self.elements.len(),
315        })
316    }
317
318    pub fn added_element_list(&self, diff: &ObserveDiff) -> String {
319        let mut out = String::new();
320        for &idx in &diff.added {
321            if let Some(el) = self.elements.get(idx) {
322                out.push_str(&el.to_string());
323                out.push('\n');
324            }
325        }
326        out
327    }
328
329    pub async fn screenshot_plain(&self) -> Result<Vec<u8>> {
330        self.page.screenshot().await
331    }
332
333    async fn require_fresh(&mut self, index: usize) -> Result<&InteractiveElement> {
334        let stored = self.elements.get(index).cloned();
335
336        if let Some(ref el) = stored {
337            let js = format!(
338                "!!document.querySelector({})",
339                serde_json::to_string(&el.selector).unwrap()
340            );
341            let exists: bool = self.page.evaluate(&js).await.unwrap_or(false);
342
343            if exists {
344                return self.elements.get(index).ok_or_else(|| {
345                    crate::eoka::Error::ElementNotFound(format!("element [{}] disappeared", index))
346                });
347            }
348
349            self.observe().await?;
350
351            if let Some(new_idx) = self
352                .elements
353                .iter()
354                .position(|e| e.fingerprint == el.fingerprint)
355            {
356                return Err(crate::eoka::Error::ElementNotFound(format!(
357                    "element [{}] \"{}\" moved to [{}] - call observe() to refresh",
358                    index, el.text, new_idx
359                )));
360            }
361
362            return Err(crate::eoka::Error::ElementNotFound(format!(
363                "element [{}] \"{}\" no longer exists on page",
364                index, el.text
365            )));
366        }
367
368        Err(crate::eoka::Error::ElementNotFound(format!(
369            "element [{}] not found (observed {} elements)",
370            index,
371            self.elements.len()
372        )))
373    }
374
375    pub async fn click(&mut self, index: usize) -> Result<()> {
376        let selector = self.require_fresh(index).await?.selector.clone();
377        self.perform_and_wait(eoka::Action::Click {
378            target: eoka::LocatorSpec::css(selector),
379            human: false,
380            double: false,
381        })
382        .await
383    }
384
385    pub async fn fill(&mut self, index: usize, text: &str) -> Result<()> {
386        let selector = self.require_fresh(index).await?.selector.clone();
387        self.perform_and_wait(eoka::Action::Fill {
388            target: eoka::LocatorSpec::css(selector),
389            value: text.into(),
390            human: false,
391        })
392        .await
393    }
394
395    pub async fn select(&mut self, index: usize, value: &str) -> Result<()> {
396        let selector = self.require_fresh(index).await?.selector.clone();
397        self.perform_and_wait(eoka::Action::SelectOption {
398            target: eoka::LocatorSpec::css(selector),
399            value: value.into(),
400        })
401        .await
402    }
403
404    pub async fn hover(&mut self, index: usize) -> Result<()> {
405        let selector = self.require_fresh(index).await?.selector.clone();
406        self.perform_and_wait(eoka::Action::Hover {
407            target: eoka::LocatorSpec::css(selector),
408            human: false,
409        })
410        .await
411    }
412
413    pub async fn scroll_to(&mut self, index: usize) -> Result<()> {
414        let selector = self.require_fresh(index).await?.selector.clone();
415        self.perform_and_wait(eoka::Action::ScrollIntoView {
416            target: eoka::LocatorSpec::css(selector),
417        })
418        .await
419    }
420
421    pub async fn try_click(&mut self, index: usize) -> Result<bool> {
422        let selector = self.require_fresh(index).await?.selector.clone();
423        let mut options = self.action_options.clone();
424        options.wait_for_target = false;
425        match self
426            .perform_with_options(
427                eoka::Action::Click {
428                    target: eoka::LocatorSpec::css(selector),
429                    human: false,
430                    double: false,
431                },
432                options,
433            )
434            .await
435        {
436            Ok(()) => Ok(true),
437            Err(Error::Action { code, receipt, .. })
438                if code == "target_unavailable"
439                    && receipt.outcome == eoka::ActionOutcome::NotDispatched =>
440            {
441                Ok(false)
442            }
443            Err(error) => Err(error),
444        }
445    }
446    pub async fn human_click(&mut self, index: usize) -> Result<()> {
447        let selector = self.require_fresh(index).await?.selector.clone();
448        self.perform_and_wait(eoka::Action::Click {
449            target: eoka::LocatorSpec::css(selector),
450            human: true,
451            double: false,
452        })
453        .await
454    }
455    pub async fn human_fill(&mut self, index: usize, text: &str) -> Result<()> {
456        let selector = self.require_fresh(index).await?.selector.clone();
457        self.perform_and_wait(eoka::Action::Fill {
458            target: eoka::LocatorSpec::css(selector),
459            value: text.into(),
460            human: true,
461        })
462        .await
463    }
464    pub async fn focus(&mut self, index: usize) -> Result<()> {
465        let selector = self.require_fresh(index).await?.selector.clone();
466        self.perform_and_wait(eoka::Action::Focus {
467            target: eoka::LocatorSpec::css(selector),
468        })
469        .await
470    }
471    pub async fn submit(&mut self, index: usize) -> Result<()> {
472        let selector = self.require_fresh(index).await?.selector.clone();
473        self.perform_and_wait(eoka::Action::Submit {
474            target: eoka::LocatorSpec::css(selector),
475        })
476        .await
477    }
478
479    pub async fn options(&mut self, index: usize) -> Result<Vec<(String, String)>> {
480        let el = self.require_fresh(index).await?;
481        let selector = el.selector.clone();
482        let js = format!(
483            r#"(() => {{
484                const sel = document.querySelector({});
485                if (!sel || !sel.options) return '[]';
486                return JSON.stringify(Array.from(sel.options).map(o => [o.value, o.text]));
487            }})()"#,
488            serde_json::to_string(&selector).unwrap()
489        );
490        let json_str: String = self.page.evaluate(&js).await?;
491        let pairs: Vec<(String, String)> = serde_json::from_str(&json_str)
492            .map_err(|e| crate::eoka::Error::cdp_msg(format!("options parse error: {}", e)))?;
493        Ok(pairs)
494    }
495
496    pub async fn goto(&mut self, url: &str) -> Result<()> {
497        self.perform_and_wait(eoka::Action::Navigate {
498            url: url.into(),
499            referrer: None,
500        })
501        .await
502    }
503
504    pub async fn back(&mut self) -> Result<()> {
505        self.perform_and_wait(eoka::Action::Back).await
506    }
507
508    pub async fn forward(&mut self) -> Result<()> {
509        self.perform_and_wait(eoka::Action::Forward).await
510    }
511
512    pub async fn reload(&mut self) -> Result<()> {
513        self.perform_and_wait(eoka::Action::Reload).await
514    }
515
516    pub async fn url(&self) -> Result<String> {
517        self.page.url().await
518    }
519
520    pub async fn title(&self) -> Result<String> {
521        self.page.title().await
522    }
523
524    pub async fn text(&self) -> Result<String> {
525        self.page.text().await
526    }
527
528    pub async fn scroll_down(&self) -> Result<()> {
529        self.page
530            .execute("window.scrollBy(0, window.innerHeight * 0.8)")
531            .await
532    }
533
534    pub async fn scroll_up(&self) -> Result<()> {
535        self.page
536            .execute("window.scrollBy(0, -window.innerHeight * 0.8)")
537            .await
538    }
539
540    pub async fn scroll_to_top(&self) -> Result<()> {
541        self.page.execute("window.scrollTo(0, 0)").await
542    }
543
544    pub async fn scroll_to_bottom(&self) -> Result<()> {
545        self.page
546            .execute("window.scrollTo(0, document.body.scrollHeight)")
547            .await
548    }
549
550    pub async fn wait_for_stable(&self) -> Result<()> {
551        let _ = self.page.wait_for_network_idle(200, 2000).await;
552        self.page.wait(50).await;
553        Ok(())
554    }
555
556    pub async fn wait(&self, ms: u64) {
557        self.page.wait(ms).await;
558    }
559
560    pub async fn wait_for_text(&self, text: &str, timeout_ms: u64) -> Result<()> {
561        self.page.wait_for_text(text, timeout_ms).await?;
562        Ok(())
563    }
564
565    pub async fn wait_for_url(&self, pattern: &str, timeout_ms: u64) -> Result<()> {
566        self.page.wait_for_url_contains(pattern, timeout_ms).await
567    }
568
569    pub async fn wait_for_idle(&self, timeout_ms: u64) -> Result<()> {
570        self.page.wait_for_network_idle(500, timeout_ms).await
571    }
572
573    pub async fn press_key(&self, key: &str) -> Result<()> {
574        self.perform_and_wait(eoka::Action::PressKey { key: key.into() })
575            .await
576    }
577
578    pub async fn mouse_down(&self, x: f64, y: f64, button: MouseButton) -> Result<()> {
579        self.page.mouse_down(x, y, button).await
580    }
581
582    pub async fn mouse_move(&self, x: f64, y: f64) -> Result<()> {
583        self.page.mouse_move(x, y).await
584    }
585
586    pub async fn mouse_up(&self, x: f64, y: f64, button: MouseButton) -> Result<()> {
587        self.page.mouse_up(x, y, button).await
588    }
589
590    pub async fn key_down(&self, key: &str) -> Result<()> {
591        self.page.key_down(key).await
592    }
593
594    pub async fn key_up(&self, key: &str) -> Result<()> {
595        self.page.key_up(key).await
596    }
597
598    pub async fn release_all_inputs(&self) -> Result<()> {
599        self.page.release_all_inputs().await
600    }
601
602    pub async fn eval<T: serde::de::DeserializeOwned>(&self, js: &str) -> Result<T> {
603        self.page.evaluate(js).await
604    }
605
606    pub async fn exec(&self, js: &str) -> Result<()> {
607        self.page.execute(js).await
608    }
609
610    pub async fn extract<T: serde::de::DeserializeOwned>(&self, js_expression: &str) -> Result<T> {
611        let escaped_js = serde_json::to_string(js_expression)
612            .map_err(|e| crate::eoka::Error::cdp_msg(format!("Failed to escape JS: {}", e)))?;
613        let js = format!("JSON.stringify(eval({}))", escaped_js);
614        let json_str: String = self.page.evaluate(&js).await?;
615        if json_str == "null" || json_str == "undefined" || json_str.is_empty() {
616            return Err(crate::eoka::Error::cdp_msg(format!(
617                "extract returned null/undefined for: {}",
618                if js_expression.len() > 60 {
619                    &js_expression[..60]
620                } else {
621                    js_expression
622                }
623            )));
624        }
625        serde_json::from_str(&json_str).map_err(|e| {
626            crate::eoka::Error::cdp_msg(format!(
627                "extract parse error: {} (got: {})",
628                e,
629                if json_str.len() > 80 {
630                    &json_str[..80]
631                } else {
632                    &json_str
633                }
634            ))
635        })
636    }
637
638    pub async fn spa_info(&self) -> Result<SpaRouterInfo> {
639        spa::detect_router(&self.page).await
640    }
641
642    pub async fn spa_navigate(&mut self, path: &str) -> Result<String> {
643        let info = spa::detect_router(&self.page).await?;
644        let result = spa::spa_navigate(&self.page, &info.router_type, path).await?;
645        self.elements.clear();
646        Ok(result)
647    }
648
649    pub async fn history_go(&mut self, delta: i32) -> Result<()> {
650        self.perform_and_wait(eoka::Action::History { delta })
651            .await?;
652        self.elements.clear();
653        Ok(())
654    }
655
656    pub async fn close(self) -> Result<()> {
657        self.operations.stop().await;
658        let release_result = self.page.release_all_inputs().await;
659        self.browser.close().await?;
660        release_result
661    }
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667
668    #[allow(clippy::too_many_arguments)]
669    fn make_element(
670        index: usize,
671        tag: &str,
672        text: &str,
673        role: Option<&str>,
674        input_type: Option<&str>,
675        placeholder: Option<&str>,
676        value: Option<&str>,
677        checked: bool,
678    ) -> InteractiveElement {
679        let selector = format!("[data-idx=\"{}\"]", index);
680        let fingerprint = InteractiveElement::compute_fingerprint(
681            tag,
682            text,
683            role,
684            input_type,
685            placeholder,
686            &selector,
687        );
688        InteractiveElement {
689            index,
690            tag: tag.to_string(),
691            text: text.to_string(),
692            role: role.map(|s| s.to_string()),
693            input_type: input_type.map(|s| s.to_string()),
694            placeholder: placeholder.map(|s| s.to_string()),
695            value: value.map(|s| s.to_string()),
696            checked,
697            selector,
698            disabled: false,
699            visible: true,
700            bbox: BoundingBox {
701                x: 0.0,
702                y: 0.0,
703                width: 100.0,
704                height: 30.0,
705            },
706            fingerprint,
707        }
708    }
709
710    #[test]
711    fn test_element_display_basic() {
712        let el = make_element(0, "button", "Submit", None, None, None, None, false);
713        assert_eq!(el.to_string(), "[0] <button> \"Submit\"");
714    }
715
716    #[test]
717    fn test_element_display_with_input_type() {
718        let el = make_element(0, "input", "", None, Some("text"), None, None, false);
719        assert_eq!(el.to_string(), "[0] <input>");
720
721        let el = make_element(0, "input", "", None, Some("password"), None, None, false);
722        assert_eq!(el.to_string(), "[0] <input type=\"password\">");
723    }
724
725    #[test]
726    fn test_element_display_with_placeholder() {
727        let el = make_element(
728            0,
729            "input",
730            "",
731            None,
732            Some("text"),
733            Some("Enter email"),
734            None,
735            false,
736        );
737        assert_eq!(el.to_string(), "[0] <input> placeholder=\"Enter email\"");
738    }
739
740    #[test]
741    fn test_element_display_with_value() {
742        let el = make_element(
743            0,
744            "input",
745            "",
746            None,
747            Some("text"),
748            None,
749            Some("hello"),
750            false,
751        );
752        assert_eq!(el.to_string(), "[0] <input> value=\"hello\"");
753    }
754
755    #[test]
756    fn test_element_display_checked() {
757        let el = make_element(0, "input", "", None, Some("checkbox"), None, None, true);
758        assert_eq!(el.to_string(), "[0] <input type=\"checkbox\"> [checked]");
759    }
760
761    #[test]
762    fn test_element_display_redundant_role_suppressed() {
763        let el = make_element(
764            0,
765            "button",
766            "Click",
767            Some("button"),
768            None,
769            None,
770            None,
771            false,
772        );
773        assert_eq!(el.to_string(), "[0] <button> \"Click\"");
774
775        let el = make_element(0, "a", "Link", Some("link"), None, None, None, false);
776        assert_eq!(el.to_string(), "[0] <a> \"Link\"");
777
778        let el = make_element(0, "a", "Menu", Some("menuitem"), None, None, None, false);
779        assert_eq!(el.to_string(), "[0] <a> \"Menu\"");
780    }
781
782    #[test]
783    fn test_element_display_non_redundant_role_shown() {
784        let el = make_element(0, "button", "Tab 1", Some("tab"), None, None, None, false);
785        assert_eq!(el.to_string(), "[0] <button> \"Tab 1\" role=\"tab\"");
786
787        let el = make_element(0, "div", "Click", Some("button"), None, None, None, false);
788        assert_eq!(el.to_string(), "[0] <div> \"Click\" role=\"button\"");
789    }
790
791    #[test]
792    fn test_observe_diff_display_no_changes() {
793        let diff = ObserveDiff {
794            added: vec![],
795            removed: 0,
796            total: 5,
797        };
798        assert_eq!(diff.to_string(), "no changes (5 elements)");
799    }
800
801    #[test]
802    fn test_observe_diff_display_added_only() {
803        let diff = ObserveDiff {
804            added: vec![5, 6],
805            removed: 0,
806            total: 7,
807        };
808        assert_eq!(diff.to_string(), "+2 added (7 total)");
809    }
810
811    #[test]
812    fn test_observe_diff_display_removed_only() {
813        let diff = ObserveDiff {
814            added: vec![],
815            removed: 3,
816            total: 2,
817        };
818        assert_eq!(diff.to_string(), "-3 removed (2 total)");
819    }
820
821    #[test]
822    fn test_observe_diff_display_both() {
823        let diff = ObserveDiff {
824            added: vec![3, 4],
825            removed: 1,
826            total: 5,
827        };
828        assert_eq!(diff.to_string(), "+2 added, -1 removed (5 total)");
829    }
830
831    #[test]
832    fn test_observe_config_default() {
833        let config = ObserveConfig::default();
834        assert!(config.viewport_only);
835    }
836
837    #[test]
838    fn test_fingerprint_uses_full_selector() {
839        let base = "a".repeat(50);
840        let sel_a = format!("{}AAAA", base);
841        let sel_b = format!("{}BBBB", base);
842
843        let fp_a = InteractiveElement::compute_fingerprint("button", "X", None, None, None, &sel_a);
844        let fp_b = InteractiveElement::compute_fingerprint("button", "X", None, None, None, &sel_b);
845
846        assert_ne!(
847            fp_a, fp_b,
848            "selectors differing after char 50 should produce different fingerprints"
849        );
850    }
851}