1#![allow(missing_docs)]
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12use serde_json::{json, Value};
13use tokio::sync::broadcast;
14
15use crate::error::{CliError, ErrorKind};
16use crate::lifecycle::Lifecycle;
17use crate::native::browser::{BrowserManager, WaitUntil};
18use crate::native::cdp::chrome::LaunchOptions;
19use crate::native::cdp::types::CdpEvent;
20use crate::native::cookies;
21use crate::native::element::{self, RefMap};
22use crate::native::interaction;
23use crate::native::network;
24use crate::native::snapshot::{self, SnapshotOptions};
25
26#[derive(Debug, Clone, Copy, Default)]
28pub struct CaptureOpts {
29 pub console: bool,
30 pub network: bool,
31}
32
33fn is_internal_browser_url(url: &str) -> bool {
35 url.starts_with("chrome:")
36 || url.starts_with("chrome-extension:")
37 || url.starts_with("devtools:")
38}
39
40fn is_noise_network_url(url: &str) -> bool {
42 is_internal_browser_url(url) || url.starts_with("data:") || url.starts_with("blob:")
43}
44
45pub struct OneShotSession {
47 manager: BrowserManager,
48 ref_map: RefMap,
49 iframe_sessions: HashMap<String, String>,
50 chrome_pid: Option<u32>,
51 capture: CaptureOpts,
52 event_rx: broadcast::Receiver<CdpEvent>,
53 console_log: Vec<Value>,
54 network_log: Vec<Value>,
55 perf_active: bool,
56 screencast_active: bool,
57 heap_chunks: Vec<String>,
58 trace_chunks: Vec<String>,
59 last_trace_path: Option<PathBuf>,
61 last_trace_body: Option<String>,
63 screencast_frames: Vec<String>,
65 screencast_dir: Option<PathBuf>,
67 screencast_ack_ids: Vec<i64>,
69 dialog_open: bool,
71 heap_snapshot_finished: bool,
73 tracing_complete: bool,
75 console_preserved: Vec<Vec<Value>>,
77 network_preserved: Vec<Vec<Value>>,
79 loaded_extension_ids: Vec<String>,
81}
82
83impl OneShotSession {
84 pub async fn launch_headless() -> Result<Self, CliError> {
86 Self::launch_headless_with_capture(CaptureOpts::default()).await
87 }
88
89 pub async fn launch_headless_with_capture(capture: CaptureOpts) -> Result<Self, CliError> {
90 let options = LaunchOptions {
91 headless: true,
92 hide_scrollbars: true,
93 ..LaunchOptions::default()
94 };
95 let manager = BrowserManager::launch(options, Some("chrome"))
96 .await
97 .map_err(|e| {
98 CliError::with_suggestion(
99 ErrorKind::Unavailable,
100 format!("Chrome launch failed: {e}"),
101 "Install system Chrome/Chromium or set executable path; re-run doctor",
102 )
103 })?;
104 let chrome_pid = manager.chrome_pid();
105 let event_rx = manager.client.subscribe();
106
107 let mut session = Self {
108 manager,
109 ref_map: RefMap::new(),
110 iframe_sessions: HashMap::new(),
111 chrome_pid,
112 capture,
113 event_rx,
114 console_log: Vec::new(),
115 network_log: Vec::new(),
116 perf_active: false,
117 screencast_active: false,
118 heap_chunks: Vec::new(),
119 trace_chunks: Vec::new(),
120 last_trace_path: None,
121 last_trace_body: None,
122 screencast_frames: Vec::new(),
123 screencast_dir: None,
124 screencast_ack_ids: Vec::new(),
125 dialog_open: false,
126 heap_snapshot_finished: false,
127 tracing_complete: false,
128 console_preserved: Vec::new(),
129 network_preserved: Vec::new(),
130 loaded_extension_ids: Vec::new(),
131 };
132 session.enable_capture_domains().await?;
133 Ok(session)
134 }
135
136 pub async fn launch_with_extensions(
138 capture: CaptureOpts,
139 extensions: Vec<String>,
140 ) -> Result<Self, CliError> {
141 if extensions.is_empty() {
142 return Self::launch_headless_with_capture(capture).await;
143 }
144 for p in &extensions {
145 let path = Path::new(p);
146 if !path.exists() {
147 return Err(CliError::with_suggestion(
148 ErrorKind::NoInput,
149 format!("extension path not found: {p}"),
150 "Pass an unpacked extension directory (contains manifest.json)",
151 ));
152 }
153 }
154 let options = LaunchOptions {
157 headless: false,
158 hide_scrollbars: true,
159 extensions: Some(extensions.clone()),
160 ..LaunchOptions::default()
161 };
162 let manager = BrowserManager::launch(options, Some("chrome"))
163 .await
164 .map_err(|e| {
165 CliError::with_suggestion(
166 ErrorKind::Unavailable,
167 format!("Chrome launch with extensions failed: {e}"),
168 "Use an unpacked extension dir; ensure Xvfb is available on Linux headed launches",
169 )
170 })?;
171 let chrome_pid = manager.chrome_pid();
172 let event_rx = manager.client.subscribe();
173 let mut session = Self {
174 manager,
175 ref_map: RefMap::new(),
176 iframe_sessions: HashMap::new(),
177 chrome_pid,
178 capture,
179 event_rx,
180 console_log: Vec::new(),
181 network_log: Vec::new(),
182 perf_active: false,
183 screencast_active: false,
184 heap_chunks: Vec::new(),
185 trace_chunks: Vec::new(),
186 last_trace_path: None,
187 last_trace_body: None,
188 screencast_frames: Vec::new(),
189 screencast_dir: None,
190 screencast_ack_ids: Vec::new(),
191 dialog_open: false,
192 heap_snapshot_finished: false,
193 tracing_complete: false,
194 console_preserved: Vec::new(),
195 network_preserved: Vec::new(),
196 loaded_extension_ids: Vec::new(),
197 };
198 session.loaded_extension_ids = extensions
200 .iter()
201 .filter_map(|p| {
202 Path::new(p)
203 .file_name()
204 .map(|s| s.to_string_lossy().into_owned())
205 })
206 .collect();
207 session.enable_capture_domains().await?;
208 if let Ok(list) = session.extension_list().await {
210 if let Some(arr) = list.get("extensions").and_then(|v| v.as_array()) {
211 for t in arr {
212 if let Some(id) = t.get("id").and_then(|v| v.as_str()) {
213 if !id.is_empty() && !session.loaded_extension_ids.iter().any(|x| x == id) {
214 session.loaded_extension_ids.push(id.to_string());
215 }
216 }
217 }
218 }
219 }
220 Ok(session)
221 }
222
223 pub fn chrome_pid(&self) -> Option<u32> {
224 self.chrome_pid
225 }
226
227 pub fn capture(&self) -> CaptureOpts {
228 self.capture
229 }
230
231 async fn enable_capture_domains(&mut self) -> Result<(), CliError> {
232 let session_id = self
233 .manager
234 .active_session_id()
235 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
236 .to_string();
237
238 let _ = self
241 .manager
242 .client
243 .send_command_no_params("Page.enable", Some(&session_id))
244 .await;
245 let _ = self.manager.client.attach_page_session_forwarders().await;
246
247 if self.capture.console {
248 self.manager
249 .client
250 .send_command_no_params("Runtime.enable", Some(&session_id))
251 .await
252 .map_err(|e| CliError::new(ErrorKind::Protocol, format!("Runtime.enable: {e}")))?;
253 let _ = self.manager.client.attach_page_console_forwarders().await;
255 }
256 if self.capture.network {
257 self.manager
258 .client
259 .send_command_no_params("Network.enable", Some(&session_id))
260 .await
261 .map_err(|e| CliError::new(ErrorKind::Protocol, format!("Network.enable: {e}")))?;
262 let _ = self
264 .manager
265 .client
266 .send_command_no_params("Network.enable", None)
267 .await;
268 let _ = self.manager.client.attach_page_network_forwarders().await;
269 }
270 Ok(())
271 }
272
273 pub fn with_capture_fields(&mut self, mut data: Value) -> Value {
275 self.drain_events();
276 if let Some(obj) = data.as_object_mut() {
277 if self.capture.console {
278 obj.insert("console".to_string(), json!(self.console_log.clone()));
279 obj.insert("console_count".to_string(), json!(self.console_log.len()));
280 }
281 if self.capture.network {
282 obj.insert("network".to_string(), json!(self.network_log.clone()));
283 obj.insert("network_count".to_string(), json!(self.network_log.len()));
284 }
285 }
286 data
287 }
288
289 pub fn drain_events(&mut self) {
291 loop {
292 match self.event_rx.try_recv() {
293 Ok(evt) => self.ingest_event(evt),
294 Err(broadcast::error::TryRecvError::Empty) => break,
295 Err(broadcast::error::TryRecvError::Lagged(_)) => continue,
296 Err(broadcast::error::TryRecvError::Closed) => break,
297 }
298 }
299 }
300
301 pub async fn pump_events(&mut self) {
303 self.drain_events();
304 let acks: Vec<i64> = self.screencast_ack_ids.drain(..).collect();
305 if acks.is_empty() {
306 return;
307 }
308 let session_id = self.manager.active_session_id().ok().map(|s| s.to_string());
309 for sid in acks {
310 let _ = self
311 .manager
312 .client
313 .send_command(
314 "Page.screencastFrameAck",
315 Some(json!({ "sessionId": sid })),
316 session_id.as_deref(),
317 )
318 .await;
319 }
320 }
321
322 fn ingest_event(&mut self, evt: CdpEvent) {
323 match evt.method.as_str() {
324 "Runtime.consoleAPICalled" if self.capture.console => {
325 let level = evt
326 .params
327 .get("type")
328 .and_then(|v| v.as_str())
329 .unwrap_or("log")
330 .to_string();
331 let raw_args: Vec<Value> = evt
332 .params
333 .get("args")
334 .and_then(|v| v.as_array())
335 .cloned()
336 .unwrap_or_default();
337 let text = network::format_console_args(&raw_args);
338 self.console_log.push(json!({
339 "type": level,
340 "text": text,
341 "args": raw_args,
342 }));
343 }
344 "Network.requestWillBeSent" if self.capture.network => {
345 let request = evt.params.get("request").cloned().unwrap_or(Value::Null);
346 let method = request
347 .get("method")
348 .and_then(|v| v.as_str())
349 .unwrap_or("GET");
350 let url = request.get("url").and_then(|v| v.as_str()).unwrap_or("");
351 if is_noise_network_url(url) {
352 return;
353 }
354 let request_id = evt
355 .params
356 .get("requestId")
357 .and_then(|v| v.as_str())
358 .unwrap_or("");
359 self.network_log.push(json!({
360 "requestId": request_id,
361 "method": method,
362 "url": url,
363 }));
364 }
365 "HeapProfiler.addHeapSnapshotChunk" => {
366 if let Some(chunk) = evt.params.get("chunk").and_then(|v| v.as_str()) {
367 self.heap_chunks.push(chunk.to_string());
368 }
369 }
370 "HeapProfiler.reportHeapSnapshotProgress" => {
371 if evt
372 .params
373 .get("finished")
374 .and_then(|v| v.as_bool())
375 .unwrap_or(false)
376 {
377 self.heap_snapshot_finished = true;
378 }
379 }
380 "Tracing.dataCollected" => {
381 if let Some(value) = evt.params.get("value") {
382 if let Some(arr) = value.as_array() {
384 for item in arr {
385 self.trace_chunks
386 .push(serde_json::to_string(item).unwrap_or_default());
387 }
388 } else {
389 self.trace_chunks
390 .push(serde_json::to_string(value).unwrap_or_default());
391 }
392 }
393 }
394 "Tracing.tracingComplete" => {
395 self.tracing_complete = true;
396 }
397 "Page.screencastFrame" => {
398 if let Some(data) = evt.params.get("data").and_then(|v| v.as_str()) {
399 if self.screencast_frames.len() < 600 {
401 self.screencast_frames.push(data.to_string());
402 }
403 }
404 if let Some(sid) = evt.params.get("sessionId").and_then(|v| v.as_i64()) {
405 self.screencast_ack_ids.push(sid);
406 }
407 }
408 "Page.javascriptDialogOpening" => {
409 self.dialog_open = true;
410 }
411 "Page.javascriptDialogClosed" => {
412 self.dialog_open = false;
413 }
414 _ => {}
417 }
418 }
419
420 pub async fn goto(
422 &mut self,
423 url: &str,
424 robots: crate::robots::RobotsPolicy,
425 ) -> Result<Value, CliError> {
426 self.goto_with_options(url, robots, None, false, None).await
427 }
428
429 pub async fn goto_with_options(
437 &mut self,
438 url: &str,
439 robots: crate::robots::RobotsPolicy,
440 init_script: Option<&str>,
441 handle_before_unload: bool,
442 navigation_timeout_ms: Option<u64>,
443 ) -> Result<Value, CliError> {
444 crate::robots::enforce_robots(url, robots, "browser-automation-cli").await?;
445 self.ref_map.clear();
446
447 self.preserve_capture_snapshot();
449
450 let mut init_script_id: Option<String> = None;
451 if let Some(js) = init_script {
452 let id = self.manager.add_script_to_evaluate(js).await.map_err(|e| {
453 CliError::with_suggestion(
454 ErrorKind::Browser,
455 format!("init_script registration failed: {e}"),
456 "Pass valid JavaScript for --init-script",
457 )
458 })?;
459 if !id.is_empty() {
460 init_script_id = Some(id);
461 }
462 }
463
464 let dialog_action = if handle_before_unload {
466 Some("accept")
467 } else {
468 None
469 };
470
471 let nav_result = self
472 .navigate_with_dialog_pump(url, navigation_timeout_ms, dialog_action)
473 .await;
474
475 if let Some(id) = init_script_id.as_deref() {
477 let _ = self.manager.remove_script_to_evaluate(id).await;
478 }
479
480 nav_result?;
481
482 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
484 self.drain_events();
485 let page_url = self
486 .manager
487 .get_url()
488 .await
489 .unwrap_or_else(|_| url.to_string());
490 let title = self.manager.get_title().await.unwrap_or_default();
491 let data = json!({
492 "url": page_url,
493 "title": title,
494 "robots_policy": robots.as_str(),
495 "init_script_applied": init_script.is_some(),
496 "handle_before_unload": handle_before_unload,
497 "navigation_timeout_ms": navigation_timeout_ms,
498 });
499 Ok(self.with_capture_fields(data))
500 }
501
502 async fn navigate_with_dialog_pump(
507 &mut self,
508 url: &str,
509 navigation_timeout_ms: Option<u64>,
510 dialog_action: Option<&str>,
511 ) -> Result<(), CliError> {
512 let dialog_task = if let Some(action) = dialog_action {
513 let accept = !action.eq_ignore_ascii_case("dismiss");
514 let client = std::sync::Arc::clone(&self.manager.client);
515 let session_id = self
516 .manager
517 .active_session_id()
518 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
519 .to_string();
520 Some(tokio::spawn(async move {
521 loop {
522 tokio::time::sleep(std::time::Duration::from_millis(40)).await;
523 let _ = client
524 .send_command(
525 "Page.handleJavaScriptDialog",
526 Some(json!({ "accept": accept })),
527 Some(&session_id),
528 )
529 .await;
530 }
531 }))
532 } else {
533 None
534 };
535
536 let nav_fut = self.manager.navigate(url, WaitUntil::Load);
537 let nav_res = if let Some(ms) = navigation_timeout_ms {
538 match tokio::time::timeout(std::time::Duration::from_millis(ms), nav_fut).await {
539 Ok(r) => r,
540 Err(_) => {
541 if let Some(t) = dialog_task {
542 t.abort();
543 }
544 return Err(CliError::with_suggestion(
545 ErrorKind::Unavailable,
546 format!("Navigation timed out after {ms}ms"),
547 "Increase --navigation-timeout-ms or check network",
548 ));
549 }
550 }
551 } else {
552 nav_fut.await
553 };
554
555 if let Some(t) = dialog_task {
556 t.abort();
557 }
558 self.dialog_open = false;
559
560 nav_res.map(|_| ()).map_err(|e| {
561 CliError::with_suggestion(
562 ErrorKind::Browser,
563 format!("Navigation failed: {e}"),
564 "Check URL scheme and network; try about:blank for smoke",
565 )
566 })
567 }
568
569 pub fn temp_user_data_dir(&self) -> Option<std::path::PathBuf> {
571 self.manager.temp_user_data_dir().map(|p| p.to_path_buf())
572 }
573
574 pub async fn add_init_script(&self, source: &str) -> Result<String, CliError> {
576 self.manager
577 .add_script_to_evaluate(source)
578 .await
579 .map_err(|e| {
580 CliError::with_suggestion(
581 ErrorKind::Browser,
582 format!("init_script registration failed: {e}"),
583 "Pass valid JavaScript for --init-script",
584 )
585 })
586 }
587
588 pub fn active_tab_id_string(&self) -> Option<String> {
590 self.manager
591 .active_tab_id()
592 .map(crate::native::browser::format_tab_id)
593 }
594
595 fn preserve_capture_snapshot(&mut self) {
597 self.drain_events();
598 if !self.console_log.is_empty() {
599 self.console_preserved.push(self.console_log.clone());
600 if self.console_preserved.len() > 3 {
601 let drop_n = self.console_preserved.len() - 3;
602 self.console_preserved.drain(0..drop_n);
603 }
604 }
605 if !self.network_log.is_empty() {
606 self.network_preserved.push(self.network_log.clone());
607 if self.network_preserved.len() > 3 {
608 let drop_n = self.network_preserved.len() - 3;
609 self.network_preserved.drain(0..drop_n);
610 }
611 }
612 self.console_log.clear();
614 self.network_log.clear();
615 }
616
617 pub async fn scrape(
619 &mut self,
620 url: &str,
621 robots: crate::robots::RobotsPolicy,
622 ) -> Result<Value, CliError> {
623 let nav = self.goto(url, robots).await?;
624 let text_val = self
625 .eval(
626 "String((document.body && document.body.innerText) || '')",
627 None,
628 Some("accept"),
629 None,
630 )
631 .await
632 .unwrap_or_else(|_| json!({"result": ""}));
633 let text_s = match text_val.get("result") {
634 Some(Value::String(s)) => s.clone(),
635 Some(other) => other.to_string(),
636 None => String::new(),
637 };
638 let html_val = self
639 .eval(
640 "String(document.documentElement ? document.documentElement.outerHTML : '')",
641 None,
642 Some("accept"),
643 None,
644 )
645 .await
646 .unwrap_or_else(|_| json!({"result": ""}));
647 let html_s = match html_val.get("result") {
648 Some(Value::String(s)) => s.clone(),
649 Some(other) => other.to_string(),
650 None => String::new(),
651 };
652 Ok(json!({
653 "source_url": nav.get("url").cloned().unwrap_or(Value::String(url.to_string())),
654 "title": nav.get("title").cloned().unwrap_or(Value::String(String::new())),
655 "robots_policy": robots.as_str(),
656 "text": text_s,
657 "html": html_s,
658 "engine": "browser",
659 }))
660 }
661
662 pub async fn view(&mut self, verbose: bool) -> Result<Value, CliError> {
664 self.drain_events();
665 let session_id = self
666 .manager
667 .active_session_id()
668 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
669 .to_string();
670
671 let options = SnapshotOptions {
672 interactive: false,
673 compact: !verbose,
674 ..SnapshotOptions::default()
675 };
676
677 self.ref_map.clear();
678 let tree = snapshot::take_snapshot(
679 &self.manager.client,
680 &session_id,
681 &options,
682 &mut self.ref_map,
683 None,
684 &self.iframe_sessions,
685 )
686 .await
687 .map_err(|e| {
688 CliError::with_suggestion(
689 ErrorKind::Browser,
690 format!("view/snapshot failed: {e}"),
691 "Ensure the page finished loading; try goto then view in the same run",
692 )
693 })?;
694
695 let tree_at = tree_to_at_refs(&tree);
696 let url = self.manager.get_url().await.unwrap_or_default();
697 let title = self.manager.get_title().await.unwrap_or_default();
698
699 let entries = self.ref_map.entries_sorted();
700 let ref_count = entries.len();
701 let refs: serde_json::Map<String, Value> = entries
702 .into_iter()
703 .map(|(ref_id, entry)| {
704 let key = format!("@{ref_id}");
705 (
706 key,
707 json!({
708 "role": entry.role,
709 "name": entry.name,
710 "id": ref_id,
711 }),
712 )
713 })
714 .collect();
715
716 Ok(json!({
717 "tree": tree_at,
718 "url": url,
719 "title": title,
720 "refs": refs,
721 "ref_count": ref_count,
722 }))
723 }
724
725 pub(crate) async fn attach_snapshot_if(
727 &mut self,
728 include: bool,
729 mut data: Value,
730 ) -> Result<Value, CliError> {
731 if !include {
732 return Ok(data);
733 }
734 let snap = self.view(false).await?;
735 if let Some(obj) = data.as_object_mut() {
736 obj.insert("snapshot".to_string(), snap);
737 obj.insert("include_snapshot".to_string(), json!(true));
738 }
739 Ok(data)
740 }
741
742 pub async fn press(
743 &mut self,
744 target: &str,
745 dblclick: bool,
746 include_snapshot: bool,
747 ) -> Result<Value, CliError> {
748 self.drain_events();
749 let session_id = self
750 .manager
751 .active_session_id()
752 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
753 .to_string();
754
755 let result = if dblclick {
756 interaction::dblclick(
757 &self.manager.client,
758 &session_id,
759 &self.ref_map,
760 target,
761 &self.iframe_sessions,
762 )
763 .await
764 } else {
765 interaction::click(
766 &self.manager.client,
767 &session_id,
768 &self.ref_map,
769 target,
770 "left",
771 1,
772 &self.iframe_sessions,
773 )
774 .await
775 }
776 .map_err(|e| {
777 CliError::with_suggestion(
778 ErrorKind::Browser,
779 format!("press failed: {e}"),
780 "Use a CSS selector or @eN from view in the same process (run script)",
781 )
782 })?;
783
784 self.drain_events();
785 let data = json!({
786 "pressed": target,
787 "dblclick": dblclick,
788 "dialog_opened": result.dialog_opened,
789 });
790 self.attach_snapshot_if(include_snapshot, data).await
791 }
792
793 pub async fn write(
794 &mut self,
795 target: &str,
796 value: &str,
797 include_snapshot: bool,
798 ) -> Result<Value, CliError> {
799 self.drain_events();
800 let session_id = self
801 .manager
802 .active_session_id()
803 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
804 .to_string();
805
806 interaction::fill_smart(
807 &self.manager.client,
808 &session_id,
809 &self.ref_map,
810 target,
811 value,
812 &self.iframe_sessions,
813 )
814 .await
815 .map_err(|e| {
816 CliError::with_suggestion(
817 ErrorKind::Browser,
818 format!("write failed: {e}"),
819 "Use a CSS selector or @eN from view; select/checkbox/radio use fill_smart semantics",
820 )
821 })?;
822
823 self.drain_events();
824 let data = json!({
825 "written": target,
826 "value_len": value.len(),
827 "fill_mode": "smart",
828 });
829 self.attach_snapshot_if(include_snapshot, data).await
830 }
831
832 pub async fn click_at(
834 &mut self,
835 x: f64,
836 y: f64,
837 dblclick: bool,
838 include_snapshot: bool,
839 ) -> Result<Value, CliError> {
840 self.drain_events();
841 let session_id = self
842 .manager
843 .active_session_id()
844 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
845 .to_string();
846 let result = interaction::click_at(&self.manager.client, &session_id, x, y, dblclick)
847 .await
848 .map_err(|e| {
849 CliError::with_suggestion(
850 ErrorKind::Browser,
851 format!("click-at failed: {e}"),
852 "Coordinates are page CSS pixels; enable --experimental-vision",
853 )
854 })?;
855 self.drain_events();
856 let data = json!({
857 "clicked_at": { "x": x, "y": y },
858 "dblclick": dblclick,
859 "dialog_opened": result.dialog_opened,
860 });
861 self.attach_snapshot_if(include_snapshot, data).await
862 }
863
864 pub async fn keys(&mut self, key: &str, include_snapshot: bool) -> Result<Value, CliError> {
865 let session_id = self
866 .manager
867 .active_session_id()
868 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
869 .to_string();
870
871 interaction::press_key(&self.manager.client, &session_id, key)
872 .await
873 .map_err(|e| {
874 CliError::with_suggestion(
875 ErrorKind::Browser,
876 format!("keys failed: {e}"),
877 "Pass a CDP key name such as Enter, Tab, Escape, or ArrowDown",
878 )
879 })?;
880
881 self.drain_events();
882 let data = json!({ "key": key });
883 self.attach_snapshot_if(include_snapshot, data).await
884 }
885
886 pub async fn type_text(
887 &mut self,
888 target: Option<&str>,
889 text: &str,
890 clear: bool,
891 submit: Option<&str>,
892 focus_only: bool,
893 ) -> Result<Value, CliError> {
894 let session_id = self
895 .manager
896 .active_session_id()
897 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
898 .to_string();
899
900 let typed_target = if focus_only || target.is_none() {
901 if clear {
903 let _ =
905 interaction::press_key(&self.manager.client, &session_id, "Control+a").await;
906 let _ =
907 interaction::press_key(&self.manager.client, &session_id, "Backspace").await;
908 }
909 interaction::type_text_into_active_context(
910 &self.manager.client,
911 &session_id,
912 text,
913 None,
914 )
915 .await
916 .map_err(|e| {
917 CliError::with_suggestion(
918 ErrorKind::Browser,
919 format!("type (focus-only) failed: {e}"),
920 "Focus an input first or pass a CSS/@eN target",
921 )
922 })?;
923 target.unwrap_or("(focused)").to_string()
924 } else {
925 let t = match target {
926 Some(s) => s,
927 None => {
928 return Err(CliError::new(
929 ErrorKind::Usage,
930 "type requires --target or --focus-only",
931 ));
932 }
933 };
934 interaction::type_text(
935 &self.manager.client,
936 &session_id,
937 &self.ref_map,
938 t,
939 text,
940 clear,
941 None,
942 &self.iframe_sessions,
943 )
944 .await
945 .map_err(|e| {
946 CliError::with_suggestion(
947 ErrorKind::Browser,
948 format!("type failed: {e}"),
949 "Use a CSS selector or @eN from view in the same process",
950 )
951 })?;
952 t.to_string()
953 };
954
955 if let Some(key) = submit {
956 interaction::press_key(&self.manager.client, &session_id, key)
957 .await
958 .map_err(|e| {
959 CliError::with_suggestion(
960 ErrorKind::Browser,
961 format!("type --submit key failed: {e}"),
962 "Pass a CDP key such as Enter",
963 )
964 })?;
965 }
966
967 self.drain_events();
968 Ok(json!({
969 "typed": typed_target,
970 "text_len": text.len(),
971 "cleared": clear,
972 "submit": submit,
973 "focus_only": focus_only || target.is_none(),
974 }))
975 }
976
977 pub async fn eval_service_worker(
979 &mut self,
980 service_worker_id: &str,
981 expression: &str,
982 ) -> Result<Value, CliError> {
983 self.pump_events().await;
984 let listed = self.extension_list().await?;
985 let targets = listed
986 .get("extensions")
987 .and_then(|v| v.as_array())
988 .cloned()
989 .unwrap_or_default();
990 let match_t = targets.iter().find(|t| {
991 t.get("id")
992 .and_then(|v| v.as_str())
993 .map(|s| s == service_worker_id || s.starts_with(service_worker_id))
994 .unwrap_or(false)
995 && t.get("type").and_then(|v| v.as_str()) == Some("service_worker")
996 });
997 let Some(t) = match_t else {
998 return Err(CliError::with_suggestion(
999 ErrorKind::NoInput,
1000 format!("service_worker not found for id: {service_worker_id}"),
1001 "Use extension list; pass --service-worker-id from a loaded extension",
1002 ));
1003 };
1004 let target_id = t
1005 .get("targetId")
1006 .and_then(|v| v.as_str())
1007 .ok_or_else(|| CliError::new(ErrorKind::Browser, "missing targetId"))?
1008 .to_string();
1009 let attach = self
1010 .manager
1011 .client
1012 .send_command(
1013 "Target.attachToTarget",
1014 Some(json!({ "targetId": target_id, "flatten": true })),
1015 None,
1016 )
1017 .await
1018 .map_err(|e| CliError::new(ErrorKind::Browser, format!("attach SW: {e}")))?;
1019 let session_id = attach
1020 .get("sessionId")
1021 .and_then(|v| v.as_str())
1022 .map(|s| s.to_string());
1023 let result = self
1024 .manager
1025 .client
1026 .send_command(
1027 "Runtime.evaluate",
1028 Some(json!({
1029 "expression": expression,
1030 "returnByValue": true,
1031 "awaitPromise": true,
1032 })),
1033 session_id.as_deref(),
1034 )
1035 .await
1036 .map_err(|e| CliError::new(ErrorKind::Browser, format!("SW evaluate: {e}")))?;
1037 Ok(json!({
1038 "result": result.get("result").cloned().unwrap_or(result),
1039 "service_worker_id": service_worker_id,
1040 "targetId": target_id,
1041 }))
1042 }
1043
1044 pub async fn eval(
1045 &mut self,
1046 expression: &str,
1047 args_json: Option<&str>,
1048 dialog_action: Option<&str>,
1049 file_path: Option<&Path>,
1050 ) -> Result<Value, CliError> {
1051 use crate::native::cdp::types::{EvaluateParams, EvaluateResult};
1052
1053 let action = dialog_action.unwrap_or("accept");
1055 let _ = action; let session_id = self
1057 .manager
1058 .active_session_id()
1059 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1060 .to_string();
1061
1062 let expr = normalize_eval_expression(expression, args_json)?;
1065
1066 let client = std::sync::Arc::clone(&self.manager.client);
1067 let expr_owned = expr.clone();
1068 let mut eval_fut = Box::pin(async move {
1069 let result: EvaluateResult = client
1070 .send_command_typed(
1071 "Runtime.evaluate",
1072 &EvaluateParams {
1073 expression: expr_owned,
1074 return_by_value: Some(true),
1075 await_promise: Some(true),
1076 },
1077 Some(&session_id),
1078 )
1079 .await?;
1080 if let Some(ref details) = result.exception_details {
1081 let msg = details
1082 .exception
1083 .as_ref()
1084 .and_then(|e| e.description.as_deref())
1085 .unwrap_or(&details.text);
1086 return Err(format!("Evaluation error: {msg}"));
1087 }
1088 Ok(result.result.value.unwrap_or(Value::Null))
1089 });
1090 let v = loop {
1091 tokio::select! {
1092 res = &mut eval_fut => {
1093 break res.map_err(|e| {
1094 CliError::with_suggestion(
1095 ErrorKind::Browser,
1096 format!("eval failed: {e}"),
1097 "Check the JS expression; use return-by-value expressions",
1098 )
1099 })?;
1100 }
1101 _ = tokio::time::sleep(std::time::Duration::from_millis(40)) => {
1102 self.drain_events();
1103 if self.dialog_open {
1104 let accept = !action.eq_ignore_ascii_case("dismiss");
1105 let prompt = if action.eq_ignore_ascii_case("accept")
1106 || action.eq_ignore_ascii_case("dismiss")
1107 {
1108 None
1109 } else {
1110 Some(action)
1111 };
1112 let _ = self.manager.handle_dialog(accept, prompt).await;
1113 self.dialog_open = false;
1114 }
1115 }
1116 }
1117 };
1118 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1120 self.drain_events();
1121 let data = self.with_capture_fields(json!({ "result": v }));
1122 if let Some(path) = file_path {
1123 let body = serde_json::to_vec_pretty(&data).map_err(|e| {
1124 CliError::new(ErrorKind::Io, format!("eval serialize for file: {e}"))
1125 })?;
1126 std::fs::write(path, body).map_err(|e| {
1127 CliError::new(ErrorKind::Io, format!("eval write {}: {e}", path.display()))
1128 })?;
1129 }
1130 Ok(data)
1131 }
1132
1133 pub async fn wait_ms(&mut self, ms: u64) -> Result<Value, CliError> {
1134 let mut remaining = ms;
1136 while remaining > 0 {
1137 let slice = remaining.min(50);
1138 tokio::time::sleep(std::time::Duration::from_millis(slice)).await;
1139 remaining = remaining.saturating_sub(slice);
1140 if self.screencast_active {
1141 self.pump_events().await;
1142 } else {
1143 self.drain_events();
1144 }
1145 }
1146 Ok(json!({ "waited_ms": ms }))
1147 }
1148
1149 pub async fn print_pdf(&mut self, path: Option<&Path>) -> Result<Value, CliError> {
1151 use base64::Engine as _;
1152 self.drain_events();
1153 let session_id = self
1154 .manager
1155 .active_session_id()
1156 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1157 .to_string();
1158 let result: Value = self
1159 .manager
1160 .client
1161 .send_command(
1162 "Page.printToPDF",
1163 Some(json!({
1164 "printBackground": true,
1165 "preferCSSPageSize": true,
1166 })),
1167 Some(&session_id),
1168 )
1169 .await
1170 .map_err(|e| {
1171 CliError::with_suggestion(
1172 ErrorKind::Browser,
1173 format!("print-pdf failed: {e}"),
1174 "Ensure the page is loaded; use goto first in the same run",
1175 )
1176 })?;
1177 let b64 = result
1178 .get("data")
1179 .or_else(|| result.pointer("/result/data"))
1180 .and_then(|v| v.as_str())
1181 .ok_or_else(|| {
1182 CliError::new(
1183 ErrorKind::Browser,
1184 "print-pdf: missing base64 data in CDP result",
1185 )
1186 })?;
1187 let bytes = base64::engine::general_purpose::STANDARD
1188 .decode(b64)
1189 .map_err(|e| CliError::new(ErrorKind::Data, format!("print-pdf base64: {e}")))?;
1190 if bytes.len() < 5 || &bytes[0..4] != b"%PDF" {
1191 return Err(CliError::new(
1192 ErrorKind::Data,
1193 "print-pdf: result is not a valid PDF",
1194 ));
1195 }
1196 let out = path.map(|p| p.to_path_buf()).unwrap_or_else(|| {
1197 let stamp = std::time::SystemTime::now()
1198 .duration_since(std::time::UNIX_EPOCH)
1199 .map(|d| d.as_millis())
1200 .unwrap_or(0);
1201 std::path::PathBuf::from(format!("print-{stamp}.pdf"))
1202 });
1203 if let Some(parent) = out.parent() {
1204 if !parent.as_os_str().is_empty() {
1205 let _ = std::fs::create_dir_all(parent);
1206 }
1207 }
1208 std::fs::write(&out, &bytes).map_err(|e| {
1209 CliError::new(ErrorKind::Io, format!("write pdf {}: {e}", out.display()))
1210 })?;
1211 Ok(json!({
1212 "path": out.display().to_string(),
1213 "bytes": bytes.len(),
1214 "format": "pdf",
1215 }))
1216 }
1217
1218 pub async fn grab(
1219 &mut self,
1220 path: Option<&Path>,
1221 format: &str,
1222 full_page: bool,
1223 quality: Option<i32>,
1224 element: Option<&str>,
1225 ) -> Result<Value, CliError> {
1226 use crate::native::screenshot::{take_screenshot, ScreenshotOptions};
1227
1228 let session_id = self
1229 .manager
1230 .active_session_id()
1231 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1232 .to_string();
1233
1234 let out_path = path.map(|p| p.to_path_buf()).unwrap_or_else(|| {
1235 let stamp = std::time::SystemTime::now()
1236 .duration_since(std::time::UNIX_EPOCH)
1237 .map(|d| d.as_millis())
1238 .unwrap_or(0);
1239 std::path::PathBuf::from(format!("grab-{stamp}.{format}"))
1240 });
1241
1242 let options = ScreenshotOptions {
1243 path: Some(out_path.to_string_lossy().into_owned()),
1244 format: format.to_string(),
1245 full_page,
1246 quality,
1247 selector: element.map(|s| s.to_string()),
1248 ..ScreenshotOptions::default()
1249 };
1250
1251 let result = take_screenshot(
1252 &self.manager.client,
1253 &session_id,
1254 &self.ref_map,
1255 &options,
1256 &self.iframe_sessions,
1257 )
1258 .await
1259 .map_err(|e| {
1260 CliError::with_suggestion(
1261 ErrorKind::Browser,
1262 format!("grab failed: {e}"),
1263 "Ensure page is loaded; check write permissions for path",
1264 )
1265 })?;
1266
1267 let path_str = if result.path.is_empty() {
1268 out_path.to_string_lossy().into_owned()
1269 } else {
1270 result.path
1271 };
1272 let path_buf = std::path::PathBuf::from(&path_str);
1273 let written = path_buf.exists();
1274 let magic_ok = written && verify_image_magic(&path_buf, format);
1275 let byte_size = std::fs::metadata(&path_buf).map(|m| m.len()).unwrap_or(0);
1276
1277 Ok(json!({
1278 "path": path_str,
1279 "format": format,
1280 "written": written,
1281 "magic_ok": magic_ok,
1282 "byte_size": byte_size,
1283 "full_page": full_page,
1284 "quality": quality,
1285 "element": element,
1286 }))
1287 }
1288
1289 pub async fn extract(&mut self, target: &str, attr: Option<&str>) -> Result<Value, CliError> {
1292 self.drain_events();
1293 let session_id = self
1294 .manager
1295 .active_session_id()
1296 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1297 .to_string();
1298
1299 if let Some(name) = attr {
1300 let v = element::get_element_attribute(
1301 &self.manager.client,
1302 &session_id,
1303 &self.ref_map,
1304 target,
1305 name,
1306 &self.iframe_sessions,
1307 )
1308 .await
1309 .map_err(|e| {
1310 CliError::with_suggestion(
1311 ErrorKind::Browser,
1312 format!("extract attr failed: {e}"),
1313 "Use --ref @eN from view in the same run",
1314 )
1315 })?;
1316 Ok(json!({ "target": target, "attr": name, "value": v }))
1317 } else {
1318 let text = element::get_element_text(
1319 &self.manager.client,
1320 &session_id,
1321 &self.ref_map,
1322 target,
1323 &self.iframe_sessions,
1324 )
1325 .await
1326 .map_err(|e| {
1327 CliError::with_suggestion(
1328 ErrorKind::Browser,
1329 format!("extract text failed: {e}"),
1330 "Use --ref @eN from view in the same run",
1331 )
1332 })?;
1333 Ok(json!({ "target": target, "text": text }))
1334 }
1335 }
1336
1337 pub async fn attr(&mut self, target: &str, name: &str) -> Result<Value, CliError> {
1338 self.extract(target, Some(name)).await
1339 }
1340
1341 pub async fn text(&mut self, target: &str) -> Result<Value, CliError> {
1343 self.extract(target, None).await
1344 }
1345
1346 pub async fn scroll(
1348 &mut self,
1349 target: Option<&str>,
1350 delta_x: f64,
1351 delta_y: f64,
1352 ) -> Result<Value, CliError> {
1353 self.drain_events();
1354 let session_id = self
1355 .manager
1356 .active_session_id()
1357 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1358 .to_string();
1359 interaction::scroll(
1360 &self.manager.client,
1361 &session_id,
1362 &self.ref_map,
1363 target,
1364 delta_x,
1365 delta_y,
1366 &self.iframe_sessions,
1367 )
1368 .await
1369 .map_err(|e| {
1370 CliError::with_suggestion(
1371 ErrorKind::Browser,
1372 format!("scroll failed: {e}"),
1373 "Pass --target @eN from view, or omit target for window scroll",
1374 )
1375 })?;
1376 Ok(json!({
1377 "ok": true,
1378 "target": target,
1379 "delta_x": delta_x,
1380 "delta_y": delta_y,
1381 }))
1382 }
1383
1384 pub async fn cookie_list(&mut self, url: Option<&str>) -> Result<Value, CliError> {
1385 self.drain_events();
1386 let session_id = self
1387 .manager
1388 .active_session_id()
1389 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1390 .to_string();
1391 let cookies = if let Some(u) = url {
1392 cookies::get_cookies(&self.manager.client, &session_id, Some(vec![u.to_string()])).await
1393 } else {
1394 cookies::get_all_cookies(&self.manager.client, &session_id).await
1395 }
1396 .map_err(|e| CliError::new(ErrorKind::Browser, format!("cookie list failed: {e}")))?;
1397 Ok(json!({
1398 "cookies": cookies,
1399 "count": cookies.len(),
1400 "url_filter": url,
1401 }))
1402 }
1403
1404 pub async fn cookie_set(&mut self, cookies_json: &str) -> Result<Value, CliError> {
1405 self.drain_events();
1406 let session_id = self
1407 .manager
1408 .active_session_id()
1409 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1410 .to_string();
1411 let parsed: Value = serde_json::from_str(cookies_json).map_err(|e| {
1412 CliError::with_suggestion(
1413 ErrorKind::Usage,
1414 format!("cookie set JSON invalid: {e}"),
1415 r#"Use --json '[{"name":"a","value":"b","url":"https://example.com"}]'"#,
1416 )
1417 })?;
1418 let arr = parsed.as_array().ok_or_else(|| {
1419 CliError::with_suggestion(
1420 ErrorKind::Usage,
1421 "cookie set requires a JSON array",
1422 r#"Use --json '[{"name":"a","value":"b","url":"https://example.com"}]'"#,
1423 )
1424 })?;
1425 let current_url = self.manager.get_url().await.ok();
1426 cookies::set_cookies(
1427 &self.manager.client,
1428 &session_id,
1429 arr.clone(),
1430 current_url.as_deref(),
1431 )
1432 .await
1433 .map_err(|e| CliError::new(ErrorKind::Browser, format!("cookie set failed: {e}")))?;
1434 Ok(json!({ "ok": true, "set_count": arr.len() }))
1435 }
1436
1437 pub async fn cookie_clear(&mut self) -> Result<Value, CliError> {
1438 self.drain_events();
1439 let session_id = self
1440 .manager
1441 .active_session_id()
1442 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1443 .to_string();
1444 cookies::clear_cookies(&self.manager.client, &session_id)
1445 .await
1446 .map_err(|e| CliError::new(ErrorKind::Browser, format!("cookie clear failed: {e}")))?;
1447 Ok(json!({ "ok": true, "cleared": true }))
1448 }
1449
1450 pub async fn page_info(&mut self) -> Result<Value, CliError> {
1451 self.drain_events();
1452 let url = self.manager.get_url().await.unwrap_or_default();
1453 let title = self.manager.get_title().await.unwrap_or_default();
1454 Ok(json!({ "url": url, "title": title }))
1455 }
1456
1457 pub async fn assert_url(&mut self, value: &str, contains: bool) -> Result<Value, CliError> {
1458 self.drain_events();
1459 let url = self.manager.get_url().await.unwrap_or_default();
1460 let ok = if contains {
1461 url.contains(value)
1462 } else {
1463 url == value
1464 };
1465 if !ok {
1466 return Err(CliError::with_suggestion(
1467 ErrorKind::Data,
1468 format!(
1469 "assert url failed: got={url:?} expected contains={contains} value={value:?}"
1470 ),
1471 "Navigate first with goto in the same run",
1472 ));
1473 }
1474 Ok(json!({ "assert": "url", "ok": true, "url": url, "value": value, "contains": contains }))
1475 }
1476
1477 pub async fn assert_text(
1478 &mut self,
1479 value: &str,
1480 target: Option<&str>,
1481 ) -> Result<Value, CliError> {
1482 self.drain_events();
1483 let haystack = if let Some(t) = target {
1484 let session_id = self
1485 .manager
1486 .active_session_id()
1487 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1488 .to_string();
1489 element::get_element_text(
1490 &self.manager.client,
1491 &session_id,
1492 &self.ref_map,
1493 t,
1494 &self.iframe_sessions,
1495 )
1496 .await
1497 .map_err(|e| CliError::new(ErrorKind::Browser, format!("assert text: {e}")))?
1498 } else {
1499 let v = self
1500 .manager
1501 .evaluate("document.body ? document.body.innerText : ''", None)
1502 .await
1503 .map_err(|e| CliError::new(ErrorKind::Browser, format!("assert text: {e}")))?;
1504 v.as_str().unwrap_or("").to_string()
1505 };
1506
1507 if !haystack.contains(value) {
1508 return Err(CliError::with_suggestion(
1509 ErrorKind::Data,
1510 format!("assert text failed: value not found: {value:?}"),
1511 "Check view/extract in the same run; text match is substring",
1512 ));
1513 }
1514 Ok(json!({ "assert": "text", "ok": true, "value": value, "target": target }))
1515 }
1516
1517 pub async fn assert_console(&mut self, level: &str, max: u64) -> Result<Value, CliError> {
1518 if !self.capture.console {
1519 return Err(CliError::with_suggestion(
1520 ErrorKind::Usage,
1521 "assert console requires --capture-console on the same invocation",
1522 "browser-automation-cli --capture-console run --script audit.jsonl",
1523 ));
1524 }
1525 self.drain_events();
1526 let level_l = level.to_ascii_lowercase();
1527 let count = self
1528 .console_log
1529 .iter()
1530 .filter(|m| {
1531 m.get("type")
1532 .and_then(|v| v.as_str())
1533 .map(|t| t.eq_ignore_ascii_case(&level_l))
1534 .unwrap_or(false)
1535 })
1536 .count() as u64;
1537 if count > max {
1538 return Err(CliError::with_suggestion(
1539 ErrorKind::Data,
1540 format!("assert console failed: level={level} count={count} max={max}"),
1541 "Fix page console noise or raise --max",
1542 ));
1543 }
1544 Ok(json!({
1545 "assert": "console",
1546 "ok": true,
1547 "level": level,
1548 "count": count,
1549 "max": max,
1550 }))
1551 }
1552
1553 pub fn console_list(
1554 &mut self,
1555 page_idx: Option<usize>,
1556 page_size: Option<usize>,
1557 types: Option<&str>,
1558 include_preserved: bool,
1559 service_worker_id: Option<&str>,
1560 ) -> Result<Value, CliError> {
1561 if !self.capture.console {
1562 return Err(CliError::with_suggestion(
1563 ErrorKind::Usage,
1564 "console list requires --capture-console",
1565 "Pass --capture-console before run/console",
1566 ));
1567 }
1568 self.drain_events();
1569 let mut messages: Vec<Value> = Vec::new();
1570 let mut include_mode = "current_navigation";
1571 if include_preserved {
1572 for ring in &self.console_preserved {
1573 messages.extend(ring.iter().cloned());
1574 }
1575 messages.extend(self.console_log.iter().cloned());
1576 include_mode = if self.console_preserved.is_empty() {
1577 "process_local_only"
1578 } else {
1579 "preserved_ring"
1580 };
1581 } else {
1582 messages.extend(self.console_log.iter().cloned());
1583 }
1584 if let Some(types_csv) = types {
1585 let wanted: Vec<String> = types_csv
1586 .split(',')
1587 .map(|s| s.trim().to_ascii_lowercase())
1588 .filter(|s| !s.is_empty())
1589 .collect();
1590 if !wanted.is_empty() {
1591 messages.retain(|m| {
1592 let level = m
1593 .get("level")
1594 .or_else(|| m.get("type"))
1595 .and_then(|v| v.as_str())
1596 .unwrap_or("")
1597 .to_ascii_lowercase();
1598 wanted.iter().any(|w| level.contains(w))
1599 });
1600 }
1601 }
1602 if let Some(sw) = service_worker_id {
1603 messages.retain(|m| {
1604 m.get("service_worker_id")
1605 .and_then(|v| v.as_str())
1606 .map(|s| s == sw)
1607 .unwrap_or(false)
1608 });
1609 }
1610 let total = messages.len();
1611 let page = page_idx.unwrap_or(0);
1612 let size = page_size.unwrap_or(total.max(1));
1613 let start = page.saturating_mul(size).min(total);
1614 let end = (start + size).min(total);
1615 let page_msgs = messages[start..end].to_vec();
1616 Ok(json!({
1617 "messages": page_msgs,
1618 "count": page_msgs.len(),
1619 "total": total,
1620 "page_idx": page,
1621 "page_size": size,
1622 "include_preserved": include_preserved,
1623 "include_preserved_mode": include_mode,
1624 }))
1625 }
1626
1627 pub fn console_get(&mut self, id: usize) -> Result<Value, CliError> {
1628 let list = self.console_list(None, None, None, true, None)?;
1630 let total = list.get("total").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
1631 self.drain_events();
1633 self.console_log
1634 .get(id)
1635 .cloned()
1636 .map(|m| json!({ "id": id, "message": m }))
1637 .ok_or_else(|| {
1638 CliError::with_suggestion(
1639 ErrorKind::Data,
1640 format!("console message id {id} not found (count={total})"),
1641 "Use console list to inspect ids (0-based index)",
1642 )
1643 })
1644 }
1645
1646 pub fn console_clear(&mut self) -> Result<Value, CliError> {
1647 if !self.capture.console {
1648 return Err(CliError::with_suggestion(
1649 ErrorKind::Usage,
1650 "console clear requires --capture-console",
1651 "Pass --capture-console before run/console",
1652 ));
1653 }
1654 self.drain_events();
1655 let n = self.console_log.len();
1656 self.console_log.clear();
1657 Ok(json!({ "cleared": n }))
1658 }
1659
1660 pub fn console_dump(&mut self, path: &Path) -> Result<Value, CliError> {
1661 let data = self.console_list(None, None, None, true, None)?;
1662 let messages = self.console_log.clone();
1664 let _ = data;
1665 let mut body = String::new();
1666 for m in &messages {
1667 body.push_str(&serde_json::to_string(m).unwrap_or_default());
1668 body.push('\n');
1669 }
1670 if let Some(parent) = path.parent() {
1671 if !parent.as_os_str().is_empty() {
1672 std::fs::create_dir_all(parent).map_err(|e| {
1673 CliError::new(ErrorKind::Io, format!("console dump mkdir: {e}"))
1674 })?;
1675 }
1676 }
1677 std::fs::write(path, body.as_bytes())
1678 .map_err(|e| CliError::new(ErrorKind::Io, format!("console dump write: {e}")))?;
1679 Ok(json!({
1680 "path": path.to_string_lossy(),
1681 "count": messages.len(),
1682 }))
1683 }
1684
1685 pub fn net_list(
1686 &mut self,
1687 page_idx: Option<usize>,
1688 page_size: Option<usize>,
1689 resource_types: Option<&str>,
1690 include_preserved: bool,
1691 ) -> Result<Value, CliError> {
1692 if !self.capture.network {
1693 return Err(CliError::with_suggestion(
1694 ErrorKind::Usage,
1695 "net list requires --capture-network",
1696 "Pass --capture-network before run/net",
1697 ));
1698 }
1699 self.drain_events();
1700 let mut requests: Vec<Value> = Vec::new();
1701 let mut include_mode = "current_navigation";
1702 if include_preserved {
1703 for ring in &self.network_preserved {
1704 requests.extend(ring.iter().cloned());
1705 }
1706 requests.extend(self.network_log.iter().cloned());
1707 include_mode = if self.network_preserved.is_empty() {
1708 "process_local_only"
1709 } else {
1710 "preserved_ring"
1711 };
1712 } else {
1713 requests.extend(self.network_log.iter().cloned());
1714 }
1715 if let Some(types_csv) = resource_types {
1716 let wanted: Vec<String> = types_csv
1717 .split(',')
1718 .map(|s| s.trim().to_ascii_lowercase())
1719 .filter(|s| !s.is_empty())
1720 .collect();
1721 if !wanted.is_empty() {
1722 requests.retain(|r| {
1723 let rt = r
1724 .get("resource_type")
1725 .or_else(|| r.get("type"))
1726 .or_else(|| r.get("resourceType"))
1727 .and_then(|v| v.as_str())
1728 .unwrap_or("")
1729 .to_ascii_lowercase();
1730 wanted.iter().any(|w| rt.contains(w))
1731 });
1732 }
1733 }
1734 let total = requests.len();
1735 let page = page_idx.unwrap_or(0);
1736 let size = page_size.unwrap_or(total.max(1));
1737 let start = page.saturating_mul(size).min(total);
1738 let end = (start + size).min(total);
1739 let page_reqs = requests[start..end].to_vec();
1740 Ok(json!({
1741 "requests": page_reqs,
1742 "count": page_reqs.len(),
1743 "total": total,
1744 "page_idx": page,
1745 "page_size": size,
1746 "include_preserved": include_preserved,
1747 "include_preserved_mode": include_mode,
1748 }))
1749 }
1750
1751 pub fn net_get(
1753 &mut self,
1754 id: &str,
1755 request_path: Option<&Path>,
1756 response_path: Option<&Path>,
1757 ) -> Result<Value, CliError> {
1758 let _ = self.net_list(None, None, None, true)?;
1759 let requests = self.network_log.clone();
1760 let (index, req) = if let Ok(idx) = id.parse::<usize>() {
1761 let req = requests.get(idx).cloned().ok_or_else(|| {
1762 CliError::with_suggestion(
1763 ErrorKind::Data,
1764 format!(
1765 "network request index {idx} not found (count={})",
1766 requests.len()
1767 ),
1768 "Use net list; pass 0-based index or requestId string",
1769 )
1770 })?;
1771 (idx, req)
1772 } else {
1773 let (idx, req) = requests
1774 .iter()
1775 .enumerate()
1776 .find(|(_, r)| {
1777 r.get("requestId")
1778 .and_then(|v| v.as_str())
1779 .map(|rid| rid == id)
1780 .unwrap_or(false)
1781 })
1782 .map(|(i, r)| (i, r.clone()))
1783 .ok_or_else(|| {
1784 CliError::with_suggestion(
1785 ErrorKind::Data,
1786 format!(
1787 "network requestId {id} not found (count={})",
1788 requests.len()
1789 ),
1790 "Use net list; pass 0-based index or exact requestId",
1791 )
1792 })?;
1793 (idx, req)
1794 };
1795 let request_id = req
1796 .get("requestId")
1797 .and_then(|v| v.as_str())
1798 .unwrap_or("")
1799 .to_string();
1800 if let Some(p) = request_path {
1801 if let Some(parent) = p.parent() {
1802 if !parent.as_os_str().is_empty() {
1803 std::fs::create_dir_all(parent).map_err(|e| {
1804 CliError::new(ErrorKind::Io, format!("net get request-path mkdir: {e}"))
1805 })?;
1806 }
1807 }
1808 let body = serde_json::to_vec_pretty(&req)
1809 .map_err(|e| CliError::new(ErrorKind::Io, format!("net get serialize: {e}")))?;
1810 std::fs::write(p, body)
1811 .map_err(|e| CliError::new(ErrorKind::Io, format!("net get request-path: {e}")))?;
1812 }
1813 if let Some(p) = response_path {
1814 if let Some(parent) = p.parent() {
1815 if !parent.as_os_str().is_empty() {
1816 std::fs::create_dir_all(parent).map_err(|e| {
1817 CliError::new(ErrorKind::Io, format!("net get response-path mkdir: {e}"))
1818 })?;
1819 }
1820 }
1821 let body = serde_json::to_vec_pretty(&req)
1822 .map_err(|e| CliError::new(ErrorKind::Io, format!("net get serialize: {e}")))?;
1823 std::fs::write(p, body)
1824 .map_err(|e| CliError::new(ErrorKind::Io, format!("net get response-path: {e}")))?;
1825 }
1826 Ok(json!({
1827 "id": index,
1828 "requestId": request_id,
1829 "request": req,
1830 "request_path": request_path.map(|p| p.to_string_lossy().to_string()),
1831 "response_path": response_path.map(|p| p.to_string_lossy().to_string()),
1832 }))
1833 }
1834
1835 pub async fn dialog(
1836 &mut self,
1837 accept: bool,
1838 prompt_text: Option<&str>,
1839 ) -> Result<Value, CliError> {
1840 self.manager
1841 .handle_dialog(accept, prompt_text)
1842 .await
1843 .map_err(|e| {
1844 CliError::with_suggestion(
1845 ErrorKind::Browser,
1846 format!("dialog failed: {e}"),
1847 "Dialog must be open; use after press that triggers alert/confirm/prompt",
1848 )
1849 })?;
1850 Ok(json!({
1851 "dialog": if accept { "accept" } else { "dismiss" },
1852 "prompt_text": prompt_text,
1853 }))
1854 }
1855
1856 pub async fn hover(&mut self, target: &str, include_snapshot: bool) -> Result<Value, CliError> {
1857 self.drain_events();
1858 let session_id = self
1859 .manager
1860 .active_session_id()
1861 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1862 .to_string();
1863 interaction::hover(
1864 &self.manager.client,
1865 &session_id,
1866 &self.ref_map,
1867 target,
1868 &self.iframe_sessions,
1869 )
1870 .await
1871 .map_err(|e| {
1872 CliError::with_suggestion(
1873 ErrorKind::Browser,
1874 format!("hover failed: {e}"),
1875 "Use a CSS selector or @eN from view in the same process (run script)",
1876 )
1877 })?;
1878 self.drain_events();
1879 let data = json!({ "hovered": target });
1880 self.attach_snapshot_if(include_snapshot, data).await
1881 }
1882
1883 pub async fn drag(
1884 &mut self,
1885 from: &str,
1886 to: &str,
1887 include_snapshot: bool,
1888 ) -> Result<Value, CliError> {
1889 self.drain_events();
1890 let session_id = self
1891 .manager
1892 .active_session_id()
1893 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1894 .to_string();
1895 interaction::drag(
1896 &self.manager.client,
1897 &session_id,
1898 &self.ref_map,
1899 from,
1900 to,
1901 &self.iframe_sessions,
1902 )
1903 .await
1904 .map_err(|e| {
1905 CliError::with_suggestion(
1906 ErrorKind::Browser,
1907 format!("drag failed: {e}"),
1908 "Use two CSS selectors or @eN refs in the same frame",
1909 )
1910 })?;
1911 self.drain_events();
1912 let data = json!({ "dragged_from": from, "dragged_to": to });
1913 self.attach_snapshot_if(include_snapshot, data).await
1914 }
1915
1916 pub async fn fill_form(
1917 &mut self,
1918 fields: &[(String, String)],
1919 include_snapshot: bool,
1920 ) -> Result<Value, CliError> {
1921 let mut filled = Vec::new();
1922 for (target, value) in fields {
1923 self.write(target, value, false).await?;
1924 filled.push(json!({ "target": target, "value_len": value.len() }));
1925 }
1926 let data = json!({ "filled": filled, "count": filled.len() });
1927 self.attach_snapshot_if(include_snapshot, data).await
1928 }
1929
1930 pub async fn upload(
1931 &mut self,
1932 target: &str,
1933 path: &Path,
1934 include_snapshot: bool,
1935 ) -> Result<Value, CliError> {
1936 self.drain_events();
1937 if !path.is_file() {
1938 return Err(CliError::with_suggestion(
1939 ErrorKind::Usage,
1940 format!("upload path is not a regular file: {}", path.display()),
1941 "Pass a single regular file path (not a directory)",
1942 ));
1943 }
1944 let abs = path
1945 .canonicalize()
1946 .map_err(|e| CliError::new(ErrorKind::Io, format!("upload canonicalize: {e}")))?;
1947 self.manager
1948 .upload_files(
1949 target,
1950 &[abs.to_string_lossy().to_string()],
1951 &self.ref_map,
1952 &self.iframe_sessions,
1953 )
1954 .await
1955 .map_err(|e| {
1956 CliError::with_suggestion(
1957 ErrorKind::Browser,
1958 format!("upload failed: {e}"),
1959 "Target must be a file input; use CSS selector or @eN",
1960 )
1961 })?;
1962 self.drain_events();
1963 let data = json!({
1964 "uploaded": target,
1965 "path": abs.to_string_lossy(),
1966 });
1967 self.attach_snapshot_if(include_snapshot, data).await
1968 }
1969
1970 pub async fn back(&mut self) -> Result<Value, CliError> {
1971 self.history_nav("back").await
1972 }
1973
1974 pub async fn forward(&mut self) -> Result<Value, CliError> {
1975 self.history_nav("forward").await
1976 }
1977
1978 pub async fn reload_with_options(
1984 &mut self,
1985 ignore_cache: bool,
1986 init_script: Option<&str>,
1987 handle_before_unload: bool,
1988 ) -> Result<Value, CliError> {
1989 self.drain_events();
1990 self.preserve_capture_snapshot();
1991
1992 let mut init_script_id: Option<String> = None;
1993 if let Some(js) = init_script {
1994 let id = self.add_init_script(js).await?;
1995 if !id.is_empty() {
1996 init_script_id = Some(id);
1997 }
1998 }
1999
2000 let dialog_action = if handle_before_unload {
2001 Some("accept")
2002 } else {
2003 None
2004 };
2005
2006 let reload_result = self
2007 .reload_with_dialog_pump(ignore_cache, dialog_action)
2008 .await;
2009
2010 if let Some(id) = init_script_id.as_deref() {
2011 let _ = self.manager.remove_script_to_evaluate(id).await;
2012 }
2013
2014 reload_result?;
2015 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2016 self.drain_events();
2017 let url = self.manager.get_url().await.unwrap_or_default();
2018 let title = self.manager.get_title().await.unwrap_or_default();
2019 Ok(json!({
2020 "reloaded": true,
2021 "ignore_cache": ignore_cache,
2022 "init_script_applied": init_script.is_some(),
2023 "handle_before_unload": handle_before_unload,
2024 "url": url,
2025 "title": title,
2026 }))
2027 }
2028
2029 pub async fn reload(&mut self, ignore_cache: bool) -> Result<Value, CliError> {
2031 self.reload_with_options(ignore_cache, None, false).await
2032 }
2033
2034 async fn reload_with_dialog_pump(
2035 &mut self,
2036 ignore_cache: bool,
2037 dialog_action: Option<&str>,
2038 ) -> Result<(), CliError> {
2039 let session_id = self
2040 .manager
2041 .active_session_id()
2042 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
2043 .to_string();
2044 let client = std::sync::Arc::clone(&self.manager.client);
2045
2046 let dialog_task = if let Some(action) = dialog_action {
2047 let accept = !action.eq_ignore_ascii_case("dismiss");
2048 let client_d = std::sync::Arc::clone(&client);
2049 let sid = session_id.clone();
2050 Some(tokio::spawn(async move {
2051 loop {
2052 tokio::time::sleep(std::time::Duration::from_millis(40)).await;
2053 let _ = client_d
2054 .send_command(
2055 "Page.handleJavaScriptDialog",
2056 Some(json!({ "accept": accept })),
2057 Some(&sid),
2058 )
2059 .await;
2060 }
2061 }))
2062 } else {
2063 None
2064 };
2065
2066 let res = client
2067 .send_command(
2068 "Page.reload",
2069 Some(json!({ "ignoreCache": ignore_cache })),
2070 Some(&session_id),
2071 )
2072 .await
2073 .map(|_| ())
2074 .map_err(|e| CliError::new(ErrorKind::Browser, format!("reload failed: {e}")));
2075
2076 if let Some(t) = dialog_task {
2077 t.abort();
2078 }
2079 self.dialog_open = false;
2080 res
2081 }
2082
2083 async fn history_nav(&mut self, direction: &str) -> Result<Value, CliError> {
2084 self.drain_events();
2085 let script = match direction {
2086 "back" => "history.back(); 'ok'",
2087 "forward" => "history.forward(); 'ok'",
2088 _ => "null",
2089 };
2090 self.manager
2091 .evaluate(script, None)
2092 .await
2093 .map_err(|e| CliError::new(ErrorKind::Browser, format!("{direction} failed: {e}")))?;
2094 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2095 self.drain_events();
2096 let url = self.manager.get_url().await.unwrap_or_default();
2097 let title = self.manager.get_title().await.unwrap_or_default();
2098 Ok(json!({
2099 "navigation": direction,
2100 "url": url,
2101 "title": title,
2102 }))
2103 }
2104
2105 pub async fn page_list(&mut self) -> Result<Value, CliError> {
2106 self.drain_events();
2107 let pages: Vec<Value> = self
2108 .manager
2109 .pages_list()
2110 .into_iter()
2111 .map(|p| {
2112 json!({
2113 "tab_id": p.tab_id,
2114 "label": p.label,
2115 "url": p.url,
2116 "title": p.title,
2117 "target_type": p.target_type,
2118 })
2119 })
2120 .collect();
2121 let active = self.manager.active_tab_id();
2122 Ok(json!({
2123 "pages": pages,
2124 "count": pages.len(),
2125 "active_tab_id": active,
2126 }))
2127 }
2128
2129 pub async fn page_new(
2130 &mut self,
2131 url: Option<&str>,
2132 background: bool,
2133 isolated_context: bool,
2134 ) -> Result<Value, CliError> {
2135 self.drain_events();
2136 let mut isolation_note = None;
2137 let mut isolation_limitation = None;
2138 let ctx_id = if isolated_context {
2139 match self.manager.create_browser_context().await {
2140 Ok(id) => {
2141 isolation_note = Some(
2142 "isolated BrowserContext created for cookie/storage isolation within this one-shot process"
2143 .to_string(),
2144 );
2145 Some(id)
2146 }
2147 Err(e) => {
2148 isolation_limitation =
2151 Some("isolated_context_unsupported_on_this_browser".to_string());
2152 isolation_note = Some(format!(
2153 "isolatedContext requested but Browser.createBrowserContext unavailable ({e}); tab uses shared browser context"
2154 ));
2155 None
2156 }
2157 }
2158 } else {
2159 None
2160 };
2161 let mut data = self
2162 .manager
2163 .tab_new_in_context(url, None, ctx_id)
2164 .await
2165 .map_err(|e| CliError::new(ErrorKind::Browser, format!("page new failed: {e}")))?;
2166 if !background {
2168 if let Some(idx) = data.get("index").and_then(|v| v.as_u64()) {
2169 let _ = self.manager.tab_switch(idx as usize).await;
2170 }
2171 }
2172 if let Some(obj) = data.as_object_mut() {
2173 obj.insert("background".into(), json!(background));
2174 obj.insert("isolated_context".into(), json!(isolated_context));
2175 if let Some(n) = isolation_note {
2176 obj.insert("note".into(), json!(n));
2177 }
2178 if let Some(lim) = isolation_limitation {
2179 obj.insert("limitation".into(), json!(lim));
2180 }
2181 }
2182 self.drain_events();
2183 Ok(data)
2184 }
2185
2186 pub async fn page_select(
2187 &mut self,
2188 index: usize,
2189 bring_to_front: bool,
2190 ) -> Result<Value, CliError> {
2191 self.drain_events();
2192 let mut data =
2193 self.manager.tab_switch(index).await.map_err(|e| {
2194 CliError::new(ErrorKind::Browser, format!("page select failed: {e}"))
2195 })?;
2196 if bring_to_front {
2197 if let Ok(session_id) = self.manager.active_session_id() {
2198 let _ = self
2199 .manager
2200 .client
2201 .send_command("Page.bringToFront", None, Some(session_id))
2202 .await;
2203 }
2204 }
2205 if let Some(obj) = data.as_object_mut() {
2206 obj.insert("bring_to_front".into(), json!(bring_to_front));
2207 }
2208 self.ref_map.clear();
2209 self.drain_events();
2210 Ok(data)
2211 }
2212
2213 pub async fn page_close(&mut self, index: Option<usize>) -> Result<Value, CliError> {
2214 self.drain_events();
2215 let data =
2216 self.manager.tab_close(index).await.map_err(|e| {
2217 CliError::new(ErrorKind::Browser, format!("page close failed: {e}"))
2218 })?;
2219 self.ref_map.clear();
2220 self.drain_events();
2221 Ok(data)
2222 }
2223
2224 pub async fn wait_for(
2225 &mut self,
2226 ms: Option<u64>,
2227 text: Option<&str>,
2228 selector: Option<&str>,
2229 state: Option<&str>,
2230 include_snapshot: bool,
2231 ) -> Result<Value, CliError> {
2232 let owned: Vec<String> = text.map(|t| vec![t.to_string()]).unwrap_or_default();
2234 self.wait_for_any(ms, &owned, selector, state, include_snapshot)
2235 .await
2236 }
2237
2238 pub async fn wait_for_any(
2240 &mut self,
2241 ms: Option<u64>,
2242 texts: &[String],
2243 selector: Option<&str>,
2244 state: Option<&str>,
2245 include_snapshot: bool,
2246 ) -> Result<Value, CliError> {
2247 let mut waited = Vec::new();
2248 let has_text = !texts.is_empty();
2249
2250 if let Some(st) = state {
2251 let until = WaitUntil::parse_token(st);
2252 let session_id = self
2253 .manager
2254 .active_session_id()
2255 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
2256 .to_string();
2257 self.manager
2258 .wait_for_lifecycle_external(until, &session_id)
2259 .await
2260 .map_err(|e| {
2261 CliError::with_suggestion(
2262 ErrorKind::Timeout,
2263 format!("wait state {st} failed: {e}"),
2264 "Use --state load|domcontentloaded|networkidle|none",
2265 )
2266 })?;
2267 waited.push(json!({"kind": "state", "state": st}));
2268 }
2269
2270 if let Some(m) = ms {
2271 if m > 0 && !has_text && selector.is_none() && state.is_none() {
2272 let data = self.wait_ms(m).await?;
2273 return self.attach_snapshot_if(include_snapshot, data).await;
2274 }
2275 if m > 0 && !has_text && selector.is_none() && state.is_some() {
2276 if m > 0 {
2278 let _ = self.wait_ms(m).await?;
2279 waited.push(json!({"kind": "ms", "ms": m}));
2280 }
2281 let data = json!({ "waited": waited, "ok": true });
2282 return self.attach_snapshot_if(include_snapshot, data).await;
2283 }
2284 }
2285
2286 if !has_text && selector.is_none() && state.is_some() {
2287 let data = json!({ "waited": waited, "ok": true });
2288 return self.attach_snapshot_if(include_snapshot, data).await;
2289 }
2290
2291 if !has_text && selector.is_none() && state.is_none() {
2292 let data = self.wait_ms(ms.unwrap_or(0)).await?;
2293 return self.attach_snapshot_if(include_snapshot, data).await;
2294 }
2295
2296 let deadline = std::time::Instant::now()
2297 + std::time::Duration::from_millis(ms.unwrap_or(10_000).max(1));
2298 loop {
2299 self.drain_events();
2300 if let Some(sel) = selector {
2301 let session_id = self
2302 .manager
2303 .active_session_id()
2304 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
2305 .to_string();
2306 if element::get_element_count(&self.manager.client, &session_id, sel)
2307 .await
2308 .unwrap_or(0)
2309 > 0
2310 {
2311 waited.push(json!({"kind": "selector", "selector": sel}));
2312 let data = json!({ "waited": waited, "ok": true });
2313 return self.attach_snapshot_if(include_snapshot, data).await;
2314 }
2315 }
2316 if has_text {
2317 let body = self
2318 .manager
2319 .evaluate("document.body ? document.body.innerText : ''", None)
2320 .await
2321 .unwrap_or(json!(""));
2322 let hay = body.as_str().unwrap_or("");
2323 if let Some(t) = texts.iter().find(|t| hay.contains(t.as_str())) {
2324 waited.push(json!({"kind": "text", "text": t, "match": "any"}));
2325 let data = json!({ "waited": waited, "ok": true });
2326 return self.attach_snapshot_if(include_snapshot, data).await;
2327 }
2328 }
2329 if std::time::Instant::now() >= deadline {
2330 return Err(CliError::with_suggestion(
2331 ErrorKind::Timeout,
2332 "wait condition not met before deadline",
2333 "Increase --ms, set --state, or ensure page content is present",
2334 ));
2335 }
2336 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2337 }
2338 }
2339
2340 #[allow(clippy::too_many_arguments)]
2341 pub async fn emulate(
2342 &mut self,
2343 user_agent: Option<&str>,
2344 locale: Option<&str>,
2345 timezone: Option<&str>,
2346 offline: bool,
2347 latitude: Option<f64>,
2348 longitude: Option<f64>,
2349 media: Option<&str>,
2350 network_conditions: Option<&str>,
2351 cpu_throttling_rate: Option<f64>,
2352 color_scheme: Option<&str>,
2353 extra_headers_json: Option<&str>,
2354 viewport: Option<&str>,
2355 ) -> Result<Value, CliError> {
2356 self.drain_events();
2357 let session_id = self
2358 .manager
2359 .active_session_id()
2360 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
2361 .to_string();
2362 if let Some(ua) = user_agent {
2363 if ua.is_empty() {
2364 } else {
2366 self.manager
2367 .set_user_agent(ua)
2368 .await
2369 .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate ua: {e}")))?;
2370 }
2371 }
2372 if let Some(loc) = locale {
2373 self.manager
2374 .set_locale(loc)
2375 .await
2376 .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate locale: {e}")))?;
2377 }
2378 if let Some(tz) = timezone {
2379 self.manager
2380 .set_timezone(tz)
2381 .await
2382 .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate timezone: {e}")))?;
2383 }
2384
2385 let mut applied_network = None;
2386 if let Some(name) = network_conditions {
2387 let preset = crate::constants::network_preset_by_name(name).ok_or_else(|| {
2388 CliError::with_suggestion(
2389 ErrorKind::Usage,
2390 format!("unknown network conditions: {name}"),
2391 format!(
2392 "Use one of: {}",
2393 crate::constants::network_preset_names().join(", ")
2394 ),
2395 )
2396 })?;
2397 network::set_network_conditions(
2398 &self.manager.client,
2399 &session_id,
2400 preset.offline,
2401 preset.latency_ms,
2402 preset.download_throughput,
2403 preset.upload_throughput,
2404 )
2405 .await
2406 .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate network: {e}")))?;
2407 applied_network = Some(preset.name);
2408 } else if offline {
2409 network::set_offline(&self.manager.client, &session_id, true)
2410 .await
2411 .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate offline: {e}")))?;
2412 applied_network = Some("Offline");
2413 }
2414
2415 if let Some(rate) = cpu_throttling_rate {
2416 let rate = rate.clamp(1.0, 20.0);
2417 network::set_cpu_throttling_rate(&self.manager.client, &session_id, rate)
2418 .await
2419 .map_err(|e| {
2420 CliError::new(ErrorKind::Browser, format!("emulate cpu throttle: {e}"))
2421 })?;
2422 }
2423
2424 if let (Some(lat), Some(lon)) = (latitude, longitude) {
2425 self.manager
2426 .set_geolocation(lat, lon, Some(1.0))
2427 .await
2428 .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate geo: {e}")))?;
2429 }
2430
2431 if let Some(scheme) = color_scheme {
2432 let value = match scheme.to_ascii_lowercase().as_str() {
2433 "dark" => "dark",
2434 "light" => "light",
2435 "auto" => "",
2436 other => {
2437 return Err(CliError::with_suggestion(
2438 ErrorKind::Usage,
2439 format!("invalid color-scheme: {other}"),
2440 "Use dark, light, or auto",
2441 ));
2442 }
2443 };
2444 self.manager
2445 .set_emulated_media(
2446 media,
2447 Some(vec![("prefers-color-scheme".into(), value.into())]),
2448 )
2449 .await
2450 .map_err(|e| {
2451 CliError::new(ErrorKind::Browser, format!("emulate color-scheme: {e}"))
2452 })?;
2453 } else if let Some(m) = media {
2454 self.manager
2455 .set_emulated_media(Some(m), None)
2456 .await
2457 .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate media: {e}")))?;
2458 }
2459
2460 if let Some(headers_raw) = extra_headers_json {
2461 let map: HashMap<String, String> = if headers_raw.trim().is_empty() {
2462 HashMap::new()
2463 } else {
2464 serde_json::from_str(headers_raw).map_err(|e| {
2465 CliError::with_suggestion(
2466 ErrorKind::Usage,
2467 format!("invalid extra-headers JSON: {e}"),
2468 r#"Pass object JSON e.g. {"X-Custom":"1"}"#,
2469 )
2470 })?
2471 };
2472 network::set_extra_headers(&self.manager.client, &session_id, &map)
2473 .await
2474 .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate headers: {e}")))?;
2475 }
2476
2477 let mut applied_viewport = None;
2478 if let Some(vp) = viewport {
2479 let spec = crate::constants::parse_viewport_spec(vp).map_err(|e| {
2480 CliError::with_suggestion(
2481 ErrorKind::Usage,
2482 e,
2483 "Format: WxHxDPR[,mobile][,touch][,landscape]",
2484 )
2485 })?;
2486 self.manager
2487 .set_viewport(
2488 spec.width,
2489 spec.height,
2490 spec.device_scale_factor,
2491 spec.mobile,
2492 )
2493 .await
2494 .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate viewport: {e}")))?;
2495 applied_viewport = Some(json!({
2496 "width": spec.width,
2497 "height": spec.height,
2498 "device_scale_factor": spec.device_scale_factor,
2499 "mobile": spec.mobile,
2500 "has_touch": spec.has_touch,
2501 "is_landscape": spec.is_landscape,
2502 }));
2503 }
2504
2505 Ok(json!({
2506 "emulated": true,
2507 "user_agent": user_agent,
2508 "locale": locale,
2509 "timezone": timezone,
2510 "offline": offline || applied_network == Some("Offline"),
2511 "latitude": latitude,
2512 "longitude": longitude,
2513 "media": media,
2514 "network_conditions": applied_network,
2515 "cpu_throttling_rate": cpu_throttling_rate,
2516 "color_scheme": color_scheme,
2517 "extra_headers": extra_headers_json.is_some(),
2518 "viewport": applied_viewport,
2519 }))
2520 }
2521
2522 pub async fn resize(
2523 &mut self,
2524 width: i32,
2525 height: i32,
2526 scale: f64,
2527 mobile: bool,
2528 ) -> Result<Value, CliError> {
2529 self.drain_events();
2530 self.manager
2531 .set_viewport(width, height, scale, mobile)
2532 .await
2533 .map_err(|e| CliError::new(ErrorKind::Browser, format!("resize failed: {e}")))?;
2534 Ok(json!({
2535 "width": width,
2536 "height": height,
2537 "scale": scale,
2538 "mobile": mobile,
2539 }))
2540 }
2541
2542 pub async fn perf_start(
2543 &mut self,
2544 path: Option<&Path>,
2545 reload: bool,
2546 auto_stop: bool,
2547 ) -> Result<Value, CliError> {
2548 self.drain_events();
2549 let session_id = self
2550 .manager
2551 .active_session_id()
2552 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
2553 .to_string();
2554 self.trace_chunks.clear();
2555 self.manager
2556 .client
2557 .send_command(
2558 "Tracing.start",
2559 Some(json!({
2560 "categories": "devtools.timeline,v8.execute,blink.user_timing,disabled-by-default-devtools.timeline",
2561 "transferMode": "ReportEvents",
2562 })),
2563 None,
2564 )
2565 .await
2566 .map_err(|e| CliError::new(ErrorKind::Browser, format!("perf start: {e}")))?;
2567 let _ = self
2568 .manager
2569 .client
2570 .send_command_no_params("Performance.enable", Some(&session_id))
2571 .await;
2572 self.perf_active = true;
2573 if reload {
2574 let _ = self.reload(false).await?;
2575 }
2576 let mut out = json!({
2577 "perf": "start",
2578 "path": path.map(|p| p.to_string_lossy().to_string()),
2579 "reload": reload,
2580 "auto_stop": auto_stop,
2581 });
2582 if auto_stop {
2583 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
2585 let stop = self.perf_stop(path).await?;
2586 if let Some(obj) = out.as_object_mut() {
2587 obj.insert("auto_stopped".into(), json!(true));
2588 obj.insert("stop".into(), stop);
2589 }
2590 }
2591 Ok(out)
2592 }
2593
2594 pub async fn perf_stop(&mut self, path: Option<&Path>) -> Result<Value, CliError> {
2595 self.pump_events().await;
2596 self.tracing_complete = false;
2597 if self.perf_active {
2598 let _ = self
2599 .manager
2600 .client
2601 .send_command("Tracing.end", None, None)
2602 .await;
2603 self.perf_active = false;
2604 }
2605 for _ in 0..100 {
2607 self.pump_events().await;
2608 if self.tracing_complete && !self.trace_chunks.is_empty() {
2609 for _ in 0..5 {
2610 self.pump_events().await;
2611 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2612 }
2613 break;
2614 }
2615 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2616 }
2617 let body = self.trace_chunks.join("\n");
2618 let chunks = self.trace_chunks.len();
2619 self.last_trace_body = Some(body.clone());
2620 let mut out_path = path.map(|p| p.to_path_buf());
2621 if out_path.is_none() {
2622 let stamp = std::time::SystemTime::now()
2624 .duration_since(std::time::UNIX_EPOCH)
2625 .map(|d| d.as_millis())
2626 .unwrap_or(0);
2627 out_path = Some(PathBuf::from(format!("trace-{stamp}.ndjson")));
2628 }
2629 if let Some(ref p) = out_path {
2630 if let Some(parent) = p.parent() {
2631 if !parent.as_os_str().is_empty() {
2632 std::fs::create_dir_all(parent).map_err(|e| {
2633 CliError::new(ErrorKind::Io, format!("perf stop mkdir: {e}"))
2634 })?;
2635 }
2636 }
2637 std::fs::write(p, body.as_bytes())
2638 .map_err(|e| CliError::new(ErrorKind::Io, format!("perf stop write: {e}")))?;
2639 self.last_trace_path = Some(p.clone());
2640 }
2641 self.trace_chunks.clear();
2642 self.tracing_complete = false;
2643 let set_id = format!(
2645 "set-{}",
2646 std::time::SystemTime::now()
2647 .duration_since(std::time::UNIX_EPOCH)
2648 .map(|d| d.as_millis())
2649 .unwrap_or(0)
2650 );
2651 Ok(json!({
2652 "perf": "stop",
2653 "path": out_path.map(|p| p.to_string_lossy().to_string()),
2654 "events": chunks,
2655 "available_insight_sets": [{
2656 "insight_set_id": set_id,
2657 "insights": [
2658 "DocumentLatency",
2659 "LCPBreakdown",
2660 "CLSCulprits",
2661 "INPBreakdown",
2662 "RenderBlocking",
2663 "ThirdParties"
2664 ]
2665 }],
2666 }))
2667 }
2668
2669 pub async fn perf_insight(
2670 &mut self,
2671 name: Option<&str>,
2672 insight_set_id: Option<&str>,
2673 ) -> Result<Value, CliError> {
2674 self.pump_events().await;
2675 let session_id = self
2676 .manager
2677 .active_session_id()
2678 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
2679 .to_string();
2680 let live_metrics = self
2681 .manager
2682 .client
2683 .send_command("Performance.getMetrics", None, Some(&session_id))
2684 .await
2685 .ok();
2686
2687 let offline = if let Some(ref p) = self.last_trace_path {
2688 crate::native::perf_insight::analyze_file(p, name).ok()
2689 } else if let Some(ref body) = self.last_trace_body {
2690 crate::native::perf_insight::analyze_text(body, name, None).ok()
2691 } else {
2692 None
2693 };
2694
2695 Ok(json!({
2696 "perf": "insight",
2697 "name": name,
2698 "insight_name": name,
2699 "insight_set_id": insight_set_id,
2700 "live_metrics": live_metrics,
2701 "trace_insight": offline,
2702 "trace_path": self.last_trace_path.as_ref().map(|p| p.to_string_lossy().to_string()),
2703 }))
2704 }
2705
2706 pub fn perf_insight_file(path: &Path, name: Option<&str>) -> Result<Value, CliError> {
2708 crate::native::perf_insight::analyze_file(path, name).map_err(|e| {
2709 CliError::with_suggestion(ErrorKind::Io, e, "Pass a path produced by perf stop --path")
2710 })
2711 }
2712
2713 pub async fn screencast_start(&mut self, path: Option<&Path>) -> Result<Value, CliError> {
2714 self.pump_events().await;
2715 let session_id = self
2716 .manager
2717 .active_session_id()
2718 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
2719 .to_string();
2720 self.screencast_frames.clear();
2721 self.screencast_ack_ids.clear();
2722 let dir = path.map(|p| p.to_path_buf()).unwrap_or_else(|| {
2723 let stamp = std::time::SystemTime::now()
2724 .duration_since(std::time::UNIX_EPOCH)
2725 .map(|d| d.as_millis())
2726 .unwrap_or(0);
2727 PathBuf::from(format!("screencast-{stamp}"))
2728 });
2729 std::fs::create_dir_all(&dir)
2730 .map_err(|e| CliError::new(ErrorKind::Io, format!("screencast dir: {e}")))?;
2731 self.screencast_dir = Some(dir.clone());
2732 let _ = self
2734 .manager
2735 .client
2736 .send_command_no_params("Page.enable", Some(&session_id))
2737 .await;
2738 self.manager
2739 .client
2740 .send_command(
2741 "Page.startScreencast",
2742 Some(json!({
2743 "format": "png",
2744 "quality": 60,
2745 "maxWidth": 1280,
2746 "maxHeight": 720,
2747 "everyNthFrame": 1,
2748 })),
2749 Some(&session_id),
2750 )
2751 .await
2752 .map_err(|e| CliError::new(ErrorKind::Browser, format!("screencast start: {e}")))?;
2753 self.screencast_active = true;
2754 for _ in 0..15 {
2756 self.pump_events().await;
2757 tokio::time::sleep(std::time::Duration::from_millis(40)).await;
2758 }
2759 Ok(json!({
2760 "screencast": "start",
2761 "dir": dir.to_string_lossy(),
2762 "note": "Frames buffered in process; stop writes PNG files + manifest.json",
2763 "frames_buffered": self.screencast_frames.len(),
2764 }))
2765 }
2766
2767 pub async fn screencast_stop(&mut self, path: Option<&Path>) -> Result<Value, CliError> {
2768 for _ in 0..40 {
2769 self.pump_events().await;
2770 tokio::time::sleep(std::time::Duration::from_millis(30)).await;
2771 }
2772 let session_id = self
2773 .manager
2774 .active_session_id()
2775 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
2776 .to_string();
2777 if self.screencast_active {
2778 let _ = self
2779 .manager
2780 .client
2781 .send_command("Page.stopScreencast", None, Some(&session_id))
2782 .await;
2783 self.screencast_active = false;
2784 }
2785 self.pump_events().await;
2786
2787 let dir = self.screencast_dir.clone().unwrap_or_else(|| {
2788 PathBuf::from(format!(
2789 "screencast-{}",
2790 std::time::SystemTime::now()
2791 .duration_since(std::time::UNIX_EPOCH)
2792 .map(|d| d.as_millis())
2793 .unwrap_or(0)
2794 ))
2795 });
2796 std::fs::create_dir_all(&dir)
2797 .map_err(|e| CliError::new(ErrorKind::Io, format!("screencast stop mkdir: {e}")))?;
2798
2799 use base64::Engine;
2800 let engine = base64::engine::general_purpose::STANDARD;
2801 let mut written = 0u64;
2802 let mut paths: Vec<String> = Vec::new();
2803 for (i, b64) in self.screencast_frames.iter().enumerate() {
2804 let bytes = match engine.decode(b64) {
2805 Ok(b) => b,
2806 Err(_) => continue,
2807 };
2808 let name = format!("frame-{:05}.png", i + 1);
2809 let out = dir.join(&name);
2810 if std::fs::write(&out, &bytes).is_ok() {
2811 written += 1;
2812 paths.push(out.to_string_lossy().into_owned());
2813 }
2814 }
2815 let video_path = path.map(|p| p.to_path_buf()).or_else(|| {
2816 self.screencast_dir.as_ref().and_then(|d| {
2818 let s = d.to_string_lossy();
2819 if s.ends_with(".webm") || s.ends_with(".mp4") {
2820 Some(d.clone())
2821 } else {
2822 None
2823 }
2824 })
2825 });
2826 let mut video_out: Option<String> = None;
2827 let mut encode_note: Option<String> = None;
2828 if let Some(ref vp) = video_path {
2829 let ext = vp
2830 .extension()
2831 .and_then(|e| e.to_str())
2832 .unwrap_or("mp4")
2833 .to_ascii_lowercase();
2834 let is_video = ext == "webm" || ext == "mp4";
2835 if is_video && written > 0 {
2836 if let Some(parent) = vp.parent() {
2837 if !parent.as_os_str().is_empty() {
2838 let _ = std::fs::create_dir_all(parent);
2839 }
2840 }
2841 let pattern = dir.join("frame-%05d.png");
2842 let vcodec = if ext == "webm" {
2843 "libvpx-vp9"
2844 } else {
2845 "libx264"
2846 };
2847 let mut cmd = std::process::Command::new("ffmpeg");
2848 cmd.arg("-y")
2849 .arg("-framerate")
2850 .arg("10")
2851 .arg("-i")
2852 .arg(&pattern)
2853 .arg("-c:v")
2854 .arg(vcodec)
2855 .arg("-pix_fmt")
2856 .arg("yuv420p")
2857 .arg(vp);
2858 match cmd.output() {
2859 Ok(out) if out.status.success() => {
2860 video_out = Some(vp.to_string_lossy().into_owned());
2861 encode_note = Some("encoded via ffmpeg".into());
2862 }
2863 Ok(out) => {
2864 encode_note = Some(format!(
2865 "ffmpeg failed: {}",
2866 String::from_utf8_lossy(&out.stderr)
2867 ));
2868 }
2869 Err(e) => {
2870 encode_note =
2871 Some(format!("ffmpeg not available: {e}; PNG frames kept in dir"));
2872 }
2873 }
2874 }
2875 }
2876 let manifest = json!({
2877 "format": "png",
2878 "frame_count": written,
2879 "frames": paths,
2880 "video": video_out,
2881 "encode_note": encode_note,
2882 "ffmpeg_hint": format!(
2883 "ffmpeg -y -framerate 10 -i {}/frame-%05d.png -c:v libx264 -pix_fmt yuv420p {}.mp4",
2884 dir.display(),
2885 dir.display()
2886 ),
2887 });
2888 let manifest_path = dir.join("manifest.json");
2889 let _ = std::fs::write(
2890 &manifest_path,
2891 serde_json::to_vec_pretty(&manifest).unwrap_or_default(),
2892 );
2893 self.screencast_frames.clear();
2894 self.screencast_ack_ids.clear();
2895 Ok(json!({
2896 "screencast": "stop",
2897 "dir": dir.to_string_lossy(),
2898 "frame_count": written,
2899 "manifest": manifest_path.to_string_lossy(),
2900 "video": video_out,
2901 "encode_note": encode_note,
2902 }))
2903 }
2904
2905 pub async fn heap_take(&mut self, path: &Path) -> Result<Value, CliError> {
2906 self.drain_events();
2907 let session_id = self
2908 .manager
2909 .active_session_id()
2910 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
2911 .to_string();
2912 self.heap_chunks.clear();
2913 self.heap_snapshot_finished = false;
2914 let _ = self
2915 .manager
2916 .client
2917 .send_command_no_params("HeapProfiler.enable", Some(&session_id))
2918 .await;
2919 self.manager
2920 .client
2921 .send_command(
2922 "HeapProfiler.takeHeapSnapshot",
2923 Some(json!({ "reportProgress": true })),
2924 Some(&session_id),
2925 )
2926 .await
2927 .map_err(|e| CliError::new(ErrorKind::Browser, format!("heap take: {e}")))?;
2928 for _ in 0..200 {
2930 self.drain_events();
2931 if self.heap_snapshot_finished && !self.heap_chunks.is_empty() {
2932 for _ in 0..10 {
2934 self.drain_events();
2935 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2936 }
2937 break;
2938 }
2939 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2940 }
2941 for _ in 0..20 {
2943 self.drain_events();
2944 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2945 }
2946 if let Some(parent) = path.parent() {
2947 if !parent.as_os_str().is_empty() {
2948 std::fs::create_dir_all(parent)
2949 .map_err(|e| CliError::new(ErrorKind::Io, format!("heap take mkdir: {e}")))?;
2950 }
2951 }
2952 let body = self.heap_chunks.join("");
2953 let bytes = body.len();
2954 if bytes == 0 {
2955 return Err(CliError::with_suggestion(
2956 ErrorKind::Browser,
2957 "heap take produced empty snapshot (no HeapProfiler chunks received)",
2958 "Ensure Chrome supports HeapProfiler; re-run doctor; check event forwarders",
2959 ));
2960 }
2961 std::fs::write(path, body.as_bytes())
2962 .map_err(|e| CliError::new(ErrorKind::Io, format!("heap take write: {e}")))?;
2963 self.heap_chunks.clear();
2964 self.heap_snapshot_finished = false;
2965 Ok(json!({
2966 "heap": "take",
2967 "path": path.to_string_lossy(),
2968 "bytes": bytes,
2969 }))
2970 }
2971
2972 pub fn heap_file_summary(path: &Path) -> Result<Value, CliError> {
2973 crate::native::heap_snapshot::summarize(path).map_err(|e| {
2974 CliError::with_suggestion(
2975 ErrorKind::Io,
2976 e,
2977 "Pass a path produced by heap take (.heapsnapshot JSON)",
2978 )
2979 })
2980 }
2981
2982 pub fn heap_close(path: &Path) -> Result<Value, CliError> {
2983 crate::native::heap_snapshot::close_snapshot(path).map_err(|e| {
2984 CliError::with_suggestion(
2985 ErrorKind::Io,
2986 e,
2987 "Pass a path produced by heap take (.heapsnapshot JSON)",
2988 )
2989 })
2990 }
2991
2992 pub fn heap_compare(base: &Path, current: &Path) -> Result<Value, CliError> {
2993 crate::native::heap_snapshot::compare(base, current).map_err(|e| {
2994 CliError::with_suggestion(ErrorKind::Io, e, "Pass two paths produced by heap take")
2995 })
2996 }
2997
2998 pub fn heap_details(path: &Path) -> Result<Value, CliError> {
2999 crate::native::heap_snapshot::details(path).map_err(|e| {
3000 CliError::with_suggestion(ErrorKind::Io, e, "Pass a valid .heapsnapshot path")
3001 })
3002 }
3003
3004 pub fn heap_dup_strings(path: &Path) -> Result<Value, CliError> {
3005 crate::native::heap_snapshot::duplicate_strings(path).map_err(|e| {
3006 CliError::with_suggestion(ErrorKind::Io, e, "Pass a valid .heapsnapshot path")
3007 })
3008 }
3009
3010 pub fn heap_class_nodes(path: &Path, id: u64) -> Result<Value, CliError> {
3011 crate::native::heap_snapshot::class_nodes(path, id).map_err(|e| {
3012 CliError::with_suggestion(
3013 ErrorKind::Io,
3014 e,
3015 "Pass a valid .heapsnapshot path and class id",
3016 )
3017 })
3018 }
3019
3020 pub fn heap_node_op(path: &Path, node: u64, op: &str) -> Result<Value, CliError> {
3021 crate::native::heap_snapshot::node_op(path, node, op).map_err(|e| {
3022 CliError::with_suggestion(
3023 ErrorKind::Io,
3024 e,
3025 "Pass a valid .heapsnapshot path and node id (or 0-based index)",
3026 )
3027 })
3028 }
3029
3030 pub fn heap_object_details(path: &Path, node: u64) -> Result<Value, CliError> {
3032 crate::native::heap_snapshot::object_details(path, node).map_err(|e| {
3033 CliError::with_suggestion(
3034 ErrorKind::Io,
3035 e,
3036 "Pass a valid .heapsnapshot path and node id (or 0-based index)",
3037 )
3038 })
3039 }
3040
3041 pub async fn extension_list(&mut self) -> Result<Value, CliError> {
3042 self.pump_events().await;
3043 let targets = self
3044 .manager
3045 .client
3046 .send_command("Target.getTargets", None, None)
3047 .await
3048 .map_err(|e| CliError::new(ErrorKind::Browser, format!("extension list: {e}")))?;
3049 let list = targets
3050 .get("targetInfos")
3051 .and_then(|v| v.as_array())
3052 .cloned()
3053 .unwrap_or_default();
3054 let extensions: Vec<Value> = list
3055 .into_iter()
3056 .filter(|t| {
3057 t.get("url")
3058 .and_then(|u| u.as_str())
3059 .map(|u| u.starts_with("chrome-extension://"))
3060 .unwrap_or(false)
3061 || t.get("type").and_then(|x| x.as_str()) == Some("service_worker")
3062 })
3063 .map(|t| {
3064 let url = t.get("url").and_then(|u| u.as_str()).unwrap_or("");
3065 let id = url
3066 .strip_prefix("chrome-extension://")
3067 .and_then(|rest| rest.split('/').next())
3068 .unwrap_or("")
3069 .to_string();
3070 json!({
3071 "id": id,
3072 "url": url,
3073 "type": t.get("type"),
3074 "title": t.get("title"),
3075 "targetId": t.get("targetId"),
3076 })
3077 })
3078 .collect();
3079 Ok(json!({ "extensions": extensions, "count": extensions.len() }))
3080 }
3081
3082 pub async fn extension_uninstall(&mut self, id: &str) -> Result<Value, CliError> {
3084 self.pump_events().await;
3085 let listed = self.extension_list().await?;
3086 let targets = listed
3087 .get("extensions")
3088 .and_then(|v| v.as_array())
3089 .cloned()
3090 .unwrap_or_default();
3091 let matches: Vec<Value> = targets
3092 .into_iter()
3093 .filter(|t| {
3094 t.get("id")
3095 .and_then(|v| v.as_str())
3096 .map(|s| s == id || s.starts_with(id) || id.contains(s))
3097 .unwrap_or(false)
3098 })
3099 .collect();
3100 if matches.is_empty() {
3101 self.loaded_extension_ids
3103 .retain(|x| x != id && !id.contains(x));
3104 return Ok(json!({
3105 "uninstalled": id,
3106 "effect": "metadata_only",
3107 "persistent": false,
3108 "ok": true,
3109 "note": "no matching extension target in this process; omitted from next load path",
3110 }));
3111 }
3112 let mut closed = Vec::new();
3113 for t in &matches {
3114 if let Some(target_id) = t.get("targetId").and_then(|v| v.as_str()) {
3115 let _ = self
3116 .manager
3117 .client
3118 .send_command(
3119 "Target.closeTarget",
3120 Some(json!({ "targetId": target_id })),
3121 None,
3122 )
3123 .await;
3124 closed.push(target_id.to_string());
3125 }
3126 }
3127 self.loaded_extension_ids
3128 .retain(|x| x != id && !id.contains(x));
3129 Ok(json!({
3130 "uninstalled": id,
3131 "effect": "unloaded",
3132 "closed_targets": closed,
3133 "persistent": false,
3134 "ok": true,
3135 }))
3136 }
3137
3138 pub async fn extension_reload(&mut self, id: &str) -> Result<Value, CliError> {
3140 self.pump_events().await;
3141 let listed = self.extension_list().await?;
3142 let targets = listed
3143 .get("extensions")
3144 .and_then(|v| v.as_array())
3145 .cloned()
3146 .unwrap_or_default();
3147 let match_t = targets.iter().find(|t| {
3148 t.get("id")
3149 .and_then(|v| v.as_str())
3150 .map(|s| s == id || s.starts_with(id) || id.contains(s))
3151 .unwrap_or(false)
3152 });
3153 let Some(t) = match_t else {
3154 return Err(CliError::with_suggestion(
3155 ErrorKind::NoInput,
3156 format!("extension id not found: {id}"),
3157 "Run extension list after extension install <unpacked-dir>",
3158 ));
3159 };
3160 let target_id = t
3161 .get("targetId")
3162 .and_then(|v| v.as_str())
3163 .ok_or_else(|| CliError::new(ErrorKind::Browser, "missing targetId"))?
3164 .to_string();
3165 let _ = self
3167 .manager
3168 .client
3169 .send_command(
3170 "Target.closeTarget",
3171 Some(json!({ "targetId": target_id })),
3172 None,
3173 )
3174 .await;
3175 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
3176 let again = self.extension_list().await?;
3177 Ok(json!({
3178 "reloaded": id,
3179 "closed_target": target_id,
3180 "after": again,
3181 "one_shot": true,
3182 "ok": true,
3183 "note": "one-shot SW restart via Target.closeTarget; install path is --load-extension on the same invocation",
3184 }))
3185 }
3186
3187 pub async fn extension_trigger(&mut self, id: &str) -> Result<Value, CliError> {
3188 self.pump_events().await;
3189 let listed = self.extension_list().await?;
3190 let targets = listed
3191 .get("extensions")
3192 .and_then(|v| v.as_array())
3193 .cloned()
3194 .unwrap_or_default();
3195 let match_t = targets.iter().find(|t| {
3196 t.get("id")
3197 .and_then(|v| v.as_str())
3198 .map(|s| s == id || s.starts_with(id))
3199 .unwrap_or(false)
3200 && t.get("type").and_then(|v| v.as_str()) == Some("service_worker")
3201 });
3202 let Some(t) = match_t else {
3203 return Err(CliError::with_suggestion(
3204 ErrorKind::NoInput,
3205 format!("extension service_worker not found for id: {id}"),
3206 "Use extension list; trigger requires a service_worker target",
3207 ));
3208 };
3209 let target_id = t
3210 .get("targetId")
3211 .and_then(|v| v.as_str())
3212 .ok_or_else(|| CliError::new(ErrorKind::Browser, "missing targetId"))?
3213 .to_string();
3214 let attach = self
3216 .manager
3217 .client
3218 .send_command(
3219 "Target.attachToTarget",
3220 Some(json!({ "targetId": target_id, "flatten": true })),
3221 None,
3222 )
3223 .await
3224 .map_err(|e| CliError::new(ErrorKind::Browser, format!("attach extension SW: {e}")))?;
3225 let session = attach
3226 .get("sessionId")
3227 .and_then(|v| v.as_str())
3228 .map(|s| s.to_string());
3229 let eval = self
3230 .manager
3231 .client
3232 .send_command(
3233 "Runtime.evaluate",
3234 Some(json!({
3235 "expression": "(() => { try { if (chrome && chrome.runtime) { return { ok: true, id: chrome.runtime.id }; } return { ok: false, reason: 'no chrome.runtime' }; } catch (e) { return { ok: false, reason: String(e) }; } })()",
3236 "returnByValue": true,
3237 "awaitPromise": true,
3238 })),
3239 session.as_deref(),
3240 )
3241 .await;
3242 Ok(json!({
3243 "triggered": id,
3244 "targetId": target_id,
3245 "evaluate": eval.unwrap_or(Value::Null),
3246 "one_shot": true,
3247 "ok": true,
3248 "note": "best-effort SW Runtime.evaluate in the same process; popup UI may need headed Chrome",
3249 }))
3250 }
3251
3252 pub async fn devtools3p_list(&mut self) -> Result<Value, CliError> {
3254 self.pump_events().await;
3255 let expr = r#"(() => {
3256 return new Promise((resolve) => {
3257 if (!window.__dtmcp) window.__dtmcp = {};
3258 window.__dtmcp.toolGroups = [];
3259 const groups = [];
3260 const event = new CustomEvent('devtoolstooldiscovery');
3261 event.respondWith = (toolGroup) => {
3262 if (!toolGroup || typeof toolGroup.name !== 'string' || !Array.isArray(toolGroup.tools)) {
3263 return;
3264 }
3265 const tools = [];
3266 for (const tool of toolGroup.tools) {
3267 if (!tool || typeof tool.name !== 'string') continue;
3268 tools.push({
3269 name: tool.name,
3270 description: typeof tool.description === 'string' ? tool.description : '',
3271 inputSchema: tool.inputSchema || {},
3272 });
3273 }
3274 const g = {
3275 name: toolGroup.name,
3276 description: typeof toolGroup.description === 'string' ? toolGroup.description : '',
3277 tools,
3278 };
3279 groups.push(g);
3280 window.__dtmcp.toolGroups.push({
3281 name: g.name,
3282 description: g.description,
3283 tools: toolGroup.tools,
3284 });
3285 if (!window.__dtmcp.executeTool) {
3286 window.__dtmcp.executeTool = async (toolName, args) => {
3287 for (const group of (window.__dtmcp.toolGroups || [])) {
3288 const t = (group.tools || []).find((x) => x.name === toolName);
3289 if (t && typeof t.execute === 'function') {
3290 return await t.execute(args || {});
3291 }
3292 }
3293 throw new Error('Tool ' + toolName + ' not found');
3294 };
3295 }
3296 };
3297 window.dispatchEvent(event);
3298 setTimeout(() => resolve(groups), 0);
3299 });
3300 })()"#;
3301 let result = self.eval(expr, None, Some("accept"), None).await?;
3302 let groups = result
3303 .get("result")
3304 .cloned()
3305 .or_else(|| result.get("value").cloned())
3306 .unwrap_or(result);
3307 let tools_flat: Vec<Value> = groups
3308 .as_array()
3309 .map(|arr| {
3310 arr.iter()
3311 .flat_map(|g| {
3312 g.get("tools")
3313 .and_then(|t| t.as_array())
3314 .cloned()
3315 .unwrap_or_default()
3316 })
3317 .collect()
3318 })
3319 .unwrap_or_default();
3320 Ok(json!({
3321 "groups": groups,
3322 "tools": tools_flat,
3323 "count": tools_flat.len(),
3324 "available": true,
3325 }))
3326 }
3327
3328 pub async fn devtools3p_exec(
3329 &mut self,
3330 name: &str,
3331 params_json: Option<&str>,
3332 ) -> Result<Value, CliError> {
3333 let _ = self.devtools3p_list().await?;
3334 let params = params_json.unwrap_or("{}");
3335 let parsed: Value = serde_json::from_str(params).map_err(|e| {
3337 CliError::with_suggestion(
3338 ErrorKind::Usage,
3339 format!("invalid params JSON: {e}"),
3340 r#"Pass --params '{"key":"value"}'"#,
3341 )
3342 })?;
3343 if !parsed.is_object() {
3344 return Err(CliError::with_suggestion(
3345 ErrorKind::Usage,
3346 "params must be a JSON object",
3347 r#"Pass --params '{"key":"value"}'"#,
3348 ));
3349 }
3350 let name_js = serde_json::to_string(name).unwrap_or_else(|_| "\"\"".into());
3351 let params_js = parsed.to_string();
3352 let expr = format!(
3353 r#"(async () => {{
3354 if (!window.__dtmcp || typeof window.__dtmcp.executeTool !== 'function') {{
3355 throw new Error('No third-party tools discovered on page');
3356 }}
3357 const out = await window.__dtmcp.executeTool({name_js}, {params_js});
3358 try {{ return JSON.parse(JSON.stringify(out)); }} catch (_) {{ return String(out); }}
3359 }})()"#
3360 );
3361 let result = self.eval(&expr, None, Some("accept"), None).await?;
3362 if result.get("exceptionDetails").is_some() {
3363 return Err(CliError::with_suggestion(
3364 ErrorKind::NoInput,
3365 format!("devtools3p exec {name} failed"),
3366 "List tools with browser-automation-cli --category-third-party devtools3p list --url <page>",
3367 ));
3368 }
3369 let value = result
3370 .get("result")
3371 .cloned()
3372 .or_else(|| result.get("value").cloned())
3373 .unwrap_or(result);
3374 Ok(json!({
3375 "name": name,
3376 "result": value,
3377 "ok": true,
3378 }))
3379 }
3380
3381 pub async fn webmcp_list(&mut self) -> Result<Value, CliError> {
3383 self.pump_events().await;
3384 let expr = r#"(() => {
3385 const tools = [];
3386 // Declarative form-based tools (test harness / early WebMCP)
3387 document.querySelectorAll('form[toolname]').forEach((form) => {
3388 tools.push({
3389 name: form.getAttribute('toolname') || '',
3390 description: form.getAttribute('tooldescription') || '',
3391 source: 'form',
3392 });
3393 });
3394 // Future navigator surface (best-effort)
3395 try {
3396 if (navigator.modelContext && typeof navigator.modelContext.listTools === 'function') {
3397 // sync list not always available; ignore
3398 }
3399 } catch (_) {}
3400 if (window.__webmcpTools && Array.isArray(window.__webmcpTools)) {
3401 for (const t of window.__webmcpTools) {
3402 if (t && t.name) tools.push({ name: t.name, description: t.description || '', source: 'window' });
3403 }
3404 }
3405 return tools;
3406 })()"#;
3407 let result = self.eval(expr, None, Some("accept"), None).await?;
3408 let tools = result
3409 .get("result")
3410 .cloned()
3411 .or_else(|| result.get("value").cloned())
3412 .unwrap_or(result);
3413 let count = tools.as_array().map(|a| a.len()).unwrap_or(0);
3414 Ok(json!({
3415 "tools": tools,
3416 "count": count,
3417 "available": true,
3418 "note": "Requires Chrome with WebMCP/DevToolsWebMCPSupport for full surface; form[toolname] always listed",
3419 }))
3420 }
3421
3422 pub async fn webmcp_exec(
3423 &mut self,
3424 name: &str,
3425 input_json: Option<&str>,
3426 ) -> Result<Value, CliError> {
3427 let input = input_json.unwrap_or("{}");
3428 let parsed: Value = serde_json::from_str(input).map_err(|e| {
3429 CliError::with_suggestion(
3430 ErrorKind::Usage,
3431 format!("invalid input JSON: {e}"),
3432 r#"Pass --input '{"key":"value"}'"#,
3433 )
3434 })?;
3435 let name_js = serde_json::to_string(name).unwrap_or_else(|_| "\"\"".into());
3436 let input_js = parsed.to_string();
3437 let expr = format!(
3438 r#"(async () => {{
3439 const toolName = {name_js};
3440 const input = {input_js};
3441 // Form-based tools
3442 const form = document.querySelector('form[toolname="' + CSS.escape(toolName) + '"]')
3443 || document.querySelector('form[toolname="' + toolName + '"]');
3444 if (form) {{
3445 return await new Promise((resolve, reject) => {{
3446 const handler = (event) => {{
3447 event.preventDefault();
3448 try {{
3449 if (typeof event.respondWith === 'function') {{
3450 // page may set respondWith on submit
3451 }}
3452 }} catch (_) {{}}
3453 }};
3454 form.addEventListener('submit', handler, {{ once: true }});
3455 // Prefer page-defined onsubmit
3456 if (typeof form.onsubmit === 'function') {{
3457 const fake = {{
3458 preventDefault() {{}},
3459 respondWith(v) {{ resolve({{ status: 'Completed', output: v }}); }},
3460 }};
3461 try {{
3462 form.onsubmit(fake);
3463 setTimeout(() => resolve({{ status: 'Completed', output: null }}), 0);
3464 }} catch (e) {{
3465 reject(e);
3466 }}
3467 return;
3468 }}
3469 form.requestSubmit ? form.requestSubmit() : form.submit();
3470 setTimeout(() => resolve({{ status: 'Completed', output: null, note: 'form submitted' }}), 50);
3471 }});
3472 }}
3473 if (window.__webmcpTools) {{
3474 const t = window.__webmcpTools.find((x) => x.name === toolName);
3475 if (t && typeof t.execute === 'function') {{
3476 const out = await t.execute(input);
3477 return {{ status: 'Completed', output: out }};
3478 }}
3479 }}
3480 throw new Error('Tool ' + toolName + ' not found');
3481 }})()"#
3482 );
3483 let result = self.eval(&expr, None, Some("accept"), None).await?;
3484 if result.get("exceptionDetails").is_some() {
3485 let msg = result
3486 .pointer("/exceptionDetails/exception/description")
3487 .or_else(|| result.pointer("/exceptionDetails/text"))
3488 .and_then(|v| v.as_str())
3489 .unwrap_or("tool not found");
3490 return Err(CliError::with_suggestion(
3491 ErrorKind::NoInput,
3492 format!("webmcp exec {name}: {msg}"),
3493 "List tools first; page must expose form[toolname] or __webmcpTools",
3494 ));
3495 }
3496 let value = result
3497 .get("result")
3498 .cloned()
3499 .or_else(|| result.get("value").cloned())
3500 .unwrap_or(result);
3501 Ok(json!({
3502 "name": name,
3503 "result": value,
3504 "ok": true,
3505 }))
3506 }
3507
3508 pub async fn shutdown(mut self) -> Result<(), CliError> {
3510 self.console_log.clear();
3511 self.network_log.clear();
3512 self.heap_chunks.clear();
3513 self.trace_chunks.clear();
3514 self.screencast_frames.clear();
3515 self.screencast_ack_ids.clear();
3516 self.screencast_dir = None;
3517 self.last_trace_body = None;
3518 self.ref_map.clear();
3519 self.manager.close().await.map_err(|e| {
3520 CliError::with_suggestion(
3521 ErrorKind::Browser,
3522 format!("Browser close failed: {e}"),
3523 "Process reaped by chromiumoxide finalize or Lightpanda process Drop",
3524 )
3525 })
3526 }
3527}
3528
3529fn verify_image_magic(path: &Path, format: &str) -> bool {
3530 let Ok(bytes) = std::fs::read(path) else {
3531 return false;
3532 };
3533 match format {
3534 "png" => bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]),
3535 "jpeg" | "jpg" => bytes.starts_with(&[0xFF, 0xD8, 0xFF]),
3536 "webp" => {
3537 bytes.len() >= 12
3538 && bytes[0..4] == [0x52, 0x49, 0x46, 0x46]
3539 && bytes[8..12] == [0x57, 0x45, 0x42, 0x50]
3540 }
3541 _ => !bytes.is_empty(),
3542 }
3543}
3544
3545pub fn tree_to_at_refs(tree: &str) -> String {
3547 let mut out = String::with_capacity(tree.len());
3548 let bytes = tree.as_bytes();
3549 let mut i = 0;
3550 while i < bytes.len() {
3551 if bytes[i..].starts_with(b"ref=") && i + 4 < bytes.len() && bytes[i + 4] == b'e' {
3552 out.push('@');
3553 i += 4;
3554 while i < bytes.len() && bytes[i].is_ascii_alphanumeric() {
3555 out.push(bytes[i] as char);
3556 i += 1;
3557 }
3558 continue;
3559 }
3560 out.push(bytes[i] as char);
3561 i += 1;
3562 }
3563 out
3564}
3565
3566fn normalize_eval_expression(
3573 expression: &str,
3574 args_json: Option<&str>,
3575) -> Result<String, CliError> {
3576 if let Some(args_raw) = args_json {
3577 let uids: Vec<String> = serde_json::from_str(args_raw).map_err(|e| {
3578 CliError::with_suggestion(
3579 ErrorKind::Usage,
3580 format!("eval --args must be a JSON array of uids: {e}"),
3581 r#"Example: --args '["@e1","@e2"]'"#,
3582 )
3583 })?;
3584 let args_js: Vec<String> = uids
3585 .iter()
3586 .map(|u| {
3587 let cleaned = u.trim().trim_start_matches('@');
3588 format!("\"{cleaned}\"")
3589 })
3590 .collect();
3591 let joined = args_js.join(",");
3592 return Ok(format!("({expression})({joined})"));
3593 }
3594
3595 let trimmed = expression.trim();
3596 let for_detect = trimmed.trim_end_matches(';').trim_end();
3598 if for_detect.ends_with(")()") {
3600 return Ok(expression.to_string());
3601 }
3602
3603 let head = trimmed.trim_start();
3604 let is_bare_callable = head.starts_with("function")
3605 || head.starts_with("async function")
3606 || (head.starts_with("async") && trimmed.contains("=>"))
3607 || (head.starts_with('(') && trimmed.contains("=>"));
3608
3609 if is_bare_callable {
3610 return Ok(format!("({expression})()"));
3612 }
3613
3614 Ok(expression.to_string())
3615}
3616
3617fn mark_launched(life: &Lifecycle, pid: Option<u32>, profile: Option<std::path::PathBuf>) {
3618 let launch_t = std::time::SystemTime::now();
3619 if let Ok(mut ledger) = life.ledger.lock() {
3620 ledger.chrome_launched = true;
3621 ledger.chrome_pid = pid;
3622 if let Some(ref dir) = profile {
3623 for name in ["SingletonLock", "SingletonCookie", "SingletonSocket"] {
3625 let p = dir.join(name);
3626 if p.exists() {
3627 ledger.side_channels.push(p);
3628 }
3629 }
3630 ledger.profile_dir = Some(dir.clone());
3631 }
3632 #[cfg(windows)]
3633 if let Some(p) = pid {
3634 if ledger.windows_job_handle == 0 {
3635 ledger.windows_job_handle = crate::win_job::create_and_assign(p);
3636 }
3637 }
3638 }
3639 std::thread::sleep(std::time::Duration::from_millis(80));
3642 let extras = crate::residual::discover_owned_chromium_tmp_side_channels(
3643 profile.as_deref(),
3644 pid,
3645 launch_t,
3646 );
3647 for p in extras {
3648 crate::lifecycle::mark_side_channel(life, p);
3649 }
3650 if let Some(ref dir) = profile {
3652 for name in ["SingletonLock", "SingletonCookie", "SingletonSocket"] {
3653 let p = dir.join(name);
3654 if p.exists() {
3655 crate::lifecycle::mark_side_channel(life, p);
3656 }
3657 }
3658 }
3659}
3660
3661fn mark_closed(life: &Lifecycle) {
3662 if let Ok(mut ledger) = life.ledger.lock() {
3663 ledger.chrome_launched = false;
3664 ledger.chrome_pid = None;
3665 ledger.profile_dir = None;
3667 ledger.side_channels.clear();
3668 }
3669}
3670
3671async fn launch_marked(life: &Lifecycle, capture: CaptureOpts) -> Result<OneShotSession, CliError> {
3672 let session = OneShotSession::launch_headless_with_capture(capture).await?;
3673 mark_launched(life, session.chrome_pid(), session.temp_user_data_dir());
3674 Ok(session)
3675}
3676
3677#[cfg(test)]
3678mod eval_normalize_tests {
3679 use super::normalize_eval_expression;
3680
3681 #[test]
3682 fn leaves_invoked_iife_alone() {
3683 let e = "(() => { return 9; })()";
3684 assert_eq!(normalize_eval_expression(e, None).unwrap(), e);
3685 let e2 = "(async () => 1)()";
3686 assert_eq!(normalize_eval_expression(e2, None).unwrap(), e2);
3687 let e3 = "(function(){ return 2; })()";
3688 assert_eq!(normalize_eval_expression(e3, None).unwrap(), e3);
3689 }
3690
3691 #[test]
3692 fn wraps_bare_arrow_once() {
3693 assert_eq!(
3694 normalize_eval_expression("() => 7", None).unwrap(),
3695 "(() => 7)()"
3696 );
3697 assert_eq!(
3698 normalize_eval_expression("async () => 3", None).unwrap(),
3699 "(async () => 3)()"
3700 );
3701 }
3702
3703 #[test]
3704 fn leaves_plain_expression() {
3705 assert_eq!(normalize_eval_expression("1+1", None).unwrap(), "1+1");
3706 assert_eq!(normalize_eval_expression("(1+1)", None).unwrap(), "(1+1)");
3707 assert_eq!(
3708 normalize_eval_expression("document.title", None).unwrap(),
3709 "document.title"
3710 );
3711 }
3712
3713 #[test]
3714 fn args_force_call() {
3715 let out = normalize_eval_expression("(el) => el", Some(r#"["@e1"]"#)).unwrap();
3716 assert_eq!(out, r#"((el) => el)("e1")"#);
3717 }
3718}
3719
3720async fn finish(
3721 life: &Lifecycle,
3722 session: OneShotSession,
3723 work_res: Result<Value, CliError>,
3724) -> Result<Value, CliError> {
3725 let close_res = session.shutdown().await;
3726 mark_closed(life);
3727 match (work_res, close_res) {
3728 (Ok(v), Ok(())) => Ok(v),
3729 (Err(e), _) => Err(e),
3730 (Ok(_), Err(e)) => Err(e),
3731 }
3732}
3733
3734pub async fn run_goto(life: &Lifecycle, url: &str) -> Result<Value, CliError> {
3735 run_goto_with_robots(
3736 life,
3737 url,
3738 CaptureOpts::default(),
3739 crate::robots::RobotsPolicy::Honor,
3740 )
3741 .await
3742}
3743
3744pub async fn run_goto_with_robots(
3745 life: &Lifecycle,
3746 url: &str,
3747 capture: CaptureOpts,
3748 robots: crate::robots::RobotsPolicy,
3749) -> Result<Value, CliError> {
3750 run_goto_with_options(life, url, capture, robots, None, false, None).await
3751}
3752
3753#[allow(clippy::too_many_arguments)]
3755pub async fn run_goto_with_options(
3756 life: &Lifecycle,
3757 url: &str,
3758 capture: CaptureOpts,
3759 robots: crate::robots::RobotsPolicy,
3760 init_script: Option<&str>,
3761 handle_before_unload: bool,
3762 navigation_timeout_ms: Option<u64>,
3763) -> Result<Value, CliError> {
3764 let mut session = launch_marked(life, capture).await?;
3765 let work = session
3766 .goto_with_options(
3767 url,
3768 robots,
3769 init_script,
3770 handle_before_unload,
3771 navigation_timeout_ms,
3772 )
3773 .await;
3774 finish(life, session, work).await
3775}
3776
3777pub async fn run_scrape(
3778 life: &Lifecycle,
3779 url: &str,
3780 robots: crate::robots::RobotsPolicy,
3781 capture: CaptureOpts,
3782) -> Result<Value, CliError> {
3783 let mut session = launch_marked(life, capture).await?;
3784 let work = session.scrape(url, robots).await;
3785 finish(life, session, work).await
3786}
3787
3788pub async fn run_goto_capture(
3789 life: &Lifecycle,
3790 url: &str,
3791 capture: CaptureOpts,
3792) -> Result<Value, CliError> {
3793 run_goto_with_robots(life, url, capture, crate::robots::RobotsPolicy::Honor).await
3794}
3795
3796pub async fn run_view(
3797 life: &Lifecycle,
3798 verbose: bool,
3799 capture: CaptureOpts,
3800) -> Result<Value, CliError> {
3801 let mut session = launch_marked(life, capture).await?;
3802 let work = async {
3803 let _ = session
3804 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
3805 .await?;
3806 session.view(verbose).await
3807 }
3808 .await;
3809 finish(life, session, work).await
3810}
3811
3812pub async fn run_press(
3813 life: &Lifecycle,
3814 target: &str,
3815 dblclick: bool,
3816 include_snapshot: bool,
3817 capture: CaptureOpts,
3818) -> Result<Value, CliError> {
3819 let mut session = launch_marked(life, capture).await?;
3820 let work = async {
3821 let _ = session
3822 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
3823 .await?;
3824 session.press(target, dblclick, include_snapshot).await
3825 }
3826 .await;
3827 finish(life, session, work).await
3828}
3829
3830pub async fn run_write(
3831 life: &Lifecycle,
3832 target: &str,
3833 value: &str,
3834 include_snapshot: bool,
3835 capture: CaptureOpts,
3836) -> Result<Value, CliError> {
3837 let mut session = launch_marked(life, capture).await?;
3838 let work = async {
3839 let _ = session
3840 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
3841 .await?;
3842 session.write(target, value, include_snapshot).await
3843 }
3844 .await;
3845 finish(life, session, work).await
3846}
3847
3848pub async fn run_keys(
3849 life: &Lifecycle,
3850 key: &str,
3851 include_snapshot: bool,
3852 capture: CaptureOpts,
3853) -> Result<Value, CliError> {
3854 let mut session = launch_marked(life, capture).await?;
3855 let work = async {
3856 let _ = session
3857 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
3858 .await?;
3859 session.keys(key, include_snapshot).await
3860 }
3861 .await;
3862 finish(life, session, work).await
3863}
3864
3865pub async fn run_type(
3866 life: &Lifecycle,
3867 target: Option<&str>,
3868 text: &str,
3869 clear: bool,
3870 submit: Option<&str>,
3871 focus_only: bool,
3872 capture: CaptureOpts,
3873) -> Result<Value, CliError> {
3874 let mut session = launch_marked(life, capture).await?;
3875 let work = async {
3876 let _ = session
3877 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
3878 .await?;
3879 session
3880 .type_text(target, text, clear, submit, focus_only)
3881 .await
3882 }
3883 .await;
3884 finish(life, session, work).await
3885}
3886
3887pub async fn run_with_session<F, Fut>(
3888 life: &Lifecycle,
3889 capture: CaptureOpts,
3890 work: F,
3891) -> Result<Value, CliError>
3892where
3893 F: FnOnce(OneShotSession) -> Fut,
3894 Fut: std::future::Future<Output = Result<(OneShotSession, Value), CliError>>,
3895{
3896 let session = launch_marked(life, capture).await?;
3897 match work(session).await {
3898 Ok((session, value)) => finish(life, session, Ok(value)).await,
3899 Err(e) => {
3900 mark_closed(life);
3901 Err(e)
3902 }
3903 }
3904}
3905
3906pub fn block_on_browser<F, T>(fut: F) -> Result<T, CliError>
3908where
3909 F: std::future::Future<Output = Result<T, CliError>>,
3910{
3911 block_on_browser_timeout(fut, 0)
3912}
3913
3914pub fn block_on_browser_timeout<F, T>(fut: F, timeout_secs: u64) -> Result<T, CliError>
3916where
3917 F: std::future::Future<Output = Result<T, CliError>>,
3918{
3919 let rt = tokio::runtime::Builder::new_multi_thread()
3920 .enable_all()
3921 .worker_threads(2)
3922 .thread_name("bac-browser")
3923 .build()
3924 .map_err(|e| {
3925 CliError::new(
3926 ErrorKind::Software,
3927 format!("Failed to create tokio runtime: {e}"),
3928 )
3929 })?;
3930 if timeout_secs == 0 {
3931 return rt.block_on(fut);
3932 }
3933 rt.block_on(async {
3934 match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), fut).await {
3935 Ok(inner) => inner,
3936 Err(_) => Err(CliError::with_suggestion(
3937 ErrorKind::Timeout,
3938 format!("operation exceeded --timeout {timeout_secs}s"),
3939 "Raise --timeout or reduce wait/navigation work",
3940 )),
3941 }
3942 })
3943}
3944
3945#[cfg(test)]
3946mod tests {
3947 use super::{is_internal_browser_url, is_noise_network_url, tree_to_at_refs};
3948 use crate::native::browser::WaitUntil;
3949 use serde_json::json;
3950
3951 #[test]
3952 fn tree_to_at_refs_rewrites_markers() {
3953 let raw = r#"- link "Home" [ref=e1]
3954 - button "Go" [checked=false, ref=e2]
3955"#;
3956 let out = tree_to_at_refs(raw);
3957 assert!(out.contains("[@e1]"), "out={out}");
3958 assert!(out.contains("@e2"), "out={out}");
3959 }
3960
3961 #[test]
3962 fn internal_browser_urls_filtered() {
3963 assert!(is_internal_browser_url("chrome://new-tab-page/"));
3964 assert!(is_internal_browser_url("chrome-extension://abc/x.js"));
3965 assert!(is_internal_browser_url("devtools://devtools/bundled/"));
3966 assert!(!is_internal_browser_url("https://example.com/"));
3967 assert!(!is_internal_browser_url("about:blank"));
3968 assert!(is_noise_network_url(
3969 "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
3970 ));
3971 assert!(is_noise_network_url("blob:https://example.com/uuid"));
3972 assert!(is_noise_network_url("chrome://new-tab-page/"));
3973 assert!(!is_noise_network_url("https://example.com/"));
3974 }
3975
3976 #[test]
3977 fn wait_until_tokens_parse() {
3978 assert_eq!(
3979 WaitUntil::parse_token("networkidle"),
3980 WaitUntil::NetworkIdle
3981 );
3982 assert_eq!(
3983 WaitUntil::parse_token("domcontentloaded"),
3984 WaitUntil::DomContentLoaded
3985 );
3986 assert_eq!(WaitUntil::parse_token("load"), WaitUntil::Load);
3987 assert_eq!(WaitUntil::parse_token("none"), WaitUntil::None);
3988 }
3989
3990 #[test]
3991 fn net_request_id_resolution_logic() {
3992 let requests = [
3993 json!({"requestId": "rid-1", "method": "GET", "url": "https://a.example/"}),
3994 json!({"requestId": "rid-2", "method": "POST", "url": "https://b.example/"}),
3995 ];
3996 let by_index = requests.get(1).unwrap();
3997 assert_eq!(by_index["requestId"], "rid-2");
3998 let by_rid = requests.iter().find(|r| r["requestId"] == "rid-1").unwrap();
3999 assert_eq!(by_rid["url"], "https://a.example/");
4000 let idx: usize = "0".parse().unwrap();
4002 assert_eq!(requests[idx]["requestId"], "rid-1");
4003 }
4004}