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