Skip to main content

rustant_core/browser/
cdp.rs

1//! CDP (Chrome DevTools Protocol) client trait and mock implementation.
2//!
3//! The `CdpClient` trait abstracts all browser interactions, enabling
4//! mock-based testing without a real Chrome instance.
5
6use crate::error::BrowserError;
7use async_trait::async_trait;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use std::collections::HashMap;
11use std::sync::Mutex;
12
13/// Metadata about an open browser tab/page.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct TabInfo {
16    /// Unique tab identifier (Chrome target ID).
17    pub id: String,
18    /// Current URL of the tab.
19    pub url: String,
20    /// Current title of the tab.
21    pub title: String,
22    /// Whether this is the currently active tab.
23    pub active: bool,
24}
25
26/// Trait abstracting Chrome DevTools Protocol operations.
27///
28/// Implementors include `MockCdpClient` (for tests) and
29/// a real `ChromiumCdpClient` (wrapping chromiumoxide) for production.
30#[async_trait]
31pub trait CdpClient: Send + Sync {
32    /// Navigate to the given URL.
33    async fn navigate(&self, url: &str) -> Result<(), BrowserError>;
34
35    /// Go back in browser history.
36    async fn go_back(&self) -> Result<(), BrowserError>;
37
38    /// Go forward in browser history.
39    async fn go_forward(&self) -> Result<(), BrowserError>;
40
41    /// Refresh the current page.
42    async fn refresh(&self) -> Result<(), BrowserError>;
43
44    /// Click an element matching the CSS selector.
45    async fn click(&self, selector: &str) -> Result<(), BrowserError>;
46
47    /// Type text into the currently focused element.
48    async fn type_text(&self, selector: &str, text: &str) -> Result<(), BrowserError>;
49
50    /// Clear and fill a form field.
51    async fn fill(&self, selector: &str, value: &str) -> Result<(), BrowserError>;
52
53    /// Select an option in a `<select>` element.
54    async fn select_option(&self, selector: &str, value: &str) -> Result<(), BrowserError>;
55
56    /// Hover over an element.
57    async fn hover(&self, selector: &str) -> Result<(), BrowserError>;
58
59    /// Press a keyboard key.
60    async fn press_key(&self, key: &str) -> Result<(), BrowserError>;
61
62    /// Scroll by the given pixel offsets.
63    async fn scroll(&self, x: i32, y: i32) -> Result<(), BrowserError>;
64
65    /// Take a screenshot and return PNG bytes.
66    async fn screenshot(&self) -> Result<Vec<u8>, BrowserError>;
67
68    /// Get the full page HTML.
69    async fn get_html(&self) -> Result<String, BrowserError>;
70
71    /// Get the visible text content.
72    async fn get_text(&self) -> Result<String, BrowserError>;
73
74    /// Get the current page URL.
75    async fn get_url(&self) -> Result<String, BrowserError>;
76
77    /// Get the current page title.
78    async fn get_title(&self) -> Result<String, BrowserError>;
79
80    /// Evaluate a JavaScript expression and return the result.
81    async fn evaluate_js(&self, script: &str) -> Result<Value, BrowserError>;
82
83    /// Wait for an element matching the selector to appear.
84    async fn wait_for_selector(&self, selector: &str, timeout_ms: u64) -> Result<(), BrowserError>;
85
86    /// Get the accessibility / ARIA tree as a string.
87    async fn get_aria_tree(&self) -> Result<String, BrowserError>;
88
89    /// Upload a file to a file input element.
90    async fn upload_file(&self, selector: &str, path: &str) -> Result<(), BrowserError>;
91
92    /// Close the current page/tab.
93    async fn close(&self) -> Result<(), BrowserError>;
94
95    // --- Tab/page management methods ---
96
97    /// Open a new tab and navigate to the given URL. Returns the tab ID.
98    async fn new_tab(&self, url: &str) -> Result<String, BrowserError>;
99
100    /// List all open tabs with their metadata.
101    async fn list_tabs(&self) -> Result<Vec<TabInfo>, BrowserError>;
102
103    /// Switch the active tab to the one with the given ID.
104    async fn switch_tab(&self, tab_id: &str) -> Result<(), BrowserError>;
105
106    /// Close a specific tab by ID.
107    async fn close_tab(&self, tab_id: &str) -> Result<(), BrowserError>;
108
109    /// Get the ID of the currently active tab.
110    async fn active_tab_id(&self) -> Result<String, BrowserError>;
111}
112
113/// A mock CDP client for testing. Records all calls and returns configurable results.
114pub struct MockCdpClient {
115    /// Current URL (set by navigate).
116    pub current_url: Mutex<String>,
117    /// Current page title.
118    pub current_title: Mutex<String>,
119    /// HTML content to return from get_html().
120    pub html_content: Mutex<String>,
121    /// Text content to return from get_text().
122    pub text_content: Mutex<String>,
123    /// ARIA tree to return from get_aria_tree().
124    pub aria_tree: Mutex<String>,
125    /// Screenshot bytes to return.
126    pub screenshot_bytes: Mutex<Vec<u8>>,
127    /// JavaScript results keyed by script.
128    pub js_results: Mutex<HashMap<String, Value>>,
129    /// Record of all method calls for assertion: (method, args).
130    pub call_log: Mutex<Vec<(String, Vec<String>)>>,
131    /// If set, navigate will return this error.
132    pub navigate_error: Mutex<Option<BrowserError>>,
133    /// If set, click will return this error.
134    pub click_error: Mutex<Option<BrowserError>>,
135    /// If set, wait_for_selector will return this error.
136    pub wait_error: Mutex<Option<BrowserError>>,
137    /// Whether the client is "closed".
138    pub closed: Mutex<bool>,
139    /// Open tabs for tab management testing.
140    pub tabs: Mutex<Vec<TabInfo>>,
141    /// ID of the currently active tab.
142    pub active_tab: Mutex<String>,
143    /// Counter for generating unique tab IDs.
144    tab_counter: Mutex<u32>,
145}
146
147impl Default for MockCdpClient {
148    fn default() -> Self {
149        let default_tab_id = "tab-0".to_string();
150        Self {
151            current_url: Mutex::new("about:blank".to_string()),
152            current_title: Mutex::new(String::new()),
153            html_content: Mutex::new("<html><body></body></html>".to_string()),
154            text_content: Mutex::new(String::new()),
155            aria_tree: Mutex::new("document\n  body".to_string()),
156            screenshot_bytes: Mutex::new(vec![0x89, 0x50, 0x4E, 0x47]), // PNG magic bytes
157            js_results: Mutex::new(HashMap::new()),
158            call_log: Mutex::new(Vec::new()),
159            navigate_error: Mutex::new(None),
160            click_error: Mutex::new(None),
161            wait_error: Mutex::new(None),
162            closed: Mutex::new(false),
163            tabs: Mutex::new(vec![TabInfo {
164                id: default_tab_id.clone(),
165                url: "about:blank".to_string(),
166                title: String::new(),
167                active: true,
168            }]),
169            active_tab: Mutex::new(default_tab_id),
170            tab_counter: Mutex::new(1),
171        }
172    }
173}
174
175impl MockCdpClient {
176    pub fn new() -> Self {
177        Self::default()
178    }
179
180    /// Set the URL that will be returned by get_url() and navigate().
181    pub fn set_url(&self, url: impl Into<String>) {
182        *self.current_url.lock().unwrap() = url.into();
183    }
184
185    /// Set the title returned by get_title().
186    pub fn set_title(&self, title: impl Into<String>) {
187        *self.current_title.lock().unwrap() = title.into();
188    }
189
190    /// Set the HTML returned by get_html().
191    pub fn set_html(&self, html: impl Into<String>) {
192        *self.html_content.lock().unwrap() = html.into();
193    }
194
195    /// Set the text returned by get_text().
196    pub fn set_text(&self, text: impl Into<String>) {
197        *self.text_content.lock().unwrap() = text.into();
198    }
199
200    /// Set the ARIA tree returned by get_aria_tree().
201    pub fn set_aria_tree(&self, tree: impl Into<String>) {
202        *self.aria_tree.lock().unwrap() = tree.into();
203    }
204
205    /// Set the screenshot bytes.
206    pub fn set_screenshot(&self, bytes: Vec<u8>) {
207        *self.screenshot_bytes.lock().unwrap() = bytes;
208    }
209
210    /// Add a JavaScript result for a given script.
211    pub fn add_js_result(&self, script: impl Into<String>, result: Value) {
212        self.js_results
213            .lock()
214            .unwrap()
215            .insert(script.into(), result);
216    }
217
218    /// Set an error that navigate() will return.
219    pub fn set_navigate_error(&self, err: BrowserError) {
220        *self.navigate_error.lock().unwrap() = Some(err);
221    }
222
223    /// Set an error that click() will return.
224    pub fn set_click_error(&self, err: BrowserError) {
225        *self.click_error.lock().unwrap() = Some(err);
226    }
227
228    /// Set an error that wait_for_selector() will return.
229    pub fn set_wait_error(&self, err: BrowserError) {
230        *self.wait_error.lock().unwrap() = Some(err);
231    }
232
233    fn log_call(&self, method: &str, args: Vec<String>) {
234        self.call_log
235            .lock()
236            .unwrap()
237            .push((method.to_string(), args));
238    }
239
240    /// Get the number of calls to a given method.
241    pub fn call_count(&self, method: &str) -> usize {
242        self.call_log
243            .lock()
244            .unwrap()
245            .iter()
246            .filter(|(m, _)| m == method)
247            .count()
248    }
249
250    /// Get all recorded calls.
251    pub fn calls(&self) -> Vec<(String, Vec<String>)> {
252        self.call_log.lock().unwrap().clone()
253    }
254}
255
256#[async_trait]
257impl CdpClient for MockCdpClient {
258    async fn navigate(&self, url: &str) -> Result<(), BrowserError> {
259        self.log_call("navigate", vec![url.to_string()]);
260        if let Some(err) = self.navigate_error.lock().unwrap().take() {
261            return Err(err);
262        }
263        *self.current_url.lock().unwrap() = url.to_string();
264        Ok(())
265    }
266
267    async fn go_back(&self) -> Result<(), BrowserError> {
268        self.log_call("go_back", vec![]);
269        Ok(())
270    }
271
272    async fn go_forward(&self) -> Result<(), BrowserError> {
273        self.log_call("go_forward", vec![]);
274        Ok(())
275    }
276
277    async fn refresh(&self) -> Result<(), BrowserError> {
278        self.log_call("refresh", vec![]);
279        Ok(())
280    }
281
282    async fn click(&self, selector: &str) -> Result<(), BrowserError> {
283        self.log_call("click", vec![selector.to_string()]);
284        if let Some(err) = self.click_error.lock().unwrap().take() {
285            return Err(err);
286        }
287        Ok(())
288    }
289
290    async fn type_text(&self, selector: &str, text: &str) -> Result<(), BrowserError> {
291        self.log_call("type_text", vec![selector.to_string(), text.to_string()]);
292        Ok(())
293    }
294
295    async fn fill(&self, selector: &str, value: &str) -> Result<(), BrowserError> {
296        self.log_call("fill", vec![selector.to_string(), value.to_string()]);
297        Ok(())
298    }
299
300    async fn select_option(&self, selector: &str, value: &str) -> Result<(), BrowserError> {
301        self.log_call(
302            "select_option",
303            vec![selector.to_string(), value.to_string()],
304        );
305        Ok(())
306    }
307
308    async fn hover(&self, selector: &str) -> Result<(), BrowserError> {
309        self.log_call("hover", vec![selector.to_string()]);
310        Ok(())
311    }
312
313    async fn press_key(&self, key: &str) -> Result<(), BrowserError> {
314        self.log_call("press_key", vec![key.to_string()]);
315        Ok(())
316    }
317
318    async fn scroll(&self, x: i32, y: i32) -> Result<(), BrowserError> {
319        self.log_call("scroll", vec![x.to_string(), y.to_string()]);
320        Ok(())
321    }
322
323    async fn screenshot(&self) -> Result<Vec<u8>, BrowserError> {
324        self.log_call("screenshot", vec![]);
325        Ok(self.screenshot_bytes.lock().unwrap().clone())
326    }
327
328    async fn get_html(&self) -> Result<String, BrowserError> {
329        self.log_call("get_html", vec![]);
330        Ok(self.html_content.lock().unwrap().clone())
331    }
332
333    async fn get_text(&self) -> Result<String, BrowserError> {
334        self.log_call("get_text", vec![]);
335        Ok(self.text_content.lock().unwrap().clone())
336    }
337
338    async fn get_url(&self) -> Result<String, BrowserError> {
339        self.log_call("get_url", vec![]);
340        Ok(self.current_url.lock().unwrap().clone())
341    }
342
343    async fn get_title(&self) -> Result<String, BrowserError> {
344        self.log_call("get_title", vec![]);
345        Ok(self.current_title.lock().unwrap().clone())
346    }
347
348    async fn evaluate_js(&self, script: &str) -> Result<Value, BrowserError> {
349        self.log_call("evaluate_js", vec![script.to_string()]);
350        let results = self.js_results.lock().unwrap();
351        match results.get(script) {
352            Some(val) => Ok(val.clone()),
353            None => Ok(Value::Null),
354        }
355    }
356
357    async fn wait_for_selector(&self, selector: &str, timeout_ms: u64) -> Result<(), BrowserError> {
358        self.log_call(
359            "wait_for_selector",
360            vec![selector.to_string(), timeout_ms.to_string()],
361        );
362        if let Some(err) = self.wait_error.lock().unwrap().take() {
363            return Err(err);
364        }
365        Ok(())
366    }
367
368    async fn get_aria_tree(&self) -> Result<String, BrowserError> {
369        self.log_call("get_aria_tree", vec![]);
370        Ok(self.aria_tree.lock().unwrap().clone())
371    }
372
373    async fn upload_file(&self, selector: &str, path: &str) -> Result<(), BrowserError> {
374        self.log_call("upload_file", vec![selector.to_string(), path.to_string()]);
375        Ok(())
376    }
377
378    async fn close(&self) -> Result<(), BrowserError> {
379        self.log_call("close", vec![]);
380        *self.closed.lock().unwrap() = true;
381        Ok(())
382    }
383
384    async fn new_tab(&self, url: &str) -> Result<String, BrowserError> {
385        self.log_call("new_tab", vec![url.to_string()]);
386        let mut counter = self.tab_counter.lock().unwrap();
387        let tab_id = format!("tab-{}", *counter);
388        *counter += 1;
389        drop(counter);
390
391        let tab = TabInfo {
392            id: tab_id.clone(),
393            url: url.to_string(),
394            title: String::new(),
395            active: false,
396        };
397        self.tabs.lock().unwrap().push(tab);
398        Ok(tab_id)
399    }
400
401    async fn list_tabs(&self) -> Result<Vec<TabInfo>, BrowserError> {
402        self.log_call("list_tabs", vec![]);
403        let active = self.active_tab.lock().unwrap().clone();
404        let mut tabs = self.tabs.lock().unwrap().clone();
405        for tab in &mut tabs {
406            tab.active = tab.id == active;
407        }
408        Ok(tabs)
409    }
410
411    async fn switch_tab(&self, tab_id: &str) -> Result<(), BrowserError> {
412        self.log_call("switch_tab", vec![tab_id.to_string()]);
413        let tabs = self.tabs.lock().unwrap();
414        if !tabs.iter().any(|t| t.id == tab_id) {
415            return Err(BrowserError::TabNotFound {
416                tab_id: tab_id.to_string(),
417            });
418        }
419        drop(tabs);
420        *self.active_tab.lock().unwrap() = tab_id.to_string();
421        Ok(())
422    }
423
424    async fn close_tab(&self, tab_id: &str) -> Result<(), BrowserError> {
425        self.log_call("close_tab", vec![tab_id.to_string()]);
426        let mut tabs = self.tabs.lock().unwrap();
427        let initial_len = tabs.len();
428        tabs.retain(|t| t.id != tab_id);
429        if tabs.len() == initial_len {
430            return Err(BrowserError::TabNotFound {
431                tab_id: tab_id.to_string(),
432            });
433        }
434        Ok(())
435    }
436
437    async fn active_tab_id(&self) -> Result<String, BrowserError> {
438        self.log_call("active_tab_id", vec![]);
439        Ok(self.active_tab.lock().unwrap().clone())
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    #[tokio::test]
448    async fn test_mock_navigate() {
449        let client = MockCdpClient::new();
450        client.navigate("https://example.com").await.unwrap();
451        assert_eq!(*client.current_url.lock().unwrap(), "https://example.com");
452        assert_eq!(client.call_count("navigate"), 1);
453    }
454
455    #[tokio::test]
456    async fn test_mock_navigate_error() {
457        let client = MockCdpClient::new();
458        client.set_navigate_error(BrowserError::NavigationFailed {
459            message: "timeout".to_string(),
460        });
461        let result = client.navigate("https://example.com").await;
462        assert!(result.is_err());
463    }
464
465    #[tokio::test]
466    async fn test_mock_click() {
467        let client = MockCdpClient::new();
468        client.click("#submit").await.unwrap();
469        assert_eq!(client.call_count("click"), 1);
470        let calls = client.calls();
471        assert_eq!(calls[0].1[0], "#submit");
472    }
473
474    #[tokio::test]
475    async fn test_mock_click_error() {
476        let client = MockCdpClient::new();
477        client.set_click_error(BrowserError::ElementNotFound {
478            selector: "#missing".to_string(),
479        });
480        let result = client.click("#missing").await;
481        assert!(result.is_err());
482    }
483
484    #[tokio::test]
485    async fn test_mock_type_text() {
486        let client = MockCdpClient::new();
487        client.type_text("#input", "hello").await.unwrap();
488        assert_eq!(client.call_count("type_text"), 1);
489    }
490
491    #[tokio::test]
492    async fn test_mock_fill() {
493        let client = MockCdpClient::new();
494        client.fill("#email", "user@example.com").await.unwrap();
495        let calls = client.calls();
496        assert_eq!(calls[0].0, "fill");
497        assert_eq!(calls[0].1[1], "user@example.com");
498    }
499
500    #[tokio::test]
501    async fn test_mock_screenshot() {
502        let client = MockCdpClient::new();
503        client.set_screenshot(vec![1, 2, 3, 4]);
504        let bytes = client.screenshot().await.unwrap();
505        assert_eq!(bytes, vec![1, 2, 3, 4]);
506    }
507
508    #[tokio::test]
509    async fn test_mock_get_html() {
510        let client = MockCdpClient::new();
511        client.set_html("<html><body>Test</body></html>");
512        let html = client.get_html().await.unwrap();
513        assert_eq!(html, "<html><body>Test</body></html>");
514    }
515
516    #[tokio::test]
517    async fn test_mock_get_text() {
518        let client = MockCdpClient::new();
519        client.set_text("Hello World");
520        let text = client.get_text().await.unwrap();
521        assert_eq!(text, "Hello World");
522    }
523
524    #[tokio::test]
525    async fn test_mock_get_url_and_title() {
526        let client = MockCdpClient::new();
527        client.set_url("https://docs.rs");
528        client.set_title("Docs.rs");
529        assert_eq!(client.get_url().await.unwrap(), "https://docs.rs");
530        assert_eq!(client.get_title().await.unwrap(), "Docs.rs");
531    }
532
533    #[tokio::test]
534    async fn test_mock_evaluate_js() {
535        let client = MockCdpClient::new();
536        client.add_js_result("1+1", serde_json::json!(2));
537        let result = client.evaluate_js("1+1").await.unwrap();
538        assert_eq!(result, serde_json::json!(2));
539    }
540
541    #[tokio::test]
542    async fn test_mock_evaluate_js_unknown_script() {
543        let client = MockCdpClient::new();
544        let result = client.evaluate_js("unknown()").await.unwrap();
545        assert_eq!(result, Value::Null);
546    }
547
548    #[tokio::test]
549    async fn test_mock_wait_for_selector() {
550        let client = MockCdpClient::new();
551        client.wait_for_selector("#loaded", 5000).await.unwrap();
552        assert_eq!(client.call_count("wait_for_selector"), 1);
553    }
554
555    #[tokio::test]
556    async fn test_mock_wait_for_selector_timeout() {
557        let client = MockCdpClient::new();
558        client.set_wait_error(BrowserError::Timeout { timeout_secs: 5 });
559        let result = client.wait_for_selector("#never", 5000).await;
560        assert!(result.is_err());
561    }
562
563    #[tokio::test]
564    async fn test_mock_aria_tree() {
565        let client = MockCdpClient::new();
566        client.set_aria_tree("document\n  heading 'Title'");
567        let tree = client.get_aria_tree().await.unwrap();
568        assert!(tree.contains("heading"));
569    }
570
571    #[tokio::test]
572    async fn test_mock_close() {
573        let client = MockCdpClient::new();
574        assert!(!*client.closed.lock().unwrap());
575        client.close().await.unwrap();
576        assert!(*client.closed.lock().unwrap());
577    }
578
579    #[tokio::test]
580    async fn test_mock_navigation_methods() {
581        let client = MockCdpClient::new();
582        client.go_back().await.unwrap();
583        client.go_forward().await.unwrap();
584        client.refresh().await.unwrap();
585        assert_eq!(client.call_count("go_back"), 1);
586        assert_eq!(client.call_count("go_forward"), 1);
587        assert_eq!(client.call_count("refresh"), 1);
588    }
589
590    #[tokio::test]
591    async fn test_mock_scroll() {
592        let client = MockCdpClient::new();
593        client.scroll(0, 500).await.unwrap();
594        let calls = client.calls();
595        assert_eq!(calls[0].0, "scroll");
596        assert_eq!(calls[0].1, vec!["0", "500"]);
597    }
598
599    #[tokio::test]
600    async fn test_mock_hover_and_press_key() {
601        let client = MockCdpClient::new();
602        client.hover("#menu").await.unwrap();
603        client.press_key("Enter").await.unwrap();
604        assert_eq!(client.call_count("hover"), 1);
605        assert_eq!(client.call_count("press_key"), 1);
606    }
607
608    #[tokio::test]
609    async fn test_mock_select_option() {
610        let client = MockCdpClient::new();
611        client.select_option("#country", "US").await.unwrap();
612        assert_eq!(client.call_count("select_option"), 1);
613    }
614
615    #[tokio::test]
616    async fn test_mock_upload_file() {
617        let client = MockCdpClient::new();
618        client
619            .upload_file("#file-input", "/tmp/test.txt")
620            .await
621            .unwrap();
622        let calls = client.calls();
623        assert_eq!(calls[0].0, "upload_file");
624        assert_eq!(calls[0].1[1], "/tmp/test.txt");
625    }
626
627    // --- Tab management tests ---
628
629    #[tokio::test]
630    async fn test_mock_default_has_one_tab() {
631        let client = MockCdpClient::new();
632        let tabs = client.list_tabs().await.unwrap();
633        assert_eq!(tabs.len(), 1);
634        assert_eq!(tabs[0].id, "tab-0");
635        assert!(tabs[0].active);
636    }
637
638    #[tokio::test]
639    async fn test_mock_new_tab() {
640        let client = MockCdpClient::new();
641        let tab_id = client.new_tab("https://example.com").await.unwrap();
642        assert_eq!(tab_id, "tab-1");
643        let tabs = client.list_tabs().await.unwrap();
644        assert_eq!(tabs.len(), 2);
645        assert_eq!(tabs[1].url, "https://example.com");
646    }
647
648    #[tokio::test]
649    async fn test_mock_switch_tab() {
650        let client = MockCdpClient::new();
651        let tab_id = client.new_tab("https://example.com").await.unwrap();
652        client.switch_tab(&tab_id).await.unwrap();
653        assert_eq!(client.active_tab_id().await.unwrap(), tab_id);
654        // The new tab should be marked active in list
655        let tabs = client.list_tabs().await.unwrap();
656        assert!(!tabs[0].active);
657        assert!(tabs[1].active);
658    }
659
660    #[tokio::test]
661    async fn test_mock_switch_tab_not_found() {
662        let client = MockCdpClient::new();
663        let result = client.switch_tab("nonexistent").await;
664        assert!(result.is_err());
665    }
666
667    #[tokio::test]
668    async fn test_mock_close_tab() {
669        let client = MockCdpClient::new();
670        let tab_id = client.new_tab("https://example.com").await.unwrap();
671        assert_eq!(client.list_tabs().await.unwrap().len(), 2);
672        client.close_tab(&tab_id).await.unwrap();
673        assert_eq!(client.list_tabs().await.unwrap().len(), 1);
674    }
675
676    #[tokio::test]
677    async fn test_mock_close_tab_not_found() {
678        let client = MockCdpClient::new();
679        let result = client.close_tab("nonexistent").await;
680        assert!(result.is_err());
681    }
682
683    #[tokio::test]
684    async fn test_mock_active_tab_id() {
685        let client = MockCdpClient::new();
686        assert_eq!(client.active_tab_id().await.unwrap(), "tab-0");
687    }
688
689    #[tokio::test]
690    async fn test_mock_multiple_tabs() {
691        let client = MockCdpClient::new();
692        let t1 = client.new_tab("https://one.com").await.unwrap();
693        let t2 = client.new_tab("https://two.com").await.unwrap();
694        let _t3 = client.new_tab("https://three.com").await.unwrap();
695        assert_eq!(client.list_tabs().await.unwrap().len(), 4); // default + 3 new
696        client.close_tab(&t1).await.unwrap();
697        assert_eq!(client.list_tabs().await.unwrap().len(), 3);
698        client.switch_tab(&t2).await.unwrap();
699        assert_eq!(client.active_tab_id().await.unwrap(), t2);
700    }
701}