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