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