Skip to main content

rustant_core/browser/
session.rs

1//! Browser session management — lifecycle, page pool, and cleanup.
2
3use crate::browser::cdp::CdpClient;
4use crate::browser::security::BrowserSecurityGuard;
5use crate::browser::snapshot::{PageSnapshot, SnapshotMode};
6use crate::error::BrowserError;
7use std::sync::Arc;
8
9/// Manages a browser session including page lifecycle and security enforcement.
10pub struct BrowserSession {
11    /// The CDP client used for browser interaction.
12    client: Arc<dyn CdpClient>,
13    /// Security guard for URL filtering and credential masking.
14    security: Arc<BrowserSecurityGuard>,
15    /// Maximum number of pages/tabs allowed.
16    max_pages: usize,
17    /// Current number of open pages.
18    open_pages: usize,
19    /// Whether the session is active.
20    active: bool,
21}
22
23impl BrowserSession {
24    /// Create a new browser session.
25    pub fn new(
26        client: Arc<dyn CdpClient>,
27        security: Arc<BrowserSecurityGuard>,
28        max_pages: usize,
29    ) -> Self {
30        Self {
31            client,
32            security,
33            max_pages,
34            open_pages: 1, // Start with one page
35            active: true,
36        }
37    }
38
39    /// Get the underlying CDP client.
40    pub fn client(&self) -> &Arc<dyn CdpClient> {
41        &self.client
42    }
43
44    /// Get the security guard.
45    pub fn security(&self) -> &Arc<BrowserSecurityGuard> {
46        &self.security
47    }
48
49    /// Navigate to a URL, checking security restrictions first.
50    pub async fn navigate(&self, url: &str) -> Result<(), BrowserError> {
51        if !self.active {
52            return Err(BrowserError::SessionError {
53                message: "Session is closed".to_string(),
54            });
55        }
56        self.security
57            .check_url(url)
58            .map_err(|msg| BrowserError::UrlBlocked { url: msg })?;
59        self.client.navigate(url).await
60    }
61
62    /// Take a snapshot of the current page in the specified mode.
63    pub async fn snapshot(&self, mode: SnapshotMode) -> Result<PageSnapshot, BrowserError> {
64        if !self.active {
65            return Err(BrowserError::SessionError {
66                message: "Session is closed".to_string(),
67            });
68        }
69
70        let url = self.client.get_url().await?;
71        let title = self.client.get_title().await?;
72
73        let content = match &mode {
74            SnapshotMode::Html => self.client.get_html().await?,
75            SnapshotMode::Text => self.client.get_text().await?,
76            SnapshotMode::AriaTree => self.client.get_aria_tree().await?,
77            SnapshotMode::Screenshot => {
78                let bytes = self.client.screenshot().await?;
79                base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes)
80            }
81        };
82
83        // Mask credentials in non-screenshot content
84        let content = if mode != SnapshotMode::Screenshot {
85            BrowserSecurityGuard::mask_credentials(&content)
86        } else {
87            content
88        };
89
90        Ok(PageSnapshot::new(url, title, mode, content))
91    }
92
93    /// Check if a new page can be opened.
94    pub fn can_open_page(&self) -> bool {
95        self.open_pages < self.max_pages
96    }
97
98    /// Record a new page being opened.
99    pub fn open_page(&mut self) -> Result<(), BrowserError> {
100        if self.open_pages >= self.max_pages {
101            return Err(BrowserError::PageLimitExceeded {
102                max: self.max_pages,
103            });
104        }
105        self.open_pages += 1;
106        Ok(())
107    }
108
109    /// Record a page being closed.
110    pub fn close_page(&mut self) {
111        if self.open_pages > 0 {
112            self.open_pages -= 1;
113        }
114    }
115
116    /// Close the entire browser session.
117    pub async fn close(&mut self) -> Result<(), BrowserError> {
118        if self.active {
119            self.active = false;
120            self.client.close().await?;
121        }
122        Ok(())
123    }
124
125    /// Whether the session is still active.
126    pub fn is_active(&self) -> bool {
127        self.active
128    }
129
130    /// Current number of open pages.
131    pub fn open_page_count(&self) -> usize {
132        self.open_pages
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::browser::cdp::MockCdpClient;
140
141    fn make_session(max_pages: usize) -> (BrowserSession, Arc<MockCdpClient>) {
142        let client = Arc::new(MockCdpClient::new());
143        let security = Arc::new(BrowserSecurityGuard::default());
144        let session = BrowserSession::new(client.clone(), security, max_pages);
145        (session, client)
146    }
147
148    fn make_session_with_security(
149        security: BrowserSecurityGuard,
150    ) -> (BrowserSession, Arc<MockCdpClient>) {
151        let client = Arc::new(MockCdpClient::new());
152        let security = Arc::new(security);
153        let session = BrowserSession::new(client.clone(), security, 5);
154        (session, client)
155    }
156
157    #[tokio::test]
158    async fn test_session_navigate() {
159        let (session, client) = make_session(5);
160        session.navigate("https://example.com").await.unwrap();
161        assert_eq!(*client.current_url.lock().unwrap(), "https://example.com");
162    }
163
164    #[tokio::test]
165    async fn test_session_navigate_blocked_url() {
166        let security = BrowserSecurityGuard::new(vec![], vec!["evil.com".to_string()]);
167        let (session, _client) = make_session_with_security(security);
168        let result = session.navigate("https://evil.com").await;
169        assert!(result.is_err());
170    }
171
172    #[tokio::test]
173    async fn test_session_snapshot_html() {
174        let (session, client) = make_session(5);
175        client.set_url("https://example.com");
176        client.set_title("Example");
177        client.set_html("<html><body>Hello</body></html>");
178        let snap = session.snapshot(SnapshotMode::Html).await.unwrap();
179        assert_eq!(snap.url, "https://example.com");
180        assert_eq!(snap.title, "Example");
181        assert_eq!(snap.mode, SnapshotMode::Html);
182        assert!(snap.content.contains("Hello"));
183    }
184
185    #[tokio::test]
186    async fn test_session_snapshot_text() {
187        let (session, client) = make_session(5);
188        client.set_url("https://docs.rs");
189        client.set_title("Docs");
190        client.set_text("Welcome to docs.rs");
191        let snap = session.snapshot(SnapshotMode::Text).await.unwrap();
192        assert_eq!(snap.mode, SnapshotMode::Text);
193        assert_eq!(snap.content, "Welcome to docs.rs");
194    }
195
196    #[tokio::test]
197    async fn test_session_snapshot_aria_tree() {
198        let (session, client) = make_session(5);
199        client.set_url("https://example.com");
200        client.set_title("Example");
201        client.set_aria_tree("document\n  heading 'Title'\n  button 'Submit'");
202        let snap = session.snapshot(SnapshotMode::AriaTree).await.unwrap();
203        assert_eq!(snap.mode, SnapshotMode::AriaTree);
204        assert!(snap.content.contains("heading"));
205    }
206
207    #[tokio::test]
208    async fn test_session_snapshot_screenshot() {
209        let (session, client) = make_session(5);
210        client.set_url("https://example.com");
211        client.set_title("Example");
212        client.set_screenshot(vec![1, 2, 3, 4, 5]);
213        let snap = session.snapshot(SnapshotMode::Screenshot).await.unwrap();
214        assert_eq!(snap.mode, SnapshotMode::Screenshot);
215        // Content should be base64-encoded
216        assert!(!snap.content.is_empty());
217    }
218
219    #[tokio::test]
220    async fn test_session_page_limit() {
221        let (mut session, _client) = make_session(2);
222        // Session starts with 1 page
223        assert_eq!(session.open_page_count(), 1);
224        assert!(session.can_open_page());
225        session.open_page().unwrap();
226        assert_eq!(session.open_page_count(), 2);
227        assert!(!session.can_open_page());
228        let result = session.open_page();
229        assert!(result.is_err());
230    }
231
232    #[tokio::test]
233    async fn test_session_close_page() {
234        let (mut session, _client) = make_session(3);
235        session.open_page().unwrap();
236        assert_eq!(session.open_page_count(), 2);
237        session.close_page();
238        assert_eq!(session.open_page_count(), 1);
239    }
240
241    #[tokio::test]
242    async fn test_session_close() {
243        let (mut session, client) = make_session(5);
244        assert!(session.is_active());
245        session.close().await.unwrap();
246        assert!(!session.is_active());
247        assert!(*client.closed.lock().unwrap());
248    }
249
250    #[tokio::test]
251    async fn test_session_navigate_after_close() {
252        let (mut session, _client) = make_session(5);
253        session.close().await.unwrap();
254        let result = session.navigate("https://example.com").await;
255        assert!(result.is_err());
256    }
257
258    #[tokio::test]
259    async fn test_session_snapshot_after_close() {
260        let (mut session, _client) = make_session(5);
261        session.close().await.unwrap();
262        let result = session.snapshot(SnapshotMode::Html).await;
263        assert!(result.is_err());
264    }
265
266    #[tokio::test]
267    async fn test_session_security_guard_gates_navigate() {
268        let security = BrowserSecurityGuard::new(vec!["allowed.com".to_string()], vec![]);
269        let (session, _client) = make_session_with_security(security);
270        // Allowed domain succeeds
271        assert!(session.navigate("https://allowed.com/page").await.is_ok());
272        // Non-allowed domain fails
273        assert!(session.navigate("https://other.com").await.is_err());
274    }
275
276    #[tokio::test]
277    async fn test_session_double_close_is_safe() {
278        let (mut session, _client) = make_session(5);
279        session.close().await.unwrap();
280        // Second close should be a no-op
281        session.close().await.unwrap();
282        assert!(!session.is_active());
283    }
284}