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