1use async_trait::async_trait;
10use chrono::{DateTime, Utc};
11use dashmap::DashMap;
12use serde::{Deserialize, Serialize};
13use uuid::Uuid;
14
15use crate::{PunchError, PunchResult};
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct BrowserConfig {
27 pub chrome_path: Option<String>,
29 pub headless: bool,
31 pub remote_debugging_port: u16,
33 pub user_data_dir: Option<String>,
35 pub timeout_secs: u64,
37 pub viewport_width: u32,
39 pub viewport_height: u32,
41}
42
43impl Default for BrowserConfig {
44 fn default() -> Self {
45 Self {
46 chrome_path: None,
47 headless: true,
48 remote_debugging_port: 9222,
49 user_data_dir: None,
50 timeout_secs: 30,
51 viewport_width: 1280,
52 viewport_height: 720,
53 }
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case", tag = "status", content = "detail")]
64pub enum BrowserState {
65 Starting,
67 Connected,
69 Navigating,
71 Ready,
73 Error(String),
75 Closed,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct BrowserSession {
82 pub id: Uuid,
84 pub created_at: DateTime<Utc>,
86 pub current_url: Option<String>,
88 pub page_title: Option<String>,
90 pub state: BrowserState,
92}
93
94impl BrowserSession {
95 pub fn new() -> Self {
97 Self {
98 id: Uuid::new_v4(),
99 created_at: Utc::now(),
100 current_url: None,
101 page_title: None,
102 state: BrowserState::Starting,
103 }
104 }
105}
106
107impl Default for BrowserSession {
108 fn default() -> Self {
109 Self::new()
110 }
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case", tag = "action")]
120pub enum BrowserAction {
121 Navigate { url: String },
123 Click { selector: String },
125 Type { selector: String, text: String },
127 Screenshot { full_page: bool },
129 GetContent { selector: Option<String> },
131 GetHtml { selector: Option<String> },
133 WaitForSelector { selector: String, timeout_ms: u64 },
135 Evaluate { javascript: String },
137 GoBack,
139 GoForward,
141 Reload,
143 Close,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct BrowserResult {
150 pub success: bool,
152 pub data: serde_json::Value,
154 pub page_url: Option<String>,
156 pub page_title: Option<String>,
158 pub duration_ms: u64,
160 pub error: Option<String>,
162}
163
164impl BrowserResult {
165 pub fn ok(data: serde_json::Value) -> Self {
167 Self {
168 success: true,
169 data,
170 page_url: None,
171 page_title: None,
172 duration_ms: 0,
173 error: None,
174 }
175 }
176
177 pub fn fail(message: impl Into<String>) -> Self {
179 Self {
180 success: false,
181 data: serde_json::Value::Null,
182 page_url: None,
183 page_title: None,
184 duration_ms: 0,
185 error: Some(message.into()),
186 }
187 }
188}
189
190#[async_trait]
199pub trait BrowserDriver: Send + Sync {
200 async fn launch(&self, config: &BrowserConfig) -> PunchResult<BrowserSession>;
202
203 async fn execute(
205 &self,
206 session: &mut BrowserSession,
207 action: BrowserAction,
208 ) -> PunchResult<BrowserResult>;
209
210 async fn close(&self, session: &mut BrowserSession) -> PunchResult<()>;
212}
213
214pub struct BrowserPool {
223 sessions: DashMap<Uuid, BrowserSession>,
225 config: BrowserConfig,
227 max_sessions: usize,
229}
230
231impl BrowserPool {
232 pub fn new(config: BrowserConfig, max_sessions: usize) -> Self {
234 Self {
235 sessions: DashMap::new(),
236 config,
237 max_sessions,
238 }
239 }
240
241 pub fn get_session(&self, id: &Uuid) -> Option<BrowserSession> {
243 self.sessions.get(id).map(|entry| entry.value().clone())
244 }
245
246 pub fn active_sessions(&self) -> Vec<BrowserSession> {
248 self.sessions
249 .iter()
250 .map(|entry| entry.value().clone())
251 .collect()
252 }
253
254 pub fn session_count(&self) -> usize {
256 self.sessions.len()
257 }
258
259 pub fn create_session(&self) -> PunchResult<BrowserSession> {
263 if self.sessions.len() >= self.max_sessions {
264 return Err(PunchError::Tool {
265 tool: "browser".into(),
266 message: format!(
267 "browser pool at capacity ({}/{})",
268 self.sessions.len(),
269 self.max_sessions
270 ),
271 });
272 }
273
274 let session = BrowserSession::new();
275 self.sessions.insert(session.id, session.clone());
276 Ok(session)
277 }
278
279 pub fn close_session(&self, id: &Uuid) -> PunchResult<()> {
281 self.sessions.remove(id).ok_or_else(|| PunchError::Tool {
282 tool: "browser".into(),
283 message: format!("session {} not found in pool", id),
284 })?;
285 Ok(())
286 }
287
288 pub fn close_all(&self) {
290 self.sessions.clear();
291 }
292
293 pub fn config(&self) -> &BrowserConfig {
295 &self.config
296 }
297}
298
299#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
308 fn test_browser_config_defaults() {
309 let config = BrowserConfig::default();
310 assert!(config.headless);
311 assert_eq!(config.remote_debugging_port, 9222);
312 assert!(config.chrome_path.is_none());
313 assert!(config.user_data_dir.is_none());
314 assert_eq!(config.timeout_secs, 30);
315 assert_eq!(config.viewport_width, 1280);
316 assert_eq!(config.viewport_height, 720);
317 }
318
319 #[test]
320 fn test_browser_session_creation() {
321 let session = BrowserSession::new();
322 assert_eq!(session.state, BrowserState::Starting);
323 assert!(session.current_url.is_none());
324 assert!(session.page_title.is_none());
325 }
326
327 #[test]
328 fn test_browser_pool_create_session() {
329 let pool = BrowserPool::new(BrowserConfig::default(), 5);
330 assert_eq!(pool.session_count(), 0);
331
332 let session = pool.create_session().expect("should create session");
333 assert_eq!(pool.session_count(), 1);
334
335 let retrieved = pool.get_session(&session.id);
336 assert!(retrieved.is_some());
337 assert_eq!(retrieved.expect("should exist").id, session.id);
338 }
339
340 #[test]
341 fn test_browser_pool_max_sessions_enforced() {
342 let pool = BrowserPool::new(BrowserConfig::default(), 2);
343
344 pool.create_session().expect("session 1");
345 pool.create_session().expect("session 2");
346
347 let result = pool.create_session();
348 assert!(result.is_err());
349 let err = result.unwrap_err().to_string();
350 assert!(err.contains("at capacity"), "error: {}", err);
351 }
352
353 #[test]
354 fn test_browser_pool_close_session() {
355 let pool = BrowserPool::new(BrowserConfig::default(), 5);
356 let session = pool.create_session().expect("should create session");
357 assert_eq!(pool.session_count(), 1);
358
359 pool.close_session(&session.id)
360 .expect("should close session");
361 assert_eq!(pool.session_count(), 0);
362
363 let result = pool.close_session(&session.id);
365 assert!(result.is_err());
366 }
367
368 #[test]
369 fn test_browser_pool_close_all() {
370 let pool = BrowserPool::new(BrowserConfig::default(), 10);
371 for _ in 0..5 {
372 pool.create_session().expect("should create session");
373 }
374 assert_eq!(pool.session_count(), 5);
375
376 pool.close_all();
377 assert_eq!(pool.session_count(), 0);
378 }
379
380 #[test]
381 fn test_browser_action_serialization() {
382 let action = BrowserAction::Navigate {
383 url: "https://example.com".into(),
384 };
385 let json = serde_json::to_string(&action).expect("should serialize");
386 assert!(json.contains("navigate"));
387 assert!(json.contains("https://example.com"));
388
389 let deserialized: BrowserAction = serde_json::from_str(&json).expect("should deserialize");
390 match deserialized {
391 BrowserAction::Navigate { url } => assert_eq!(url, "https://example.com"),
392 _ => panic!("expected Navigate variant"),
393 }
394 }
395
396 #[test]
397 fn test_browser_result_construction() {
398 let ok_result = BrowserResult::ok(serde_json::json!({"html": "<h1>Hello</h1>"}));
399 assert!(ok_result.success);
400 assert!(ok_result.error.is_none());
401 assert_eq!(ok_result.data["html"], "<h1>Hello</h1>");
402
403 let fail_result = BrowserResult::fail("element not found");
404 assert!(!fail_result.success);
405 assert_eq!(fail_result.error.as_deref(), Some("element not found"));
406 assert_eq!(fail_result.data, serde_json::Value::Null);
407 }
408
409 #[test]
410 fn test_browser_state_transitions() {
411 let states = vec![
413 BrowserState::Starting,
414 BrowserState::Connected,
415 BrowserState::Navigating,
416 BrowserState::Ready,
417 BrowserState::Error("timeout".into()),
418 BrowserState::Closed,
419 ];
420
421 for (i, a) in states.iter().enumerate() {
423 for (j, b) in states.iter().enumerate() {
424 if i == j {
425 assert_eq!(a, b);
426 } else {
427 assert_ne!(a, b);
428 }
429 }
430 }
431
432 let err1 = BrowserState::Error("timeout".into());
434 let err2 = BrowserState::Error("crash".into());
435 assert_ne!(err1, err2);
436 }
437
438 #[test]
439 fn test_browser_config_serialization_roundtrip() {
440 let config = BrowserConfig {
441 chrome_path: Some("/usr/bin/chromium".into()),
442 headless: false,
443 remote_debugging_port: 9333,
444 user_data_dir: Some("/tmp/chrome-data".into()),
445 timeout_secs: 60,
446 viewport_width: 1920,
447 viewport_height: 1080,
448 };
449
450 let json = serde_json::to_string(&config).expect("should serialize config");
451 let deserialized: BrowserConfig =
452 serde_json::from_str(&json).expect("should deserialize config");
453
454 assert_eq!(
455 deserialized.chrome_path.as_deref(),
456 Some("/usr/bin/chromium")
457 );
458 assert!(!deserialized.headless);
459 assert_eq!(deserialized.remote_debugging_port, 9333);
460 assert_eq!(
461 deserialized.user_data_dir.as_deref(),
462 Some("/tmp/chrome-data")
463 );
464 assert_eq!(deserialized.timeout_secs, 60);
465 assert_eq!(deserialized.viewport_width, 1920);
466 assert_eq!(deserialized.viewport_height, 1080);
467 }
468
469 #[test]
470 fn test_browser_pool_active_sessions() {
471 let pool = BrowserPool::new(BrowserConfig::default(), 5);
472 let s1 = pool.create_session().expect("session 1");
473 let s2 = pool.create_session().expect("session 2");
474
475 let active = pool.active_sessions();
476 assert_eq!(active.len(), 2);
477
478 let ids: Vec<Uuid> = active.iter().map(|s| s.id).collect();
479 assert!(ids.contains(&s1.id));
480 assert!(ids.contains(&s2.id));
481 }
482
483 #[test]
484 fn test_browser_session_default() {
485 let session = BrowserSession::default();
486 assert_eq!(session.state, BrowserState::Starting);
487 }
488}