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