1use super::config::BrowseConfig;
8use super::engine::{BrowserEngine, BrowserError};
9use super::helpers;
10use super::tab_guard::TabGuard;
11use crate::tools::{AgentTool, AgentToolResult, ToolContext, ToolError};
12use async_trait::async_trait;
13use serde_json::{json, Value};
14use std::sync::Arc;
15use std::time::Instant;
16use tokio::sync::{oneshot, Mutex};
17
18pub struct BrowseSessionTool {
24 engine: Arc<dyn BrowserEngine>,
25 tab: Arc<Mutex<Option<TabGuard>>>,
26 config: BrowseConfig,
27 last_action: Arc<Mutex<Option<Instant>>>,
28}
29
30impl BrowseSessionTool {
31 pub fn new(engine: Arc<dyn BrowserEngine>) -> Self {
33 Self {
34 engine,
35 tab: Arc::new(Mutex::new(None)),
36 config: BrowseConfig::default(),
37 last_action: Arc::new(Mutex::new(None)),
38 }
39 }
40
41 pub fn with_config(engine: Arc<dyn BrowserEngine>, config: BrowseConfig) -> Self {
43 Self {
44 engine,
45 tab: Arc::new(Mutex::new(None)),
46 config,
47 last_action: Arc::new(Mutex::new(None)),
48 }
49 }
50
51 async fn touch(&self) {
53 *self.last_action.lock().await = Some(Instant::now());
54 }
55
56 async fn check_idle_timeout(&self) -> Result<(), ToolError> {
59 if self.config.session_idle_timeout_secs == 0 {
60 return Ok(());
61 }
62 let elapsed = {
63 let last = self.last_action.lock().await;
64 match *last {
65 Some(instant) => instant.elapsed().as_secs(),
66 None => return Ok(()), }
68 };
69 if elapsed >= self.config.session_idle_timeout_secs {
70 let mut slot = self.tab.lock().await;
72 if let Some(guard) = slot.take() {
73 tracing::warn!(
74 elapsed_secs = elapsed,
75 timeout_secs = self.config.session_idle_timeout_secs,
76 "browse_session: auto-closing stale session"
77 );
78 guard.close().await;
79 }
80 return Err(format!(
81 "Session timed out after {}s of inactivity",
82 elapsed
83 ));
84 }
85 Ok(())
86 }
87}
88
89#[async_trait]
90impl AgentTool for BrowseSessionTool {
91 fn name(&self) -> &str {
92 "browse_session"
93 }
94
95 fn label(&self) -> &str {
96 "Browser Session"
97 }
98
99 fn description(&self) -> &str {
100 "Interactive browser session with a persistent tab across calls. \
101 Open a session, perform multiple operations, then close when done. \
102 The tab retains cookies, localStorage, and DOM state between actions. \
103 Use for multi-step interactions like form filling, login flows, and \
104 SPA exploration where reasoning is needed between steps."
105 }
106
107 fn parameters_schema(&self) -> Value {
108 json!({
109 "type": "object",
110 "properties": {
111 "action": {
112 "type": "string",
113 "enum": [
114 "open",
115 "goto",
116 "back",
117 "forward",
118 "reload",
119 "click",
120 "fill",
121 "type",
122 "clear",
123 "press",
124 "select",
125 "check",
126 "uncheck",
127 "scroll",
128 "scroll_into_view",
129 "hover",
130 "double_click",
131 "right_click",
132 "drag",
133 "upload_file",
134 "wait_for",
135 "content",
136 "query_all",
137 "extract_links",
138 "evaluate",
139 "evaluate_await",
140 "get_value",
141 "screenshot",
142 "close"
143 ],
144 "description": "Session action to perform"
145 },
146 "url": {
147 "type": "string",
148 "description": "URL to navigate to (goto action)"
149 },
150 "selector": {
151 "type": "string",
152 "description": "CSS selector (click, fill, type, clear, select, check, uncheck, wait_for, query_all, extract_links)"
153 },
154 "value": {
155 "type": "string",
156 "description": "Value to fill/type/select (fill, type, select actions)"
157 },
158 "combo": {
159 "type": "string",
160 "description": "Key combo (press action, e.g. 'Enter', 'Control+a')"
161 },
162 "pixels": {
163 "type": "integer",
164 "description": "Scroll distance in pixels (scroll action, positive = down)"
165 },
166 "javascript": {
167 "type": "string",
168 "description": "JS expression to evaluate (evaluate, evaluate_await actions)"
169 },
170 "format": {
171 "type": "string",
172 "enum": ["markdown", "html", "text", "links"],
173 "default": "markdown",
174 "description": "Output format for content action"
175 },
176 "timeout_ms": {
177 "type": "integer",
178 "default": 10000,
179 "description": "Timeout in ms (wait_for action)"
180 },
181 "from_selector": {
182 "type": "string",
183 "description": "Source CSS selector (drag action)"
184 },
185 "to_selector": {
186 "type": "string",
187 "description": "Target CSS selector (drag action)"
188 },
189 "file_path": {
190 "type": "string",
191 "description": "Local file path to upload (upload_file action)"
192 },
193 "width": {
194 "type": "integer",
195 "default": 800,
196 "description": "Viewport width for screenshot (default: 800)"
197 }
198 },
199 "required": ["action"]
200 })
201 }
202
203 #[allow(clippy::too_many_lines)]
204 async fn execute(
205 &self,
206 _tool_call_id: &str,
207 params: Value,
208 _signal: Option<oneshot::Receiver<()>>,
209 _ctx: &ToolContext,
210 ) -> Result<AgentToolResult, ToolError> {
211 let action = params["action"]
212 .as_str()
213 .ok_or_else(|| "Missing required parameter: action".to_string())?;
214
215 let url = params["url"].as_str();
216 let selector = params["selector"].as_str();
217 let value = params["value"].as_str();
218 let combo = params["combo"].as_str();
219 let pixels = params["pixels"].as_u64().unwrap_or(300);
220 let javascript = params["javascript"].as_str();
221 let format = params["format"].as_str().unwrap_or("markdown");
222 let timeout_ms = params["timeout_ms"]
223 .as_u64()
224 .unwrap_or(self.config.default_wait_timeout_ms);
225 let width = params["width"]
226 .as_u64()
227 .unwrap_or(self.config.screenshot_width as u64) as u32;
228 let from_selector = params["from_selector"].as_str();
229 let to_selector = params["to_selector"].as_str();
230 let file_path = params["file_path"].as_str();
231
232 tracing::info!(action = %action, "browse_session action");
233
234 self.touch().await;
235
236 match action {
237 "open" => {
239 let mut slot = self.tab.lock().await;
240 if let Some(old_guard) = slot.take() {
242 tracing::warn!("browse_session: closing previous session on re-open");
243 old_guard.close().await;
244 }
245 let raw_tab = self
246 .engine
247 .new_tab()
248 .await
249 .map_err(|e| format!("Failed to open browser tab: {}", e))?;
250 let guard = TabGuard::new(raw_tab);
251 *slot = Some(guard);
252 Ok(json_ok())
253 }
254
255 "close" => {
256 let mut slot = self.tab.lock().await;
257 match slot.take() {
258 Some(guard) => {
259 guard.close().await;
260 Ok(json_ok())
261 }
262 None => Ok(json_error("no active session to close")),
263 }
264 }
265
266 "goto" => {
268 self.check_idle_timeout().await?;
269 let url = url.ok_or_else(|| "Missing required parameter: url".to_string())?;
270 let slot = self.tab.lock().await;
271 let tab = require_tab(&slot)?;
272 let page = tab.goto(url).await.map_err(browser_err)?;
273 Ok(AgentToolResult::success(json_str(&json!({
274 "status": "ok",
275 "url": page.url,
276 "title": page.title,
277 "status_code": page.status,
278 }))))
279 }
280
281 "back" => {
282 self.check_idle_timeout().await?;
283 let slot = self.tab.lock().await;
284 let tab = require_tab(&slot)?;
285 let _ = tab.evaluate("history.back()").await;
286 let page = tab.content().await.map_err(browser_err)?;
287 Ok(AgentToolResult::success(json_str(&json!({
288 "status": "ok",
289 "url": page.url,
290 "title": page.title,
291 }))))
292 }
293
294 "forward" => {
295 self.check_idle_timeout().await?;
296 let slot = self.tab.lock().await;
297 let tab = require_tab(&slot)?;
298 let _ = tab.evaluate("history.forward()").await;
299 let page = tab.content().await.map_err(browser_err)?;
300 Ok(AgentToolResult::success(json_str(&json!({
301 "status": "ok",
302 "url": page.url,
303 "title": page.title,
304 }))))
305 }
306
307 "reload" => {
308 self.check_idle_timeout().await?;
309 let slot = self.tab.lock().await;
310 let tab = require_tab(&slot)?;
311 let _ = tab.evaluate("location.reload()").await;
312 let page = tab.content().await.map_err(browser_err)?;
313 Ok(AgentToolResult::success(json_str(&json!({
314 "status": "ok",
315 "url": page.url,
316 "title": page.title,
317 }))))
318 }
319
320 "click" => {
322 self.check_idle_timeout().await?;
323 let sel =
324 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
325 let slot = self.tab.lock().await;
326 let tab = require_tab(&slot)?;
327 tab.click(sel).await.map_err(browser_err)?;
328 Ok(json_ok())
329 }
330
331 "fill" => {
332 self.check_idle_timeout().await?;
333 let sel =
334 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
335 let val = value.ok_or_else(|| "Missing required parameter: value".to_string())?;
336 let slot = self.tab.lock().await;
337 let tab = require_tab(&slot)?;
338 tab.fill(sel, val).await.map_err(browser_err)?;
339 Ok(json_ok())
340 }
341
342 "type" => {
343 self.check_idle_timeout().await?;
344 let sel =
345 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
346 let val = value.ok_or_else(|| "Missing required parameter: value".to_string())?;
347 let slot = self.tab.lock().await;
348 let tab = require_tab(&slot)?;
349 tab.type_(sel, val).await.map_err(browser_err)?;
350 Ok(json_ok())
351 }
352
353 "clear" => {
354 self.check_idle_timeout().await?;
355 let sel =
356 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
357 let slot = self.tab.lock().await;
358 let tab = require_tab(&slot)?;
359 tab.clear(sel).await.map_err(browser_err)?;
360 Ok(json_ok())
361 }
362
363 "press" => {
364 self.check_idle_timeout().await?;
365 let c = combo.ok_or_else(|| "Missing required parameter: combo".to_string())?;
366 let slot = self.tab.lock().await;
367 let tab = require_tab(&slot)?;
368 tab.press(c).await.map_err(browser_err)?;
369 Ok(json_ok())
370 }
371
372 "select" => {
373 self.check_idle_timeout().await?;
374 let sel =
375 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
376 let val = value.ok_or_else(|| "Missing required parameter: value".to_string())?;
377 let slot = self.tab.lock().await;
378 let tab = require_tab(&slot)?;
379 tab.select_option(sel, val).await.map_err(browser_err)?;
380 Ok(json_ok())
381 }
382
383 "check" => {
384 self.check_idle_timeout().await?;
385 let sel =
386 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
387 let slot = self.tab.lock().await;
388 let tab = require_tab(&slot)?;
389 tab.check(sel).await.map_err(browser_err)?;
390 Ok(json_ok())
391 }
392
393 "uncheck" => {
394 self.check_idle_timeout().await?;
395 let sel =
396 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
397 let slot = self.tab.lock().await;
398 let tab = require_tab(&slot)?;
399 tab.uncheck(sel).await.map_err(browser_err)?;
400 Ok(json_ok())
401 }
402
403 "scroll" => {
404 self.check_idle_timeout().await?;
405 let slot = self.tab.lock().await;
406 let tab = require_tab(&slot)?;
407 tab.scroll(0.0, pixels as f64).await.map_err(browser_err)?;
408 Ok(json_ok())
409 }
410
411 "wait_for" => {
413 self.check_idle_timeout().await?;
414 let sel =
415 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
416 let slot = self.tab.lock().await;
417 let tab = require_tab(&slot)?;
418 tab.wait_for(sel, timeout_ms).await.map_err(browser_err)?;
419 Ok(json_ok())
420 }
421
422 "content" => {
424 self.check_idle_timeout().await?;
425 let slot = self.tab.lock().await;
426 let tab = require_tab(&slot)?;
427 let page = tab.content().await.map_err(browser_err)?;
428
429 let content = match format {
430 "html" => {
431 if let Some(sel) = selector {
432 tab.query_all(sel).await.map_err(browser_err)?.join("\n\n")
433 } else {
434 page.html.clone()
435 }
436 }
437 "links" => {
438 let links = if let Some(sel) = selector {
439 let js = helpers::js_links_within(sel);
440 let value = tab.evaluate(&js).await.map_err(browser_err)?;
441 helpers::parse_link_values(value)
442 } else {
443 helpers::extract_links(tab)
444 .await
445 .map_err(|e: ToolError| e)?
446 };
447 helpers::format_links(&links)
448 }
449 "text" => {
450 if let Some(sel) = selector {
451 tab.query_all(sel).await.map_err(browser_err)?.join("\n")
452 } else {
453 page.markdown.clone()
454 }
455 }
456 _ => {
457 if let Some(sel) = selector {
459 tab.query_all(sel).await.map_err(browser_err)?.join("\n\n")
460 } else {
461 page.markdown.clone()
462 }
463 }
464 };
465
466 Ok(AgentToolResult::success(json_str(&json!({
467 "status": "ok",
468 "url": page.url,
469 "title": page.title,
470 "content": content,
471 }))))
472 }
473
474 "query_all" => {
475 self.check_idle_timeout().await?;
476 let sel =
477 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
478 let slot = self.tab.lock().await;
479 let tab = require_tab(&slot)?;
480 let results = tab.query_all(sel).await.map_err(browser_err)?;
481 Ok(AgentToolResult::success(json_str(&json!({
482 "status": "ok",
483 "results": results,
484 }))))
485 }
486
487 "extract_links" => {
488 self.check_idle_timeout().await?;
489 let slot = self.tab.lock().await;
490 let tab = require_tab(&slot)?;
491
492 let links = if let Some(sel) = selector {
493 let js = helpers::js_links_within(sel);
494 let value = tab.evaluate(&js).await.map_err(browser_err)?;
495 helpers::parse_link_values(value)
496 } else {
497 helpers::extract_links(tab)
498 .await
499 .map_err(|e: ToolError| e)?
500 };
501
502 let json_links: Vec<Value> = links
503 .iter()
504 .map(|(text, href)| json!({ "text": text, "href": href }))
505 .collect();
506
507 Ok(AgentToolResult::success(json_str(&json!({
508 "status": "ok",
509 "links": json_links,
510 }))))
511 }
512
513 "evaluate" => {
515 self.check_idle_timeout().await?;
516 let js = javascript
517 .ok_or_else(|| "Missing required parameter: javascript".to_string())?;
518 let slot = self.tab.lock().await;
519 let tab = require_tab(&slot)?;
520 let result_val = tab.evaluate(js).await.map_err(browser_err)?;
521 Ok(AgentToolResult::success(json_str(&json!({
522 "status": "ok",
523 "result": result_val,
524 }))))
525 }
526
527 "evaluate_await" => {
528 self.check_idle_timeout().await?;
529 let js = javascript
530 .ok_or_else(|| "Missing required parameter: javascript".to_string())?;
531 let slot = self.tab.lock().await;
532 let tab = require_tab(&slot)?;
533 let result_val = tab.evaluate_await(js).await.map_err(browser_err)?;
534 Ok(AgentToolResult::success(json_str(&json!({
535 "status": "ok",
536 "result": result_val,
537 }))))
538 }
539
540 "screenshot" => {
542 self.check_idle_timeout().await?;
543 let slot = self.tab.lock().await;
544 let tab = require_tab(&slot)?;
545 let png = tab.screenshot(width).await.map_err(browser_err)?;
546 let size_bytes = png.len();
547 let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &png);
548 let img = oxi_ai::ContentBlock::Image(oxi_ai::ImageContent::new(b64, "image/png"));
549
550 Ok(AgentToolResult::success(json_str(&json!({
551 "status": "ok",
552 "size_bytes": size_bytes,
553 })))
554 .with_content_blocks(vec![img]))
555 }
556
557 "scroll_into_view" => {
559 self.check_idle_timeout().await?;
560 let sel =
561 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
562 let slot = self.tab.lock().await;
563 let tab = require_tab(&slot)?;
564 tab.scroll_into_view(sel).await.map_err(browser_err)?;
565 Ok(json_ok())
566 }
567
568 "hover" => {
569 self.check_idle_timeout().await?;
570 let sel =
571 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
572 let slot = self.tab.lock().await;
573 let tab = require_tab(&slot)?;
574 tab.hover(sel).await.map_err(browser_err)?;
575 Ok(json_ok())
576 }
577
578 "double_click" => {
579 self.check_idle_timeout().await?;
580 let sel =
581 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
582 let slot = self.tab.lock().await;
583 let tab = require_tab(&slot)?;
584 tab.double_click(sel).await.map_err(browser_err)?;
585 Ok(json_ok())
586 }
587
588 "right_click" => {
589 self.check_idle_timeout().await?;
590 let sel =
591 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
592 let slot = self.tab.lock().await;
593 let tab = require_tab(&slot)?;
594 tab.right_click(sel).await.map_err(browser_err)?;
595 Ok(json_ok())
596 }
597
598 "drag" => {
599 self.check_idle_timeout().await?;
600 let from = from_selector
601 .ok_or_else(|| "Missing required parameter: from_selector".to_string())?;
602 let to = to_selector
603 .ok_or_else(|| "Missing required parameter: to_selector".to_string())?;
604 let slot = self.tab.lock().await;
605 let tab = require_tab(&slot)?;
606 tab.drag(from, to).await.map_err(browser_err)?;
607 Ok(json_ok())
608 }
609
610 "upload_file" => {
611 self.check_idle_timeout().await?;
612 let sel =
613 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
614 let path =
615 file_path.ok_or_else(|| "Missing required parameter: file_path".to_string())?;
616 let slot = self.tab.lock().await;
617 let tab = require_tab(&slot)?;
618 tab.upload_file(sel, path).await.map_err(browser_err)?;
619 Ok(json_ok())
620 }
621
622 "get_value" => {
623 self.check_idle_timeout().await?;
624 let sel =
625 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
626 let slot = self.tab.lock().await;
627 let tab = require_tab(&slot)?;
628 let result_val = tab.get_value(sel).await.map_err(browser_err)?;
629 Ok(AgentToolResult::success(json_str(&json!({
630 "status": "ok",
631 "value": result_val,
632 }))))
633 }
634
635 _ => Err(format!(
636 "Unknown action: '{}'. Valid actions: open, goto, back, forward, reload, \
637 click, fill, type, clear, press, select, check, uncheck, scroll, \
638 scroll_into_view, hover, double_click, right_click, drag, upload_file, \
639 wait_for, content, query_all, extract_links, evaluate, evaluate_await, \
640 get_value, screenshot, close",
641 action
642 )),
643 }
644 }
645}
646
647fn require_tab(slot: &Option<TabGuard>) -> Result<&dyn super::engine::BrowserTab, ToolError> {
651 match slot {
652 Some(guard) => Ok(guard.tab()),
653 None => Err(BrowserError::NoActiveSession.into()),
654 }
655}
656
657fn json_str(v: &Value) -> String {
659 serde_json::to_string_pretty(v).unwrap_or_default()
660}
661
662fn json_ok() -> AgentToolResult {
664 AgentToolResult::success(json_str(&json!({ "status": "ok" })))
665}
666
667fn json_error(msg: &str) -> AgentToolResult {
669 AgentToolResult::success(json_str(&json!({
670 "status": "error",
671 "error": msg,
672 })))
673}
674
675fn browser_err(e: BrowserError) -> ToolError {
677 e.to_string()
678}
679
680#[cfg(test)]
683mod tests {
684 use super::*;
685 use crate::tools::browse::engine::{BrowserError, PageContent};
686 use async_trait::async_trait;
687 use std::sync::atomic::{AtomicBool, Ordering};
688
689 struct MockTab {
692 closed: Arc<AtomicBool>,
693 }
694
695 impl MockTab {
696 fn new() -> (Self, Arc<AtomicBool>) {
697 let closed = Arc::new(AtomicBool::new(false));
698 (
699 Self {
700 closed: closed.clone(),
701 },
702 closed,
703 )
704 }
705 }
706
707 #[async_trait]
708 impl super::super::engine::BrowserTab for MockTab {
709 async fn goto(&self, _url: &str) -> Result<PageContent, BrowserError> {
710 Ok(PageContent {
711 url: "https://example.com".into(),
712 title: "Example".into(),
713 status: 200,
714 markdown: "# Example\nHello".into(),
715 html: "<h1>Example</h1>".into(),
716 })
717 }
718 async fn click(&self, _selector: &str) -> Result<(), BrowserError> {
719 Ok(())
720 }
721 async fn type_(&self, _selector: &str, _text: &str) -> Result<(), BrowserError> {
722 Ok(())
723 }
724 async fn fill(&self, _selector: &str, _value: &str) -> Result<(), BrowserError> {
725 Ok(())
726 }
727 async fn press(&self, _combo: &str) -> Result<(), BrowserError> {
728 Ok(())
729 }
730 async fn wait_for(&self, _selector: &str, _timeout_ms: u64) -> Result<(), BrowserError> {
731 Ok(())
732 }
733 async fn content(&self) -> Result<PageContent, BrowserError> {
734 Ok(PageContent {
735 url: "https://example.com".into(),
736 title: "Example".into(),
737 status: 200,
738 markdown: "# Example\nHello".into(),
739 html: "<h1>Example</h1>".into(),
740 })
741 }
742 async fn query_all(&self, _selector: &str) -> Result<Vec<String>, BrowserError> {
743 Ok(vec!["item1".into(), "item2".into()])
744 }
745 async fn evaluate(&self, _js: &str) -> Result<Value, BrowserError> {
746 Ok(Value::String("ok".into()))
747 }
748 async fn screenshot(&self, _width: u32) -> Result<Vec<u8>, BrowserError> {
749 Ok(vec![0x89, 0x50, 0x4E, 0x47]) }
751 async fn close(&self) -> Result<(), BrowserError> {
752 self.closed.store(true, Ordering::SeqCst);
753 Ok(())
754 }
755 async fn back(&self) -> Result<PageContent, BrowserError> {
756 Ok(PageContent::empty())
757 }
758 async fn forward(&self) -> Result<PageContent, BrowserError> {
759 Ok(PageContent::empty())
760 }
761 async fn reload(&self) -> Result<PageContent, BrowserError> {
762 Ok(PageContent::empty())
763 }
764 async fn select_option(&self, _selector: &str, _value: &str) -> Result<(), BrowserError> {
765 Ok(())
766 }
767 async fn check(&self, _selector: &str) -> Result<(), BrowserError> {
768 Ok(())
769 }
770 async fn uncheck(&self, _selector: &str) -> Result<(), BrowserError> {
771 Ok(())
772 }
773 async fn hover(&self, _selector: &str) -> Result<(), BrowserError> {
774 Ok(())
775 }
776 async fn double_click(&self, _selector: &str) -> Result<(), BrowserError> {
777 Ok(())
778 }
779 async fn right_click(&self, _selector: &str) -> Result<(), BrowserError> {
780 Ok(())
781 }
782 async fn scroll_into_view(&self, _selector: &str) -> Result<(), BrowserError> {
783 Ok(())
784 }
785 async fn drag(&self, _from_selector: &str, _to_selector: &str) -> Result<(), BrowserError> {
786 Ok(())
787 }
788 async fn upload_file(&self, _selector: &str, _path: &str) -> Result<(), BrowserError> {
789 Ok(())
790 }
791 async fn get_value(&self, _selector: &str) -> Result<String, BrowserError> {
792 Ok("mock_value".into())
793 }
794 async fn evaluate_await(&self, _js: &str) -> Result<Value, BrowserError> {
795 Ok(Value::String("ok".into()))
796 }
797 }
798
799 struct MockEngine;
802
803 #[async_trait]
804 impl super::super::engine::BrowserEngine for MockEngine {
805 async fn new_tab(&self) -> Result<Box<dyn super::super::engine::BrowserTab>, BrowserError> {
806 let (tab, _) = MockTab::new();
807 Ok(Box::new(tab))
808 }
809 async fn close(&self) -> Result<(), BrowserError> {
810 Ok(())
811 }
812 async fn is_alive(&self) -> bool {
813 true
814 }
815 }
816
817 fn make_tool() -> BrowseSessionTool {
819 let engine: Arc<dyn BrowserEngine> = Arc::new(MockEngine);
820 BrowseSessionTool::new(engine)
821 }
822
823 #[tokio::test]
826 async fn test_open_close_lifecycle() {
827 let tool = make_tool();
828 let ctx = ToolContext::default();
829
830 let result = tool
831 .execute("c1", json!({"action": "open"}), None, &ctx)
832 .await
833 .unwrap();
834 assert!(result.success);
835 assert!(result.output.contains("ok"));
836
837 let result = tool
838 .execute("c2", json!({"action": "close"}), None, &ctx)
839 .await
840 .unwrap();
841 assert!(result.success);
842 }
843
844 #[tokio::test]
845 async fn test_goto_requires_open_session() {
846 let tool = make_tool();
847 let ctx = ToolContext::default();
848
849 let result = tool
850 .execute(
851 "c1",
852 json!({"action": "goto", "url": "https://example.com"}),
853 None,
854 &ctx,
855 )
856 .await;
857 assert!(result.is_err());
858 assert!(result
859 .unwrap_err()
860 .to_string()
861 .contains("no active session"));
862 }
863
864 #[tokio::test]
865 async fn test_open_goto_close() {
866 let tool = make_tool();
867 let ctx = ToolContext::default();
868
869 tool.execute("c1", json!({"action": "open"}), None, &ctx)
870 .await
871 .unwrap();
872
873 let result = tool
874 .execute(
875 "c2",
876 json!({"action": "goto", "url": "https://example.com"}),
877 None,
878 &ctx,
879 )
880 .await
881 .unwrap();
882 assert!(result.success);
883 assert!(result.output.contains("example.com"));
884 assert!(result.output.contains("200"));
885
886 let result = tool
887 .execute("c3", json!({"action": "close"}), None, &ctx)
888 .await
889 .unwrap();
890 assert!(result.success);
891 }
892
893 #[tokio::test]
894 async fn test_content_action() {
895 let tool = make_tool();
896 let ctx = ToolContext::default();
897
898 tool.execute("c1", json!({"action": "open"}), None, &ctx)
899 .await
900 .unwrap();
901 tool.execute(
902 "c2",
903 json!({"action": "goto", "url": "https://example.com"}),
904 None,
905 &ctx,
906 )
907 .await
908 .unwrap();
909
910 let result = tool
911 .execute(
912 "c3",
913 json!({"action": "content", "format": "markdown"}),
914 None,
915 &ctx,
916 )
917 .await
918 .unwrap();
919 assert!(result.success);
920 assert!(result.output.contains("Example"));
921 assert!(result.output.contains("Hello"));
922
923 tool.execute("c4", json!({"action": "close"}), None, &ctx)
924 .await
925 .unwrap();
926 }
927
928 #[tokio::test]
929 async fn test_query_all_action() {
930 let tool = make_tool();
931 let ctx = ToolContext::default();
932
933 tool.execute("c1", json!({"action": "open"}), None, &ctx)
934 .await
935 .unwrap();
936
937 let result = tool
938 .execute(
939 "c2",
940 json!({"action": "query_all", "selector": ".item"}),
941 None,
942 &ctx,
943 )
944 .await
945 .unwrap();
946 assert!(result.success);
947 assert!(result.output.contains("item1"));
948 assert!(result.output.contains("item2"));
949
950 tool.execute("c3", json!({"action": "close"}), None, &ctx)
951 .await
952 .unwrap();
953 }
954
955 #[tokio::test]
956 async fn test_evaluate_action() {
957 let tool = make_tool();
958 let ctx = ToolContext::default();
959
960 tool.execute("c1", json!({"action": "open"}), None, &ctx)
961 .await
962 .unwrap();
963
964 let result = tool
965 .execute(
966 "c2",
967 json!({"action": "evaluate", "javascript": "document.title"}),
968 None,
969 &ctx,
970 )
971 .await
972 .unwrap();
973 assert!(result.success);
974 assert!(result.output.contains("ok"));
975
976 tool.execute("c3", json!({"action": "close"}), None, &ctx)
977 .await
978 .unwrap();
979 }
980
981 #[tokio::test]
982 async fn test_screenshot_action() {
983 let tool = make_tool();
984 let ctx = ToolContext::default();
985
986 tool.execute("c1", json!({"action": "open"}), None, &ctx)
987 .await
988 .unwrap();
989
990 let result = tool
991 .execute("c2", json!({"action": "screenshot"}), None, &ctx)
992 .await
993 .unwrap();
994 assert!(result.success);
995 assert!(result.output.contains("size_bytes"));
996 assert!(result.content_blocks.is_some());
997
998 tool.execute("c3", json!({"action": "close"}), None, &ctx)
999 .await
1000 .unwrap();
1001 }
1002
1003 #[tokio::test]
1004 async fn test_dom_actions() {
1005 let tool = make_tool();
1006 let ctx = ToolContext::default();
1007
1008 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1009 .await
1010 .unwrap();
1011
1012 let actions: Vec<(&str, Value)> = vec![
1013 ("click", json!({"action": "click", "selector": "#btn"})),
1014 (
1015 "fill",
1016 json!({"action": "fill", "selector": "#input", "value": "hello"}),
1017 ),
1018 (
1019 "type",
1020 json!({"action": "type", "selector": "#input", "value": "world"}),
1021 ),
1022 ("clear", json!({"action": "clear", "selector": "#input"})),
1023 ("press", json!({"action": "press", "combo": "Enter"})),
1024 ("check", json!({"action": "check", "selector": "#agree"})),
1025 (
1026 "uncheck",
1027 json!({"action": "uncheck", "selector": "#newsletter"}),
1028 ),
1029 ("scroll", json!({"action": "scroll", "pixels": 500})),
1030 (
1031 "wait_for",
1032 json!({"action": "wait_for", "selector": ".loaded"}),
1033 ),
1034 (
1035 "scroll_into_view",
1036 json!({"action": "scroll_into_view", "selector": "#section"}),
1037 ),
1038 ("hover", json!({"action": "hover", "selector": "#menu"})),
1039 (
1040 "double_click",
1041 json!({"action": "double_click", "selector": "#item"}),
1042 ),
1043 (
1044 "right_click",
1045 json!({"action": "right_click", "selector": "#item"}),
1046 ),
1047 (
1048 "get_value",
1049 json!({"action": "get_value", "selector": "#input"}),
1050 ),
1051 ];
1052
1053 for (name, params) in &actions {
1054 let result = tool.execute("cx", params.clone(), None, &ctx).await;
1055 assert!(result.is_ok(), "Action '{}' failed: {:?}", name, result);
1056 }
1057
1058 tool.execute("c99", json!({"action": "close"}), None, &ctx)
1059 .await
1060 .unwrap();
1061 }
1062
1063 #[tokio::test]
1064 async fn test_navigation_actions() {
1065 let tool = make_tool();
1066 let ctx = ToolContext::default();
1067
1068 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1069 .await
1070 .unwrap();
1071
1072 for nav_action in &["back", "forward", "reload"] {
1073 let result = tool
1074 .execute("cx", json!({"action": *nav_action}), None, &ctx)
1075 .await;
1076 assert!(result.is_ok(), "Navigation action '{}' failed", nav_action);
1077 }
1078
1079 tool.execute("c99", json!({"action": "close"}), None, &ctx)
1080 .await
1081 .unwrap();
1082 }
1083
1084 #[tokio::test]
1085 async fn test_unknown_action() {
1086 let tool = make_tool();
1087 let ctx = ToolContext::default();
1088
1089 let result = tool
1090 .execute("c1", json!({"action": "nonexistent"}), None, &ctx)
1091 .await;
1092 assert!(result.is_err());
1093 assert!(result.unwrap_err().to_string().contains("Unknown action"));
1094 }
1095
1096 #[tokio::test]
1097 async fn test_close_without_open() {
1098 let tool = make_tool();
1099 let ctx = ToolContext::default();
1100
1101 let result = tool
1102 .execute("c1", json!({"action": "close"}), None, &ctx)
1103 .await
1104 .unwrap();
1105 assert!(result.success);
1106 assert!(result.output.contains("error"));
1107 }
1108
1109 #[tokio::test]
1110 async fn test_re_open_closes_previous() {
1111 let tool = make_tool();
1112 let ctx = ToolContext::default();
1113
1114 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1115 .await
1116 .unwrap();
1117
1118 let result = tool
1119 .execute("c2", json!({"action": "open"}), None, &ctx)
1120 .await
1121 .unwrap();
1122 assert!(result.success);
1123
1124 let result = tool
1125 .execute(
1126 "c3",
1127 json!({"action": "goto", "url": "https://example.com"}),
1128 None,
1129 &ctx,
1130 )
1131 .await
1132 .unwrap();
1133 assert!(result.success);
1134 }
1135
1136 #[tokio::test]
1137 async fn test_missing_required_params() {
1138 let tool = make_tool();
1139 let ctx = ToolContext::default();
1140
1141 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1142 .await
1143 .unwrap();
1144
1145 assert!(tool
1147 .execute("c2", json!({"action": "goto"}), None, &ctx)
1148 .await
1149 .is_err());
1150
1151 assert!(tool
1153 .execute("c3", json!({"action": "click"}), None, &ctx)
1154 .await
1155 .is_err());
1156
1157 assert!(tool
1159 .execute(
1160 "c4",
1161 json!({"action": "fill", "selector": "#x"}),
1162 None,
1163 &ctx
1164 )
1165 .await
1166 .is_err());
1167
1168 assert!(tool
1170 .execute("c5", json!({"action": "press"}), None, &ctx)
1171 .await
1172 .is_err());
1173
1174 assert!(tool
1176 .execute("c6", json!({"action": "evaluate"}), None, &ctx)
1177 .await
1178 .is_err());
1179
1180 tool.execute("c7", json!({"action": "close"}), None, &ctx)
1181 .await
1182 .unwrap();
1183 }
1184
1185 #[tokio::test]
1186 async fn test_name_label_description() {
1187 let tool = make_tool();
1188 assert_eq!(tool.name(), "browse_session");
1189 assert_eq!(tool.label(), "Browser Session");
1190 assert!(!tool.description().is_empty());
1191 }
1192
1193 #[tokio::test]
1194 async fn test_schema_has_all_actions() {
1195 let tool = make_tool();
1196 let schema = tool.parameters_schema();
1197 let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
1198 assert_eq!(actions.len(), 29);
1199 }
1200}