1#![allow(missing_docs)]
11use serde_json::{json, Value};
12use std::collections::HashSet;
13use std::future::Future;
14use std::sync::Arc;
15use std::time::{Duration, Instant};
16use tokio::sync::broadcast;
17
18use super::cdp::chrome::LaunchOptions;
19use super::cdp::client::CdpClient;
20use super::cdp::lightpanda::{launch_lightpanda, LightpandaLaunchOptions, LightpandaProcess};
21use super::cdp::types::*;
22use super::element::{resolve_element_object_id, RefMap};
23
24pub fn validate_launch_options(
31 extensions: Option<&[String]>,
32 has_cdp: bool,
33 profile: Option<&str>,
34 storage_state: Option<&str>,
35 allow_file_access: bool,
36 executable_path: Option<&str>,
37) -> Result<(), String> {
38 let has_extensions = extensions.map(|e| !e.is_empty()).unwrap_or(false);
39
40 if has_extensions && has_cdp {
41 return Err(
42 "Cannot use extensions with cdp_url (extensions require local browser launch)"
43 .to_string(),
44 );
45 }
46 if profile.is_some() && has_cdp {
47 return Err(
48 "Cannot use profile with cdp_url (profile requires local browser launch)".to_string(),
49 );
50 }
51 if storage_state.is_some() && profile.is_some() {
52 return Err("Cannot use storage_state with profile".to_string());
53 }
54 if storage_state.is_some() && has_extensions {
55 return Err("Cannot use storage_state with extensions".to_string());
56 }
57 if allow_file_access {
58 if let Some(path) = executable_path {
59 let lower = path.to_lowercase();
60 if lower.contains("firefox") || lower.contains("webkit") || lower.contains("safari") {
61 return Err(
62 "allow_file_access is not supported with non-Chromium browsers".to_string(),
63 );
64 }
65 }
66 }
67 Ok(())
68}
69
70fn validate_lightpanda_options(options: &LaunchOptions) -> Result<(), String> {
72 if options
73 .extensions
74 .as_ref()
75 .map(|e| !e.is_empty())
76 .unwrap_or(false)
77 {
78 return Err("Extensions are not supported with Lightpanda".to_string());
79 }
80 if options.profile.is_some() {
81 return Err("Profiles are not supported with Lightpanda".to_string());
82 }
83 if options.storage_state.is_some() {
84 return Err("Storage state is not supported with Lightpanda".to_string());
85 }
86 if options.allow_file_access {
87 return Err("File access is not supported with Lightpanda".to_string());
88 }
89 if !options.headless {
90 return Err("Headed mode is not supported with Lightpanda (headless only)".to_string());
91 }
92 if options.webgpu {
93 return Err("WebGPU (--webgpu) is not supported with Lightpanda".to_string());
94 }
95 if !options.args.is_empty() {
96 return Err(
97 "Custom Chrome arguments (--args) are not supported with Lightpanda".to_string(),
98 );
99 }
100 Ok(())
101}
102
103fn is_internal_chrome_target(url: &str) -> bool {
106 url.starts_with("chrome://")
107 || url.starts_with("chrome-extension://")
108 || url.starts_with("devtools://")
109}
110
111pub(crate) fn should_track_target(target: &TargetInfo) -> bool {
112 (target.target_type == "page" || target.target_type == "webview")
113 && (target.url.is_empty() || !is_internal_chrome_target(&target.url))
114}
115
116fn update_page_target_info_in_pages(pages: &mut [PageInfo], target: &TargetInfo) -> bool {
117 if let Some(page) = pages.iter_mut().find(|p| p.target_id == target.target_id) {
118 page.url = target.url.clone();
119 page.title = target.title.clone();
120 page.target_type = target.target_type.clone();
121 return true;
122 }
123 false
124}
125
126fn active_page_index_after_removal(
127 active_page_index: usize,
128 removed_index: usize,
129 remaining_pages: usize,
130) -> usize {
131 if remaining_pages == 0 {
132 return 0;
133 }
134
135 if removed_index < active_page_index {
136 return active_page_index - 1;
137 }
138
139 if active_page_index >= remaining_pages {
140 return remaining_pages - 1;
141 }
142
143 active_page_index
144}
145
146pub fn to_ai_friendly_error(error: &str) -> String {
148 let lower = error.to_lowercase();
149 if lower.contains("strict mode violation") {
150 return "Element matched multiple results. Use a more specific selector.".to_string();
151 }
152 if lower.contains("element is not visible") {
153 return "Element exists but is not visible. Wait for it to become visible or scroll it into view."
154 .to_string();
155 }
156 if lower.contains("intercept") {
157 return "Another element is covering the target element. Try scrolling or closing overlays."
158 .to_string();
159 }
160 if lower.contains("timeout") {
161 return "Operation timed out. The page may still be loading or the element may not exist."
162 .to_string();
163 }
164 if lower.contains("element not found") || lower.contains("no element") {
165 return "Element not found. Verify the selector is correct and the element exists in the DOM."
166 .to_string();
167 }
168 error.to_string()
169}
170
171#[derive(Debug, Clone)]
172pub struct PageInfo {
173 pub tab_id: u32,
174 pub label: Option<String>,
180 pub target_id: String,
181 pub session_id: String,
182 pub url: String,
183 pub title: String,
184 pub target_type: String, }
186
187pub fn format_tab_id(tab_id: u32) -> String {
191 format!("t{}", tab_id)
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
197pub enum TabRef {
198 Id(u32),
199 Label(String),
200}
201
202impl TabRef {
203 pub fn parse(input: &str) -> Result<Self, String> {
207 let input = input.trim();
208 if input.is_empty() {
209 return Err("Empty tab reference; expected `t<N>` (e.g. `t2`) or a label".to_string());
210 }
211 if let Some(digits) = input.strip_prefix('t').or_else(|| input.strip_prefix('T')) {
212 if !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()) {
213 let id: u32 = digits.parse().map_err(|_| {
214 format!(
215 "Tab id `{}` out of range; ids are incrementing positive integers",
216 input
217 )
218 })?;
219 if id == 0 {
220 return Err(format!(
221 "Tab id `{}` is invalid; tab ids start at t1",
222 input
223 ));
224 }
225 return Ok(TabRef::Id(id));
226 }
227 }
228 if input.chars().all(|c| c.is_ascii_digit()) {
229 return Err(format!(
230 "Expected a tab id like `t{}` or a label; positional integers are not accepted \
231 (run `browser-automation-cli tab` to list stable tab ids)",
232 input
233 ));
234 }
235 if !is_valid_label(input) {
236 return Err(format!(
237 "Invalid tab label `{}`; labels must start with a letter and contain only \
238 letters, digits, `-`, and `_`",
239 input
240 ));
241 }
242 Ok(TabRef::Label(input.to_string()))
243 }
244}
245
246pub fn is_valid_label(s: &str) -> bool {
250 let mut chars = s.chars();
251 match chars.next() {
252 Some(c) if c.is_ascii_alphabetic() => {}
253 _ => return false,
254 }
255 chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub enum WaitUntil {
260 Load,
261 DomContentLoaded,
262 NetworkIdle,
263 None,
264}
265
266impl std::str::FromStr for WaitUntil {
267 type Err = std::convert::Infallible;
268
269 fn from_str(s: &str) -> Result<Self, Self::Err> {
270 Ok(match s {
271 "domcontentloaded" => Self::DomContentLoaded,
272 "networkidle" => Self::NetworkIdle,
273 "none" => Self::None,
274 _ => Self::Load,
275 })
276 }
277}
278
279impl WaitUntil {
280 pub fn parse_token(s: &str) -> Self {
282 s.parse().unwrap_or(Self::Load)
283 }
284}
285
286pub enum BrowserProcess {
289 Lightpanda(LightpandaProcess),
290}
291
292impl BrowserProcess {
293 pub fn kill(&mut self) {
294 match self {
295 BrowserProcess::Lightpanda(p) => p.kill(),
296 }
297 }
298
299 pub fn wait_or_kill(&mut self, _timeout: std::time::Duration) {
300 match self {
301 BrowserProcess::Lightpanda(p) => p.kill(),
302 }
303 }
304
305 pub fn has_exited(&mut self) -> bool {
307 match self {
308 BrowserProcess::Lightpanda(p) => p.has_exited(),
309 }
310 }
311
312 pub fn id(&self) -> Option<u32> {
314 match self {
315 BrowserProcess::Lightpanda(p) => p.id(),
316 }
317 }
318}
319
320#[must_use = "BrowserManager owns Chrome/CDP resources; shut down before discard"]
324pub struct BrowserManager {
325 pub client: Arc<CdpClient>,
326 browser_process: Option<BrowserProcess>,
327 owns_oxide_browser: bool,
329 ws_url: String,
330 pages: Vec<PageInfo>,
331 active_page_index: usize,
332 default_timeout_ms: u64,
333 pub download_path: Option<String>,
335 pub ignore_https_errors: bool,
337 visited_origins: HashSet<String>,
339 next_tab_id: u32,
340 direct_page: bool,
343 temp_user_data_dir: Option<std::path::PathBuf>,
345}
346
347const LIGHTPANDA_CDP_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
348const LIGHTPANDA_CDP_CONNECT_POLL_INTERVAL: Duration = Duration::from_millis(100);
349const LIGHTPANDA_TARGET_INIT_TIMEOUT: Duration = Duration::from_secs(10);
350
351impl BrowserManager {
352 pub fn chrome_pid(&self) -> Option<u32> {
354 if let Some(ref process) = self.browser_process {
355 return process.id();
356 }
357 None
358 }
359
360 pub fn temp_user_data_dir(&self) -> Option<&std::path::Path> {
362 self.temp_user_data_dir.as_deref()
363 }
364
365 pub async fn launch(options: LaunchOptions, engine: Option<&str>) -> Result<Self, String> {
366 let engine = engine.unwrap_or("chrome");
367
368 match engine {
369 "chrome" => {
370 validate_launch_options(
371 options.extensions.as_deref(),
372 false,
373 options.profile.as_deref(),
374 options.storage_state.as_deref(),
375 options.allow_file_access,
376 options.executable_path.as_deref(),
377 )?;
378 }
379 "lightpanda" => {
380 validate_lightpanda_options(&options)?;
381 }
382 _ => {
383 return Err(format!(
384 "Unknown engine '{}'. Supported engines: chrome, lightpanda",
385 engine
386 ));
387 }
388 }
389
390 let ignore_https_errors = options.ignore_https_errors;
391 let user_agent = options.user_agent.clone();
392 let color_scheme = options.color_scheme.clone();
393 let download_path = options.download_path.clone();
394
395 let manager = if engine == "lightpanda" {
396 let lp_options = LightpandaLaunchOptions {
397 executable_path: options.executable_path.clone(),
398 proxy: options.proxy.clone(),
399 port: None,
400 };
401 let lp = launch_lightpanda(&lp_options).await?;
402 let ws_url = lp.ws_url.clone();
403 let process = BrowserProcess::Lightpanda(lp);
404 let mut manager = initialize_lightpanda_manager(ws_url, process).await?;
405 manager.owns_oxide_browser = false;
406 manager
407 } else {
408 let launched = super::cdp::oxide::launch_with_oxide(&options).await?;
410 let ws_url = launched.ws_url.clone();
411 let temp_user_data_dir = launched.temp_user_data_dir.clone();
412 let client =
413 Arc::new(CdpClient::from_browser(launched.browser, launched.handler).await?);
414 let mut manager = Self {
415 client,
416 browser_process: None,
417 owns_oxide_browser: true,
418 ws_url,
419 pages: Vec::new(),
420 active_page_index: 0,
421 default_timeout_ms: 25_000,
422 download_path: download_path.clone(),
423 ignore_https_errors,
424 visited_origins: HashSet::new(),
425 next_tab_id: 1,
426 direct_page: false,
427 temp_user_data_dir,
428 };
429 manager.discover_and_attach_targets().await?;
430 manager
431 };
432
433 let session_id = manager.active_session_id()?.to_string();
434
435 if ignore_https_errors {
436 let _ = manager
437 .client
438 .send_command(
439 "Security.setIgnoreCertificateErrors",
440 Some(json!({ "ignore": true })),
441 Some(&session_id),
442 )
443 .await;
444 }
445
446 if let Some(ref ua) = user_agent {
447 let _ = manager
448 .client
449 .send_command(
450 "Emulation.setUserAgentOverride",
451 Some(json!({ "userAgent": ua })),
452 Some(&session_id),
453 )
454 .await;
455 }
456
457 if let Some(ref scheme) = color_scheme {
458 let _ = manager
459 .client
460 .send_command(
461 "Emulation.setEmulatedMedia",
462 Some(json!({ "features": [{ "name": "prefers-color-scheme", "value": scheme }] })),
463 Some(&session_id),
464 )
465 .await;
466 }
467
468 if let Some(ref path) = download_path {
469 let _ = manager
470 .client
471 .send_command(
472 "Browser.setDownloadBehavior",
473 Some(json!({ "behavior": "allow", "downloadPath": path })),
474 None,
475 )
476 .await;
477 }
478
479 Ok(manager)
480 }
481
482 async fn discover_and_attach_targets(&mut self) -> Result<(), String> {
483 self.client
484 .send_command_typed::<_, Value>(
485 "Target.setDiscoverTargets",
486 &SetDiscoverTargetsParams { discover: true },
487 None,
488 )
489 .await?;
490
491 let result: GetTargetsResult = self
492 .client
493 .send_command_typed("Target.getTargets", &json!({}), None)
494 .await?;
495
496 let page_targets: Vec<TargetInfo> = result
497 .target_infos
498 .into_iter()
499 .filter(should_track_target)
500 .collect();
501
502 if page_targets.is_empty() {
503 let result: CreateTargetResult = self
505 .client
506 .send_command_typed(
507 "Target.createTarget",
508 &CreateTargetParams {
509 url: "about:blank".to_string(),
510 browser_context_id: None,
511 },
512 None,
513 )
514 .await?;
515
516 let attach_result: AttachToTargetResult = self
517 .client
518 .send_command_typed(
519 "Target.attachToTarget",
520 &AttachToTargetParams {
521 target_id: result.target_id.clone(),
522 flatten: true,
523 },
524 None,
525 )
526 .await?;
527
528 let tab_id = self.next_tab_id;
529 self.next_tab_id += 1;
530 self.pages.push(PageInfo {
531 tab_id,
532 label: None,
533 target_id: result.target_id,
534 session_id: attach_result.session_id.clone(),
535 url: "about:blank".to_string(),
536 title: String::new(),
537 target_type: "page".to_string(),
538 });
539 self.active_page_index = 0;
540 self.enable_domains(&attach_result.session_id).await?;
541 } else {
542 let cdp_limit = crate::concurrency::effective_limit_capped(32);
544 let client = std::sync::Arc::clone(&self.client);
545 let attach_futs: Vec<_> = page_targets
546 .iter()
547 .map(|target| {
548 let client = std::sync::Arc::clone(&client);
549 let tid = target.target_id.clone();
550 async move {
551 client
552 .send_command_typed::<_, AttachToTargetResult>(
553 "Target.attachToTarget",
554 &AttachToTargetParams {
555 target_id: tid,
556 flatten: true,
557 },
558 None,
559 )
560 .await
561 }
562 })
563 .collect();
564 let attach_results =
565 crate::concurrency::join_bounded_ordered(attach_futs, cdp_limit).await;
566 for (target, attach_result) in page_targets.iter().zip(attach_results) {
567 let attach_result = attach_result?;
568 let tab_id = self.next_tab_id;
569 self.next_tab_id += 1;
570 self.pages.push(PageInfo {
571 tab_id,
572 label: None,
573 target_id: target.target_id.clone(),
574 session_id: attach_result.session_id.clone(),
575 url: target.url.clone(),
576 title: target.title.clone(),
577 target_type: target.target_type.clone(),
578 });
579 }
580
581 self.active_page_index = 0;
582 let session_id = self.pages[0].session_id.clone();
583 self.enable_domains(&session_id).await?;
584 }
585
586 Ok(())
587 }
588
589 pub async fn enable_domains_pub(&self, session_id: &str) -> Result<(), String> {
590 self.enable_domains(session_id).await
591 }
592
593 pub async fn prepare_domains_pub(&self, session_id: &str) -> Result<(), String> {
594 self.prepare_domains(session_id).await
595 }
596
597 pub async fn resume_if_waiting_pub(&self, session_id: &str) -> Result<(), String> {
598 self.resume_if_waiting(session_id).await
599 }
600
601 pub async fn enable_browser_auto_attach_pub(&self) -> Result<(), String> {
602 self.client
603 .send_command(
604 "Target.setAutoAttach",
605 Some(json!({
606 "autoAttach": true,
607 "waitForDebuggerOnStart": true,
608 "flatten": true
609 })),
610 None,
611 )
612 .await?;
613 Ok(())
614 }
615
616 async fn enable_domains(&self, session_id: &str) -> Result<(), String> {
617 self.prepare_domains(session_id).await?;
618 self.resume_if_waiting(session_id).await?;
619 Ok(())
620 }
621
622 async fn prepare_domains(&self, session_id: &str) -> Result<(), String> {
623 self.client
624 .send_command_no_params("Page.enable", Some(session_id))
625 .await?;
626 self.client
627 .send_command_no_params("Runtime.enable", Some(session_id))
628 .await?;
629 self.client
630 .send_command_no_params("Network.enable", Some(session_id))
631 .await?;
632 let _ = self
638 .client
639 .send_command(
640 "Target.setAutoAttach",
641 Some(json!({
642 "autoAttach": true,
643 "waitForDebuggerOnStart": true,
644 "flatten": true
645 })),
646 Some(session_id),
647 )
648 .await;
649 Ok(())
650 }
651
652 async fn resume_if_waiting(&self, session_id: &str) -> Result<(), String> {
653 let _ = self
656 .client
657 .send_command_no_wait("Runtime.runIfWaitingForDebugger", None, Some(session_id))
658 .await;
659 Ok(())
660 }
661
662 pub fn active_session_id(&self) -> Result<&str, String> {
663 self.pages
664 .get(self.active_page_index)
665 .map(|p| p.session_id.as_str())
666 .ok_or_else(|| "No active page".to_string())
667 }
668
669 pub async fn navigate(&mut self, url: &str, wait_until: WaitUntil) -> Result<Value, String> {
670 let session_id = self.active_session_id()?.to_string();
671 let mut lifecycle_rx = self.client.subscribe();
672
673 let nav_result: PageNavigateResult = self
674 .client
675 .send_command_typed(
676 "Page.navigate",
677 &PageNavigateParams {
678 url: url.to_string(),
679 referrer: None,
680 },
681 Some(&session_id),
682 )
683 .await?;
684
685 if let Some(ref error_text) = nav_result.error_text {
686 return Err(format!("Navigation failed: {}", error_text));
687 }
688
689 if nav_result.loader_id.is_some() && wait_until != WaitUntil::None {
693 self.wait_for_lifecycle(wait_until, &session_id, &mut lifecycle_rx)
694 .await?;
695 }
696
697 let page_url = self.get_url().await.unwrap_or_else(|_| url.to_string());
698 let title = self.get_title().await.unwrap_or_default();
699
700 if let Ok(parsed) = url::Url::parse(&page_url) {
702 let origin = parsed.origin().ascii_serialization();
703 if origin != "null" {
704 self.visited_origins.insert(origin);
705 }
706 }
707
708 if let Some(page) = self.pages.get_mut(self.active_page_index) {
709 page.url = page_url.clone();
710 page.title = title.clone();
711 }
712
713 Ok(json!({ "url": page_url, "title": title }))
714 }
715
716 async fn wait_for_lifecycle(
717 &self,
718 wait_until: WaitUntil,
719 session_id: &str,
720 rx: &mut broadcast::Receiver<CdpEvent>,
721 ) -> Result<(), String> {
722 let (event_name, ready_states): (&str, &[&str]) = match wait_until {
723 WaitUntil::Load => ("Page.loadEventFired", &["complete"]),
724 WaitUntil::DomContentLoaded => {
725 ("Page.domContentEventFired", &["interactive", "complete"])
726 }
727 WaitUntil::NetworkIdle => return self.wait_for_network_idle(session_id, rx).await,
728 WaitUntil::None => return Ok(()),
729 };
730
731 let timeout = tokio::time::Duration::from_millis(self.default_timeout_ms);
732
733 tokio::time::timeout(timeout, async {
734 loop {
735 if let Ok(state) = self
738 .client
739 .send_command(
740 "Runtime.evaluate",
741 Some(json!({
742 "expression": "document.readyState",
743 "returnByValue": true
744 })),
745 Some(session_id),
746 )
747 .await
748 {
749 let rs = state
750 .get("result")
751 .and_then(|r| r.get("value"))
752 .and_then(|v| v.as_str())
753 .unwrap_or("");
754 if ready_states.contains(&rs) {
755 return Ok(());
756 }
757 }
758
759 match tokio::time::timeout(tokio::time::Duration::from_millis(100), rx.recv()).await
760 {
761 Ok(Ok(event)) => {
762 let sid_ok = event.session_id.is_none()
763 || event.session_id.as_deref() == Some(session_id);
764 if event.method == event_name && sid_ok {
765 return Ok(());
766 }
767 }
768 Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue,
769 Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => {
770 }
772 Err(_) => {
773 }
775 }
776 }
777 })
778 .await
779 .map_err(|_| format!("Timeout waiting for {}", event_name))?
780 }
781
782 async fn wait_for_network_idle(
783 &self,
784 session_id: &str,
785 rx: &mut broadcast::Receiver<CdpEvent>,
786 ) -> Result<(), String> {
787 let timeout = tokio::time::Duration::from_millis(self.default_timeout_ms);
788 poll_network_idle(session_id, rx, timeout).await
789 }
790
791 pub async fn get_url(&self) -> Result<String, String> {
792 let result = self.evaluate_simple("location.href").await?;
793 Ok(result.as_str().unwrap_or("").to_string())
794 }
795
796 pub async fn get_title(&self) -> Result<String, String> {
797 let result = self.evaluate_simple("document.title").await?;
798 Ok(result.as_str().unwrap_or("").to_string())
799 }
800
801 pub async fn get_content(&self) -> Result<String, String> {
802 let result = self
803 .evaluate_simple("document.documentElement.outerHTML")
804 .await?;
805 Ok(result.as_str().unwrap_or("").to_string())
806 }
807
808 pub async fn evaluate(&self, script: &str, _args: Option<Value>) -> Result<Value, String> {
809 let session_id = self.active_session_id()?.to_string();
810
811 let result: EvaluateResult = self
812 .client
813 .send_command_typed(
814 "Runtime.evaluate",
815 &EvaluateParams {
816 expression: script.to_string(),
817 return_by_value: Some(true),
818 await_promise: Some(true),
819 },
820 Some(&session_id),
821 )
822 .await?;
823
824 if let Some(ref details) = result.exception_details {
825 let msg = details
826 .exception
827 .as_ref()
828 .and_then(|e| e.description.as_deref())
829 .unwrap_or(&details.text);
830 return Err(format!("Evaluation error: {}", msg));
831 }
832
833 Ok(result.result.value.unwrap_or(Value::Null))
834 }
835
836 async fn evaluate_simple(&self, expression: &str) -> Result<Value, String> {
837 self.evaluate(expression, None).await
838 }
839
840 pub async fn wait_for_lifecycle_external(
841 &self,
842 wait_until: WaitUntil,
843 session_id: &str,
844 ) -> Result<(), String> {
845 let mut rx = self.client.subscribe();
846 self.wait_for_lifecycle(wait_until, session_id, &mut rx)
847 .await
848 }
849
850 pub async fn close(&mut self) -> Result<(), String> {
851 if self.owns_oxide_browser {
853 self.owns_oxide_browser = false;
854 let res = super::cdp::oxide::finalize_browser(self.client.browser()).await;
855 if let Some(dir) = self.temp_user_data_dir.take() {
856 let _ = std::fs::remove_dir_all(&dir);
857 }
858 return res;
859 }
860
861 if self.browser_process.is_some() {
862 let _ = self
864 .client
865 .send_command_no_params("Browser.close", None)
866 .await;
867 }
868
869 if let Some(mut process) = self.browser_process.take() {
870 let timeout = std::time::Duration::from_secs(5);
871 let _ = tokio::task::spawn_blocking(move || {
872 process.wait_or_kill(timeout);
873 })
874 .await;
875 }
876
877 if let Some(dir) = self.temp_user_data_dir.take() {
878 let _ = std::fs::remove_dir_all(&dir);
879 }
880
881 Ok(())
882 }
883
884 pub fn has_pages(&self) -> bool {
885 !self.pages.is_empty()
886 }
887
888 pub fn default_timeout_ms(&self) -> u64 {
889 self.default_timeout_ms
890 }
891
892 pub async fn is_connection_alive(&self) -> bool {
895 let timeout = tokio::time::Duration::from_secs(3);
896 let result = tokio::time::timeout(
897 timeout,
898 self.client
899 .send_command_no_params("Browser.getVersion", None),
900 )
901 .await;
902
903 match result {
904 Ok(Ok(_)) => true,
905 Ok(Err(_)) | Err(_) => false,
906 }
907 }
908
909 pub fn has_process_exited(&mut self) -> bool {
913 if let Some(ref mut process) = self.browser_process {
914 process.has_exited()
915 } else {
916 false
917 }
918 }
919
920 pub fn get_cdp_url(&self) -> &str {
921 &self.ws_url
922 }
923
924 pub fn chrome_host_port(&self) -> &str {
926 let stripped = self
927 .ws_url
928 .strip_prefix("ws://")
929 .or_else(|| self.ws_url.strip_prefix("wss://"))
930 .unwrap_or(&self.ws_url);
931 stripped.split('/').next().unwrap_or(stripped)
932 }
933
934 pub fn active_target_id(&self) -> Result<&str, String> {
935 self.pages
936 .get(self.active_page_index)
937 .map(|p| p.target_id.as_str())
938 .ok_or_else(|| "No active page".to_string())
939 }
940
941 pub fn is_cdp_connection(&self) -> bool {
943 self.browser_process.is_none()
944 }
945
946 pub fn is_direct_page_connection(&self) -> bool {
947 self.direct_page
948 }
949
950 pub async fn ensure_page(&mut self) -> Result<(), String> {
953 if !self.pages.is_empty() {
954 return Ok(());
955 }
956
957 let result: CreateTargetResult = self
958 .client
959 .send_command_typed(
960 "Target.createTarget",
961 &CreateTargetParams {
962 url: "about:blank".to_string(),
963 browser_context_id: None,
964 },
965 None,
966 )
967 .await?;
968
969 let attach_result: AttachToTargetResult = self
970 .client
971 .send_command_typed(
972 "Target.attachToTarget",
973 &AttachToTargetParams {
974 target_id: result.target_id.clone(),
975 flatten: true,
976 },
977 None,
978 )
979 .await?;
980
981 let tab_id = self.next_tab_id;
982 self.next_tab_id += 1;
983 self.pages.push(PageInfo {
984 tab_id,
985 label: None,
986 target_id: result.target_id,
987 session_id: attach_result.session_id.clone(),
988 url: "about:blank".to_string(),
989 title: String::new(),
990 target_type: "page".to_string(),
991 });
992 self.active_page_index = 0;
993 self.enable_domains(&attach_result.session_id).await?;
994
995 Ok(())
996 }
997
998 pub fn update_active_page_if_needed(&mut self) {
1005 if self.pages.is_empty() {
1006 self.active_page_index = 0;
1007 return;
1008 }
1009 if self.active_page_index >= self.pages.len() {
1010 self.active_page_index = self.pages.len() - 1;
1011 }
1012 }
1013
1014 fn update_active_page_after_removal(&mut self, removed_index: usize) {
1015 self.active_page_index = active_page_index_after_removal(
1016 self.active_page_index,
1017 removed_index,
1018 self.pages.len(),
1019 );
1020 }
1021
1022 pub fn tab_list(&self) -> Vec<Value> {
1023 self.pages
1024 .iter()
1025 .enumerate()
1026 .map(|(i, p)| {
1027 json!({
1028 "tabId": format_tab_id(p.tab_id),
1029 "label": p.label,
1030 "title": p.title,
1031 "url": p.url,
1032 "type": p.target_type,
1033 "active": i == self.active_page_index,
1034 })
1035 })
1036 .collect()
1037 }
1038
1039 pub fn resolve_tab_ref(&self, tab_ref: &TabRef) -> Result<u32, String> {
1042 match tab_ref {
1043 TabRef::Id(id) => {
1044 if self.has_tab_id(*id) {
1045 Ok(*id)
1046 } else {
1047 Err(format!(
1048 "Tab {} not found; run `browser-automation-cli tab` to list open tabs",
1049 format_tab_id(*id)
1050 ))
1051 }
1052 }
1053 TabRef::Label(name) => self
1054 .pages
1055 .iter()
1056 .find(|p| p.label.as_deref() == Some(name.as_str()))
1057 .map(|p| p.tab_id)
1058 .ok_or_else(|| {
1059 format!(
1060 "No tab with label `{}`; run `browser-automation-cli tab` to list open tabs",
1061 name
1062 )
1063 }),
1064 }
1065 }
1066
1067 pub fn has_label(&self, label: &str) -> bool {
1069 self.pages.iter().any(|p| p.label.as_deref() == Some(label))
1070 }
1071
1072 pub async fn tab_new(
1073 &mut self,
1074 url: Option<&str>,
1075 label: Option<&str>,
1076 ) -> Result<Value, String> {
1077 self.tab_new_in_context(url, label, None).await
1078 }
1079
1080 pub async fn tab_new_in_context(
1082 &mut self,
1083 url: Option<&str>,
1084 label: Option<&str>,
1085 browser_context_id: Option<String>,
1086 ) -> Result<Value, String> {
1087 if let Some(label) = label {
1088 if !is_valid_label(label) {
1089 return Err(format!(
1090 "Invalid tab label `{}`; labels must start with a letter and contain only \
1091 letters, digits, `-`, and `_`",
1092 label
1093 ));
1094 }
1095 if self.has_label(label) {
1096 return Err(format!(
1097 "Label `{}` is already used by another tab; labels must be unique within a \
1098 session",
1099 label
1100 ));
1101 }
1102 }
1103
1104 let target_url = url.unwrap_or("about:blank");
1105
1106 let result: CreateTargetResult = self
1107 .client
1108 .send_command_typed(
1109 "Target.createTarget",
1110 &CreateTargetParams {
1111 url: target_url.to_string(),
1112 browser_context_id: browser_context_id.clone(),
1113 },
1114 None,
1115 )
1116 .await?;
1117
1118 let attach: AttachToTargetResult = self
1119 .client
1120 .send_command_typed(
1121 "Target.attachToTarget",
1122 &AttachToTargetParams {
1123 target_id: result.target_id.clone(),
1124 flatten: true,
1125 },
1126 None,
1127 )
1128 .await?;
1129
1130 self.enable_domains(&attach.session_id).await?;
1131
1132 let tab_id = self.next_tab_id;
1133 self.next_tab_id += 1;
1134 let index = self.pages.len();
1135 let label = label.map(|s| s.to_string());
1136 self.pages.push(PageInfo {
1137 tab_id,
1138 label: label.clone(),
1139 target_id: result.target_id,
1140 session_id: attach.session_id,
1141 url: target_url.to_string(),
1142 title: String::new(),
1143 target_type: "page".to_string(),
1144 });
1145 self.active_page_index = index;
1146
1147 let mut out = json!({
1148 "tabId": format_tab_id(tab_id),
1149 "index": index,
1150 "label": label,
1151 "url": target_url,
1152 "total": self.pages.len(),
1153 });
1154 if let Some(ctx) = browser_context_id {
1155 if let Some(obj) = out.as_object_mut() {
1156 obj.insert("browser_context_id".into(), json!(ctx));
1157 }
1158 }
1159 Ok(out)
1160 }
1161
1162 pub async fn create_browser_context(&self) -> Result<String, String> {
1164 let result = self
1165 .client
1166 .send_command("Browser.createBrowserContext", None, None)
1167 .await?;
1168 result
1169 .get("browserContextId")
1170 .and_then(|v| v.as_str())
1171 .map(|s| s.to_string())
1172 .ok_or_else(|| "Browser.createBrowserContext missing browserContextId".to_string())
1173 }
1174
1175 pub async fn tab_switch(&mut self, index: usize) -> Result<Value, String> {
1176 if index >= self.pages.len() {
1177 return Err(format!(
1178 "Tab index {} out of range (0-{})",
1179 index,
1180 self.pages.len().saturating_sub(1)
1181 ));
1182 }
1183
1184 self.active_page_index = index;
1185 let session_id = self.pages[index].session_id.clone();
1186 self.enable_domains(&session_id).await?;
1187
1188 let _ = self
1190 .client
1191 .send_command("Page.bringToFront", None, Some(&session_id))
1192 .await;
1193
1194 let url = self.get_url().await.unwrap_or_default();
1195 let title = self.get_title().await.unwrap_or_default();
1196
1197 if let Some(page) = self.pages.get_mut(index) {
1198 page.url = url.clone();
1199 page.title = title.clone();
1200 }
1201
1202 let page = &self.pages[index];
1203 Ok(json!({
1204 "tabId": format_tab_id(page.tab_id),
1205 "label": page.label,
1206 "url": url,
1207 "title": title,
1208 }))
1209 }
1210
1211 pub async fn tab_close(&mut self, index: Option<usize>) -> Result<Value, String> {
1212 let target_index = index.unwrap_or(self.active_page_index);
1213
1214 if target_index >= self.pages.len() {
1215 return Err(format!("Tab index {} out of range", target_index));
1216 }
1217
1218 if self.pages.len() <= 1 {
1219 return Err("Cannot close the last tab".to_string());
1220 }
1221
1222 let page = self.pages.remove(target_index);
1223 self.update_active_page_after_removal(target_index);
1224 let closed_tab_id = page.tab_id;
1225 let closed_label = page.label.clone();
1226 let _ = self
1227 .client
1228 .send_command_typed::<_, Value>(
1229 "Target.closeTarget",
1230 &CloseTargetParams {
1231 target_id: page.target_id,
1232 },
1233 None,
1234 )
1235 .await;
1236
1237 let session_id = self.pages[self.active_page_index].session_id.clone();
1238 self.enable_domains(&session_id).await?;
1239
1240 Ok(json!({
1241 "tabId": format_tab_id(closed_tab_id),
1242 "label": closed_label,
1243 "closed": true,
1244 }))
1245 }
1246
1247 pub async fn set_viewport(
1252 &self,
1253 width: i32,
1254 height: i32,
1255 device_scale_factor: f64,
1256 mobile: bool,
1257 ) -> Result<(), String> {
1258 let session_id = self.active_session_id()?;
1259 self.client
1260 .send_command(
1261 "Emulation.setDeviceMetricsOverride",
1262 Some(json!({
1263 "width": width,
1264 "height": height,
1265 "deviceScaleFactor": device_scale_factor,
1266 "mobile": mobile,
1267 })),
1268 Some(session_id),
1269 )
1270 .await?;
1271
1272 if let Ok(target_id) = self.active_target_id() {
1275 if let Ok(window_info) = self
1276 .client
1277 .send_command(
1278 "Browser.getWindowForTarget",
1279 Some(json!({ "targetId": target_id })),
1280 None,
1281 )
1282 .await
1283 {
1284 if let Some(window_id) = window_info.get("windowId").and_then(|v| v.as_i64()) {
1285 if let Err(e) = self
1286 .client
1287 .send_command(
1288 "Browser.setContentsSize",
1289 Some(json!({
1290 "windowId": window_id,
1291 "width": width,
1292 "height": height,
1293 })),
1294 None,
1295 )
1296 .await
1297 {
1298 tracing::warn!(
1299 target: "browser_automation_cli::native::browser",
1300 error = %e,
1301 "Browser.setContentsSize failed (experimental CDP)"
1302 );
1303 }
1304 }
1305 }
1306 }
1307
1308 Ok(())
1309 }
1310
1311 pub async fn set_user_agent(&self, user_agent: &str) -> Result<(), String> {
1312 let session_id = self.active_session_id()?;
1313 self.client
1314 .send_command(
1315 "Emulation.setUserAgentOverride",
1316 Some(json!({ "userAgent": user_agent })),
1317 Some(session_id),
1318 )
1319 .await?;
1320 Ok(())
1321 }
1322
1323 pub async fn set_emulated_media(
1324 &self,
1325 media: Option<&str>,
1326 features: Option<Vec<(String, String)>>,
1327 ) -> Result<(), String> {
1328 let session_id = self.active_session_id()?;
1329 let mut params = json!({});
1330 if let Some(m) = media {
1331 params["media"] = Value::String(m.to_string());
1332 }
1333 if let Some(feats) = features {
1334 let features_arr: Vec<Value> = feats
1335 .iter()
1336 .map(|(name, value)| json!({ "name": name, "value": value }))
1337 .collect();
1338 params["features"] = Value::Array(features_arr);
1339 }
1340 self.client
1341 .send_command("Emulation.setEmulatedMedia", Some(params), Some(session_id))
1342 .await?;
1343 Ok(())
1344 }
1345
1346 pub async fn bring_to_front(&self) -> Result<(), String> {
1347 let session_id = self.active_session_id()?;
1348 self.client
1349 .send_command("Page.bringToFront", None, Some(session_id))
1350 .await?;
1351 Ok(())
1352 }
1353
1354 pub async fn set_timezone(&self, timezone_id: &str) -> Result<(), String> {
1355 let session_id = self.active_session_id()?;
1356 self.client
1357 .send_command(
1358 "Emulation.setTimezoneOverride",
1359 Some(json!({ "timezoneId": timezone_id })),
1360 Some(session_id),
1361 )
1362 .await?;
1363 Ok(())
1364 }
1365
1366 pub async fn set_locale(&self, locale: &str) -> Result<(), String> {
1367 let session_id = self.active_session_id()?;
1368 self.client
1369 .send_command(
1370 "Emulation.setLocaleOverride",
1371 Some(json!({ "locale": locale })),
1372 Some(session_id),
1373 )
1374 .await?;
1375 Ok(())
1376 }
1377
1378 pub async fn set_geolocation(
1379 &self,
1380 latitude: f64,
1381 longitude: f64,
1382 accuracy: Option<f64>,
1383 ) -> Result<(), String> {
1384 let session_id = self.active_session_id()?;
1385 self.client
1386 .send_command(
1387 "Emulation.setGeolocationOverride",
1388 Some(json!({
1389 "latitude": latitude,
1390 "longitude": longitude,
1391 "accuracy": accuracy.unwrap_or(1.0),
1392 })),
1393 Some(session_id),
1394 )
1395 .await?;
1396 Ok(())
1397 }
1398
1399 pub async fn grant_permissions(&self, permissions: &[String]) -> Result<(), String> {
1400 self.client
1401 .send_command(
1402 "Browser.grantPermissions",
1403 Some(json!({ "permissions": permissions })),
1404 None,
1405 )
1406 .await?;
1407 Ok(())
1408 }
1409
1410 pub async fn handle_dialog(
1411 &self,
1412 accept: bool,
1413 prompt_text: Option<&str>,
1414 ) -> Result<(), String> {
1415 let session_id = self.active_session_id()?;
1416 let mut params = json!({ "accept": accept });
1417 if let Some(text) = prompt_text {
1418 params["promptText"] = Value::String(text.to_string());
1419 }
1420 self.client
1421 .send_command(
1422 "Page.handleJavaScriptDialog",
1423 Some(params),
1424 Some(session_id),
1425 )
1426 .await?;
1427 Ok(())
1428 }
1429
1430 pub async fn upload_files(
1431 &self,
1432 selector: &str,
1433 files: &[String],
1434 ref_map: &RefMap,
1435 iframe_sessions: &rustc_hash::FxHashMap<String, String>,
1436 ) -> Result<(), String> {
1437 let session_id = self.active_session_id()?;
1438
1439 let (object_id, effective_session_id) =
1440 resolve_element_object_id(&self.client, session_id, ref_map, selector, iframe_sessions)
1441 .await?;
1442
1443 let describe: Value = self
1444 .client
1445 .send_command(
1446 "DOM.describeNode",
1447 Some(json!({ "objectId": object_id })),
1448 Some(&effective_session_id),
1449 )
1450 .await?;
1451
1452 let backend_node_id = describe
1453 .get("node")
1454 .and_then(|n| n.get("backendNodeId"))
1455 .and_then(|v| v.as_i64())
1456 .ok_or("Could not get backendNodeId for file input")?;
1457
1458 self.client
1459 .send_command(
1460 "DOM.setFileInputFiles",
1461 Some(json!({
1462 "files": files,
1463 "backendNodeId": backend_node_id,
1464 })),
1465 Some(&effective_session_id),
1466 )
1467 .await?;
1468
1469 Ok(())
1470 }
1471
1472 pub async fn add_script_to_evaluate(&self, source: &str) -> Result<String, String> {
1473 let session_id = self.active_session_id()?;
1474 let result = self
1475 .client
1476 .send_command(
1477 "Page.addScriptToEvaluateOnNewDocument",
1478 Some(json!({ "source": source })),
1479 Some(session_id),
1480 )
1481 .await?;
1482 Ok(result
1483 .get("identifier")
1484 .and_then(|v| v.as_str())
1485 .unwrap_or("")
1486 .to_string())
1487 }
1488
1489 pub async fn remove_script_to_evaluate(&self, identifier: &str) -> Result<(), String> {
1490 let session_id = self.active_session_id()?;
1491 self.client
1492 .send_command(
1493 "Page.removeScriptToEvaluateOnNewDocument",
1494 Some(json!({ "identifier": identifier })),
1495 Some(session_id),
1496 )
1497 .await?;
1498 Ok(())
1499 }
1500
1501 pub async fn tab_switch_by_id(&mut self, tab_id: u32) -> Result<Value, String> {
1502 let index = self
1503 .pages
1504 .iter()
1505 .position(|p| p.tab_id == tab_id)
1506 .ok_or_else(|| format!("Tab ID {} not found", tab_id))?;
1507 self.tab_switch(index).await
1508 }
1509
1510 pub async fn tab_close_by_id(&mut self, tab_id: Option<u32>) -> Result<Value, String> {
1511 let index = match tab_id {
1512 Some(id) => Some(
1513 self.pages
1514 .iter()
1515 .position(|p| p.tab_id == id)
1516 .ok_or_else(|| format!("Tab ID {} not found", id))?,
1517 ),
1518 None => None,
1519 };
1520 self.tab_close(index).await
1521 }
1522
1523 pub fn assign_tab_id(&mut self) -> u32 {
1524 let id = self.next_tab_id;
1525 self.next_tab_id += 1;
1526 id
1527 }
1528
1529 pub fn add_page(&mut self, page: PageInfo) {
1530 let index = self.pages.len();
1531 self.pages.push(page);
1532 self.active_page_index = index;
1533 }
1534
1535 pub fn update_page_target_info(&mut self, target: &TargetInfo) -> bool {
1536 update_page_target_info_in_pages(&mut self.pages, target)
1537 }
1538
1539 pub fn remove_page_by_target_id(&mut self, target_id: &str) {
1540 if let Some(pos) = self.pages.iter().position(|p| p.target_id == target_id) {
1541 self.pages.remove(pos);
1542 self.update_active_page_after_removal(pos);
1543 }
1544 }
1545
1546 pub fn has_target(&self, target_id: &str) -> bool {
1547 self.pages.iter().any(|p| p.target_id == target_id)
1548 }
1549
1550 pub fn page_count(&self) -> usize {
1551 self.pages.len()
1552 }
1553
1554 pub fn active_tab_id(&self) -> Option<u32> {
1556 self.pages.get(self.active_page_index).map(|p| p.tab_id)
1557 }
1558
1559 pub fn has_tab_id(&self, tab_id: u32) -> bool {
1561 self.pages.iter().any(|p| p.tab_id == tab_id)
1562 }
1563
1564 pub fn pages_list(&self) -> Vec<PageInfo> {
1565 self.pages.clone()
1566 }
1567
1568 pub fn visited_origins(&self) -> &HashSet<String> {
1569 &self.visited_origins
1570 }
1571
1572 pub async fn set_download_behavior(&self, download_path: &str) -> Result<(), String> {
1573 let session_id = self.active_session_id()?;
1574 self.client
1575 .send_command(
1576 "Browser.setDownloadBehavior",
1577 Some(json!({
1578 "behavior": "allowAndName",
1579 "downloadPath": download_path,
1580 "eventsEnabled": true,
1581 })),
1582 Some(session_id),
1583 )
1584 .await?;
1585 Ok(())
1586 }
1587}
1588
1589async fn poll_network_idle(
1595 session_id: &str,
1596 rx: &mut broadcast::Receiver<CdpEvent>,
1597 overall_timeout: tokio::time::Duration,
1598) -> Result<(), String> {
1599 let mut pending = HashSet::<String>::new();
1602
1603 tokio::time::timeout(overall_timeout, async {
1604 let mut idle_start: Option<tokio::time::Instant> = None;
1605
1606 loop {
1607 let recv_result =
1608 tokio::time::timeout(tokio::time::Duration::from_millis(600), rx.recv()).await;
1609
1610 match recv_result {
1611 Ok(Ok(event))
1612 if event.session_id.is_none()
1613 || event.session_id.as_deref() == Some(session_id) =>
1614 {
1615 match event.method.as_str() {
1616 "Network.requestWillBeSent" => {
1617 if let Some(id) = event.params.get("requestId").and_then(|v| v.as_str())
1618 {
1619 pending.insert(id.to_string());
1620 idle_start = None;
1621 }
1622 }
1623 "Network.loadingFinished" | "Network.loadingFailed" => {
1624 if let Some(id) = event.params.get("requestId").and_then(|v| v.as_str())
1625 {
1626 pending.remove(id);
1627 if pending.is_empty() {
1628 idle_start = Some(tokio::time::Instant::now());
1629 }
1630 }
1631 }
1632 "Page.loadEventFired" if pending.is_empty() => {
1633 idle_start = Some(tokio::time::Instant::now());
1634 }
1635 _ => {}
1636 }
1637 }
1638 Ok(Ok(_)) => {}
1639 Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue,
1640 Ok(Err(_)) => break,
1641 Err(_) => {
1642 if pending.is_empty() && idle_start.is_none() {
1648 idle_start = Some(tokio::time::Instant::now());
1649 }
1650 }
1651 }
1652
1653 if let Some(start) = idle_start {
1654 if start.elapsed() >= tokio::time::Duration::from_millis(500) {
1655 return Ok(());
1656 }
1657 }
1658 }
1659
1660 Ok(())
1661 })
1662 .await
1663 .map_err(|_| "Timeout waiting for networkidle".to_string())?
1664}
1665
1666async fn attach_ws_with_retry(
1667 ws_url: &str,
1668 total_timeout: Duration,
1669 poll_interval: Duration,
1670) -> Result<CdpClient, String> {
1671 let deadline = Instant::now() + total_timeout;
1672
1673 loop {
1674 match CdpClient::connect(ws_url).await {
1675 Ok(client) => return Ok(client),
1676 Err(err) => {
1677 if Instant::now() >= deadline {
1678 return Err(err);
1679 }
1680 }
1681 }
1682
1683 tokio::time::sleep(poll_interval).await;
1684 }
1685}
1686
1687async fn initialize_lightpanda_manager(
1688 ws_url: String,
1689 process: BrowserProcess,
1690) -> Result<BrowserManager, String> {
1691 let deadline = Instant::now() + LIGHTPANDA_TARGET_INIT_TIMEOUT;
1692 let mut process = Some(process);
1693
1694 loop {
1695 let client = match attach_ws_with_retry(
1696 &ws_url,
1697 LIGHTPANDA_CDP_CONNECT_TIMEOUT,
1698 LIGHTPANDA_CDP_CONNECT_POLL_INTERVAL,
1699 )
1700 .await
1701 {
1702 Ok(client) => client,
1703 Err(err) => {
1704 if Instant::now() >= deadline {
1705 return Err(lightpanda_target_init_timeout(Some(&err)));
1706 }
1707 tokio::time::sleep(LIGHTPANDA_CDP_CONNECT_POLL_INTERVAL).await;
1708 continue;
1709 }
1710 };
1711
1712 let mut manager = BrowserManager {
1713 client: Arc::new(client),
1714 browser_process: None,
1715 owns_oxide_browser: false,
1716 ws_url: ws_url.clone(),
1717 pages: Vec::new(),
1718 active_page_index: 0,
1719 default_timeout_ms: 25_000,
1720 download_path: None,
1721 ignore_https_errors: false,
1722 visited_origins: HashSet::new(),
1723 next_tab_id: 1,
1724 direct_page: false,
1725 temp_user_data_dir: None,
1726 };
1727
1728 match discover_and_attach_lightpanda_targets(&mut manager, deadline).await {
1729 Ok(()) => {
1730 manager.browser_process = process.take();
1731 return Ok(manager);
1732 }
1733 Err(err) => {
1734 if Instant::now() >= deadline {
1735 return Err(lightpanda_target_init_timeout(Some(&err)));
1736 }
1737 tokio::time::sleep(LIGHTPANDA_CDP_CONNECT_POLL_INTERVAL).await;
1738 }
1739 }
1740 }
1741}
1742
1743async fn discover_and_attach_lightpanda_targets(
1744 manager: &mut BrowserManager,
1745 deadline: Instant,
1746) -> Result<(), String> {
1747 run_with_lightpanda_deadline(
1748 deadline,
1749 manager.discover_and_attach_targets(),
1750 "Target domain initialization attempt exceeded the remaining startup deadline",
1751 )
1752 .await
1753}
1754
1755fn remaining_until(deadline: Instant) -> Option<Duration> {
1756 deadline.checked_duration_since(Instant::now())
1757}
1758
1759async fn run_with_lightpanda_deadline<F, T>(
1760 deadline: Instant,
1761 operation: F,
1762 timeout_context: &'static str,
1763) -> Result<T, String>
1764where
1765 F: Future<Output = Result<T, String>>,
1766{
1767 let remaining = remaining_until(deadline)
1768 .ok_or_else(|| lightpanda_target_init_timeout(Some("deadline expired before retry")))?;
1769
1770 match tokio::time::timeout(remaining, operation).await {
1771 Ok(result) => result,
1772 Err(_) => Err(lightpanda_target_init_timeout(Some(timeout_context))),
1773 }
1774}
1775
1776fn lightpanda_target_init_timeout(last_error: Option<&str>) -> String {
1777 let mut message = format!(
1778 "Timed out after {}ms waiting for Lightpanda Target domain to initialize",
1779 LIGHTPANDA_TARGET_INIT_TIMEOUT.as_millis(),
1780 );
1781 if let Some(last_error) = last_error {
1782 message.push_str(&format!("\nLast error: {}", last_error));
1783 }
1784 message
1785}
1786
1787#[cfg(test)]
1788mod tests {
1789 use super::*;
1790 use tokio::time::sleep;
1791
1792 #[test]
1793 fn test_format_tab_id() {
1794 assert_eq!(format_tab_id(1), "t1");
1795 assert_eq!(format_tab_id(42), "t42");
1796 }
1797
1798 #[test]
1799 fn test_parse_tab_ref_id() {
1800 assert_eq!(TabRef::parse("t1"), Ok(TabRef::Id(1)));
1801 assert_eq!(TabRef::parse("t42"), Ok(TabRef::Id(42)));
1802 assert_eq!(TabRef::parse("T7"), Ok(TabRef::Id(7)));
1803 }
1804
1805 #[test]
1806 fn test_parse_tab_ref_label() {
1807 assert_eq!(TabRef::parse("docs"), Ok(TabRef::Label("docs".to_string())));
1808 assert_eq!(
1809 TabRef::parse("app-2"),
1810 Ok(TabRef::Label("app-2".to_string()))
1811 );
1812 assert_eq!(
1813 TabRef::parse("my_tab"),
1814 Ok(TabRef::Label("my_tab".to_string()))
1815 );
1816 }
1817
1818 #[test]
1819 fn test_parse_tab_ref_rejects_bare_integer() {
1820 let err = TabRef::parse("2").unwrap_err();
1821 assert!(
1822 err.contains("positional integers are not accepted"),
1823 "error should teach the user to use `t<N>`: {}",
1824 err
1825 );
1826 assert!(err.contains("t2"));
1827 }
1828
1829 #[test]
1830 fn test_parse_tab_ref_rejects_empty() {
1831 assert!(TabRef::parse("").is_err());
1832 assert!(TabRef::parse(" ").is_err());
1833 }
1834
1835 #[test]
1836 fn test_parse_tab_ref_rejects_zero() {
1837 let err = TabRef::parse("t0").unwrap_err();
1838 assert!(err.contains("start at t1"));
1839 }
1840
1841 #[test]
1842 fn test_parse_tab_ref_rejects_invalid_label() {
1843 assert!(TabRef::parse("2docs").is_err());
1844 assert!(TabRef::parse("-docs").is_err());
1845 assert!(TabRef::parse("docs!").is_err());
1846 assert!(TabRef::parse("docs space").is_err());
1847 }
1848
1849 #[test]
1850 fn test_is_valid_label() {
1851 assert!(is_valid_label("docs"));
1852 assert!(is_valid_label("Docs"));
1853 assert!(is_valid_label("app-2"));
1854 assert!(is_valid_label("my_tab"));
1855 assert!(!is_valid_label(""));
1856 assert!(!is_valid_label("2docs"));
1857 assert!(!is_valid_label("-docs"));
1858 assert!(!is_valid_label("docs!"));
1859 }
1860
1861 #[test]
1862 fn test_should_track_popup_target_with_empty_url() {
1863 let target = TargetInfo {
1864 target_id: "popup-1".to_string(),
1865 target_type: "page".to_string(),
1866 title: String::new(),
1867 url: String::new(),
1868 attached: None,
1869 browser_context_id: None,
1870 };
1871
1872 assert!(should_track_target(&target));
1873 }
1874
1875 #[test]
1876 fn test_should_not_track_internal_chrome_target() {
1877 let target = TargetInfo {
1878 target_id: "chrome-tab".to_string(),
1879 target_type: "page".to_string(),
1880 title: "New Tab".to_string(),
1881 url: "chrome://newtab/".to_string(),
1882 attached: None,
1883 browser_context_id: None,
1884 };
1885
1886 assert!(!should_track_target(&target));
1887 }
1888
1889 #[test]
1890 fn test_update_page_target_info_in_pages_updates_existing_page() {
1891 let mut pages = vec![PageInfo {
1892 tab_id: 1,
1893 label: None,
1894 target_id: "popup-1".to_string(),
1895 session_id: "session-1".to_string(),
1896 url: String::new(),
1897 title: String::new(),
1898 target_type: "page".to_string(),
1899 }];
1900 let target = TargetInfo {
1901 target_id: "popup-1".to_string(),
1902 target_type: "page".to_string(),
1903 title: "Popup".to_string(),
1904 url: "https://example.com/popup".to_string(),
1905 attached: None,
1906 browser_context_id: None,
1907 };
1908
1909 assert!(update_page_target_info_in_pages(&mut pages, &target));
1910 assert_eq!(pages[0].url, "https://example.com/popup");
1911 assert_eq!(pages[0].title, "Popup");
1912 }
1913
1914 #[test]
1915 fn test_active_page_index_after_removal_shifts_when_earlier_tab_is_removed() {
1916 assert_eq!(active_page_index_after_removal(2, 0, 3), 1);
1917 }
1918
1919 #[test]
1920 fn test_active_page_index_after_removal_keeps_same_slot_when_later_tab_is_removed() {
1921 assert_eq!(active_page_index_after_removal(1, 2, 3), 1);
1922 }
1923
1924 #[test]
1925 fn test_active_page_index_after_removal_clamps_when_active_last_tab_is_removed() {
1926 assert_eq!(active_page_index_after_removal(3, 3, 3), 2);
1927 }
1928
1929 #[test]
1930 fn test_active_page_index_after_removal_resets_when_last_page_disappears() {
1931 assert_eq!(active_page_index_after_removal(0, 0, 0), 0);
1932 }
1933
1934 #[test]
1935 fn test_validate_launch_options_extensions_and_cdp() {
1936 let ext = vec!["/path/to/ext".to_string()];
1937 assert!(validate_launch_options(Some(&ext), true, None, None, false, None,).is_err());
1938 }
1939
1940 #[test]
1941 fn test_validate_launch_options_profile_and_cdp() {
1942 assert!(validate_launch_options(None, true, Some("/path"), None, false, None,).is_err());
1943 }
1944
1945 #[test]
1946 fn test_validate_launch_options_storage_state_and_profile() {
1947 assert!(validate_launch_options(
1948 None,
1949 false,
1950 Some("/profile"),
1951 Some("/state.json"),
1952 false,
1953 None,
1954 )
1955 .is_err());
1956 }
1957
1958 #[test]
1959 fn test_validate_launch_options_storage_state_and_extensions() {
1960 let ext = vec!["/ext".to_string()];
1961 assert!(
1962 validate_launch_options(Some(&ext), false, None, Some("/state.json"), false, None,)
1963 .is_err()
1964 );
1965 }
1966
1967 #[test]
1968 fn test_validate_launch_options_allow_file_access_firefox() {
1969 assert!(
1970 validate_launch_options(None, false, None, None, true, Some("/usr/bin/firefox"),)
1971 .is_err()
1972 );
1973 }
1974
1975 #[test]
1976 fn test_validate_launch_options_valid() {
1977 assert!(validate_launch_options(None, false, None, None, false, None,).is_ok());
1978 }
1979
1980 #[test]
1981 fn test_to_ai_friendly_error_strict_mode() {
1982 assert_eq!(
1983 to_ai_friendly_error("Strict mode violation: multiple elements"),
1984 "Element matched multiple results. Use a more specific selector."
1985 );
1986 }
1987
1988 #[test]
1989 fn test_to_ai_friendly_error_not_visible() {
1990 assert_eq!(
1991 to_ai_friendly_error("element is not visible"),
1992 "Element exists but is not visible. Wait for it to become visible or scroll it into view."
1993 );
1994 }
1995
1996 #[test]
1997 fn test_to_ai_friendly_error_intercept() {
1998 assert_eq!(
1999 to_ai_friendly_error("element intercepted by another element"),
2000 "Another element is covering the target element. Try scrolling or closing overlays."
2001 );
2002 }
2003
2004 #[test]
2005 fn test_to_ai_friendly_error_timeout() {
2006 assert_eq!(
2007 to_ai_friendly_error("Timeout waiting for element"),
2008 "Operation timed out. The page may still be loading or the element may not exist."
2009 );
2010 }
2011
2012 #[test]
2013 fn test_to_ai_friendly_error_not_found() {
2014 assert_eq!(
2015 to_ai_friendly_error("Element not found"),
2016 "Element not found. Verify the selector is correct and the element exists in the DOM."
2017 );
2018 }
2019
2020 #[test]
2021 fn test_to_ai_friendly_error_unknown() {
2022 let msg = "Some custom error message";
2023 assert_eq!(to_ai_friendly_error(msg), msg);
2024 }
2025
2026 #[test]
2028 fn test_to_ai_friendly_error_ignores_non_element_not_found() {
2029 let err = "Chrome not found. Install Chrome or use --executable-path.";
2030 assert_eq!(to_ai_friendly_error(err), err);
2031 }
2032
2033 #[test]
2034 fn test_to_ai_friendly_error_catches_no_element() {
2035 let mapped =
2036 "Element not found. Verify the selector is correct and the element exists in the DOM.";
2037 assert_eq!(to_ai_friendly_error("No element found for css 'x'"), mapped);
2038 }
2039
2040 #[test]
2041 fn test_remaining_until_returns_none_for_past_deadline() {
2042 let deadline = Instant::now()
2043 .checked_sub(Duration::from_millis(1))
2044 .expect("past instant should be representable");
2045 assert!(remaining_until(deadline).is_none());
2046 }
2047
2048 #[tokio::test]
2049 async fn test_run_with_lightpanda_deadline_enforces_timeout() {
2050 let deadline = Instant::now() + Duration::from_millis(25);
2051 let err = tokio::time::timeout(
2052 Duration::from_secs(1),
2053 run_with_lightpanda_deadline(
2054 deadline,
2055 async {
2056 sleep(Duration::from_millis(100)).await;
2057 Ok::<(), String>(())
2058 },
2059 "Target domain initialization attempt exceeded the remaining startup deadline",
2060 ),
2061 )
2062 .await
2063 .expect("outer timeout should not fire")
2064 .unwrap_err();
2065
2066 assert!(err.contains(
2067 "Timed out after 10000ms waiting for Lightpanda Target domain to initialize"
2068 ));
2069 assert!(err.contains("remaining startup deadline"));
2070 }
2071
2072 #[tokio::test]
2073 async fn test_run_with_lightpanda_deadline_returns_operation_error() {
2074 let deadline = Instant::now() + Duration::from_secs(1);
2075 let err = run_with_lightpanda_deadline(
2076 deadline,
2077 async { Err::<(), String>("Target.getTargets failed".to_string()) },
2078 "unused timeout context",
2079 )
2080 .await
2081 .unwrap_err();
2082
2083 assert_eq!(err, "Target.getTargets failed");
2084 }
2085
2086 #[test]
2087 fn test_lightpanda_target_init_timeout_includes_last_error() {
2088 let err = lightpanda_target_init_timeout(Some("Target.setDiscoverTargets failed"));
2089 assert!(err.contains(
2090 "Timed out after 10000ms waiting for Lightpanda Target domain to initialize"
2091 ));
2092 assert!(err.contains("Target.setDiscoverTargets failed"));
2093 }
2094
2095 #[test]
2096 fn test_validate_lightpanda_rejects_webgpu() {
2097 let options = LaunchOptions {
2098 webgpu: true,
2099 ..Default::default()
2100 };
2101 let err = validate_lightpanda_options(&options).unwrap_err();
2102 assert!(err.contains("WebGPU"));
2103 assert!(validate_lightpanda_options(&LaunchOptions::default()).is_ok());
2104 }
2105
2106 #[test]
2107 fn test_is_internal_chrome_target() {
2108 assert!(is_internal_chrome_target("chrome://newtab/"));
2109 assert!(is_internal_chrome_target(
2110 "chrome://omnibox-popup.top-chrome/"
2111 ));
2112 assert!(is_internal_chrome_target(
2113 "chrome-extension://abc123/popup.html"
2114 ));
2115 assert!(is_internal_chrome_target(
2116 "devtools://devtools/bundled/inspector.html"
2117 ));
2118 assert!(!is_internal_chrome_target("https://example.com"));
2119 assert!(!is_internal_chrome_target("http://localhost:3000"));
2120 assert!(!is_internal_chrome_target("about:blank"));
2121 }
2122
2123 fn cdp_event(method: &str, session_id: &str, params: Value) -> CdpEvent {
2128 CdpEvent {
2129 method: method.to_string(),
2130 params,
2131 session_id: Some(session_id.to_string()),
2132 }
2133 }
2134
2135 #[tokio::test]
2139 async fn test_network_idle_no_events_does_not_return_instantly() {
2140 let (tx, mut rx) = broadcast::channel::<CdpEvent>(16);
2141 let session = "s1";
2142
2143 let start = tokio::time::Instant::now();
2144 let result = tokio::time::timeout(
2145 Duration::from_secs(5),
2146 poll_network_idle(session, &mut rx, Duration::from_secs(5)),
2147 )
2148 .await
2149 .expect("outer timeout should not fire");
2150
2151 assert!(result.is_ok());
2152 let elapsed = start.elapsed();
2153 assert!(
2154 elapsed >= Duration::from_millis(500),
2155 "network idle returned in {:?}, expected >= 500ms",
2156 elapsed
2157 );
2158
2159 drop(tx);
2160 }
2161
2162 #[tokio::test]
2165 async fn test_network_idle_after_requests_complete() {
2166 let (tx, mut rx) = broadcast::channel::<CdpEvent>(16);
2167 let session = "s1";
2168
2169 let _keep_alive = tx.clone();
2170 tokio::spawn(async move {
2171 sleep(Duration::from_millis(50)).await;
2172 let _ = tx.send(cdp_event(
2173 "Network.requestWillBeSent",
2174 session,
2175 json!({ "requestId": "r1" }),
2176 ));
2177 sleep(Duration::from_millis(100)).await;
2178 let _ = tx.send(cdp_event(
2179 "Network.loadingFinished",
2180 session,
2181 json!({ "requestId": "r1" }),
2182 ));
2183 });
2184
2185 let start = tokio::time::Instant::now();
2186 let result = tokio::time::timeout(
2187 Duration::from_secs(5),
2188 poll_network_idle(session, &mut rx, Duration::from_secs(5)),
2189 )
2190 .await
2191 .expect("outer timeout should not fire");
2192
2193 assert!(result.is_ok());
2194 let elapsed = start.elapsed();
2195 assert!(
2196 elapsed >= Duration::from_millis(500),
2197 "should wait >= 500ms after last request finishes, got {:?}",
2198 elapsed
2199 );
2200 }
2201
2202 #[tokio::test]
2204 async fn test_network_idle_resets_on_new_request() {
2205 let (tx, mut rx) = broadcast::channel::<CdpEvent>(16);
2206 let session = "s1";
2207
2208 let _keep_alive = tx.clone();
2209 tokio::spawn(async move {
2210 sleep(Duration::from_millis(50)).await;
2211 let _ = tx.send(cdp_event(
2212 "Network.requestWillBeSent",
2213 session,
2214 json!({ "requestId": "r1" }),
2215 ));
2216 sleep(Duration::from_millis(50)).await;
2217 let _ = tx.send(cdp_event(
2218 "Network.loadingFinished",
2219 session,
2220 json!({ "requestId": "r1" }),
2221 ));
2222 sleep(Duration::from_millis(200)).await;
2224 let _ = tx.send(cdp_event(
2225 "Network.requestWillBeSent",
2226 session,
2227 json!({ "requestId": "r2" }),
2228 ));
2229 sleep(Duration::from_millis(100)).await;
2230 let _ = tx.send(cdp_event(
2231 "Network.loadingFinished",
2232 session,
2233 json!({ "requestId": "r2" }),
2234 ));
2235 });
2236
2237 let start = tokio::time::Instant::now();
2238 let result = tokio::time::timeout(
2239 Duration::from_secs(5),
2240 poll_network_idle(session, &mut rx, Duration::from_secs(5)),
2241 )
2242 .await
2243 .expect("outer timeout should not fire");
2244
2245 assert!(result.is_ok());
2246 let elapsed = start.elapsed();
2247 assert!(
2249 elapsed >= Duration::from_millis(800),
2250 "should wait for idle after second request, got {:?}",
2251 elapsed
2252 );
2253 }
2254
2255 #[tokio::test]
2258 async fn test_network_idle_overall_timeout() {
2259 let (tx, mut rx) = broadcast::channel::<CdpEvent>(16);
2260 let session = "s1";
2261
2262 tokio::spawn(async move {
2264 for i in 0u64.. {
2265 let _ = tx.send(cdp_event(
2266 "Network.requestWillBeSent",
2267 session,
2268 json!({ "requestId": format!("r{}", i) }),
2269 ));
2270 sleep(Duration::from_millis(100)).await;
2271 }
2272 });
2273
2274 let result = poll_network_idle(session, &mut rx, Duration::from_millis(800)).await;
2275 assert!(result.is_err());
2276 assert!(result
2277 .unwrap_err()
2278 .contains("Timeout waiting for networkidle"));
2279 }
2280}