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}