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 mut waited = Vec::new();
1777
1778 if let Some(st) = state {
1779 let until = WaitUntil::parse_token(st);
1780 let session_id = self
1781 .manager
1782 .active_session_id()
1783 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1784 .to_string();
1785 self.manager
1786 .wait_for_lifecycle_external(until, &session_id)
1787 .await
1788 .map_err(|e| {
1789 CliError::with_suggestion(
1790 ErrorKind::Timeout,
1791 format!("wait state {st} failed: {e}"),
1792 "Use --state load|domcontentloaded|networkidle|none",
1793 )
1794 })?;
1795 waited.push(json!({"kind": "state", "state": st}));
1796 }
1797
1798 if let Some(m) = ms {
1799 if m > 0 && text.is_none() && selector.is_none() && state.is_none() {
1800 let data = self.wait_ms(m).await?;
1801 return self.attach_snapshot_if(include_snapshot, data).await;
1802 }
1803 if m > 0 && text.is_none() && selector.is_none() && state.is_some() {
1804 if m > 0 {
1806 let _ = self.wait_ms(m).await?;
1807 waited.push(json!({"kind": "ms", "ms": m}));
1808 }
1809 let data = json!({ "waited": waited, "ok": true });
1810 return self.attach_snapshot_if(include_snapshot, data).await;
1811 }
1812 }
1813
1814 if text.is_none() && selector.is_none() && state.is_some() {
1815 let data = json!({ "waited": waited, "ok": true });
1816 return self.attach_snapshot_if(include_snapshot, data).await;
1817 }
1818
1819 if text.is_none() && selector.is_none() && state.is_none() {
1820 let data = self.wait_ms(ms.unwrap_or(0)).await?;
1821 return self.attach_snapshot_if(include_snapshot, data).await;
1822 }
1823
1824 let deadline = std::time::Instant::now()
1825 + std::time::Duration::from_millis(ms.unwrap_or(10_000).max(1));
1826 loop {
1827 self.drain_events();
1828 if let Some(sel) = selector {
1829 let session_id = self
1830 .manager
1831 .active_session_id()
1832 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1833 .to_string();
1834 if element::get_element_count(&self.manager.client, &session_id, sel)
1835 .await
1836 .unwrap_or(0)
1837 > 0
1838 {
1839 waited.push(json!({"kind": "selector", "selector": sel}));
1840 let data = json!({ "waited": waited, "ok": true });
1841 return self.attach_snapshot_if(include_snapshot, data).await;
1842 }
1843 }
1844 if let Some(t) = text {
1845 let body = self
1846 .manager
1847 .evaluate("document.body ? document.body.innerText : ''", None)
1848 .await
1849 .unwrap_or(json!(""));
1850 let hay = body.as_str().unwrap_or("");
1851 if hay.contains(t) {
1852 waited.push(json!({"kind": "text", "text": t}));
1853 let data = json!({ "waited": waited, "ok": true });
1854 return self.attach_snapshot_if(include_snapshot, data).await;
1855 }
1856 }
1857 if std::time::Instant::now() >= deadline {
1858 return Err(CliError::with_suggestion(
1859 ErrorKind::Timeout,
1860 "wait condition not met before deadline",
1861 "Increase --ms, set --state, or ensure page content is present",
1862 ));
1863 }
1864 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1865 }
1866 }
1867
1868 #[allow(clippy::too_many_arguments)]
1869 pub async fn emulate(
1870 &mut self,
1871 user_agent: Option<&str>,
1872 locale: Option<&str>,
1873 timezone: Option<&str>,
1874 offline: bool,
1875 latitude: Option<f64>,
1876 longitude: Option<f64>,
1877 media: Option<&str>,
1878 network_conditions: Option<&str>,
1879 cpu_throttling_rate: Option<f64>,
1880 color_scheme: Option<&str>,
1881 extra_headers_json: Option<&str>,
1882 viewport: Option<&str>,
1883 ) -> Result<Value, CliError> {
1884 self.drain_events();
1885 let session_id = self
1886 .manager
1887 .active_session_id()
1888 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
1889 .to_string();
1890 if let Some(ua) = user_agent {
1891 if ua.is_empty() {
1892 } else {
1894 self.manager
1895 .set_user_agent(ua)
1896 .await
1897 .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate ua: {e}")))?;
1898 }
1899 }
1900 if let Some(loc) = locale {
1901 self.manager
1902 .set_locale(loc)
1903 .await
1904 .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate locale: {e}")))?;
1905 }
1906 if let Some(tz) = timezone {
1907 self.manager
1908 .set_timezone(tz)
1909 .await
1910 .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate timezone: {e}")))?;
1911 }
1912
1913 let mut applied_network = None;
1914 if let Some(name) = network_conditions {
1915 let preset = crate::constants::network_preset_by_name(name).ok_or_else(|| {
1916 CliError::with_suggestion(
1917 ErrorKind::Usage,
1918 format!("unknown network conditions: {name}"),
1919 format!(
1920 "Use one of: {}",
1921 crate::constants::network_preset_names().join(", ")
1922 ),
1923 )
1924 })?;
1925 network::set_network_conditions(
1926 &self.manager.client,
1927 &session_id,
1928 preset.offline,
1929 preset.latency_ms,
1930 preset.download_throughput,
1931 preset.upload_throughput,
1932 )
1933 .await
1934 .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate network: {e}")))?;
1935 applied_network = Some(preset.name);
1936 } else if offline {
1937 network::set_offline(&self.manager.client, &session_id, true)
1938 .await
1939 .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate offline: {e}")))?;
1940 applied_network = Some("Offline");
1941 }
1942
1943 if let Some(rate) = cpu_throttling_rate {
1944 let rate = rate.clamp(1.0, 20.0);
1945 network::set_cpu_throttling_rate(&self.manager.client, &session_id, rate)
1946 .await
1947 .map_err(|e| {
1948 CliError::new(ErrorKind::Browser, format!("emulate cpu throttle: {e}"))
1949 })?;
1950 }
1951
1952 if let (Some(lat), Some(lon)) = (latitude, longitude) {
1953 self.manager
1954 .set_geolocation(lat, lon, Some(1.0))
1955 .await
1956 .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate geo: {e}")))?;
1957 }
1958
1959 if let Some(scheme) = color_scheme {
1960 let value = match scheme.to_ascii_lowercase().as_str() {
1961 "dark" => "dark",
1962 "light" => "light",
1963 "auto" => "",
1964 other => {
1965 return Err(CliError::with_suggestion(
1966 ErrorKind::Usage,
1967 format!("invalid color-scheme: {other}"),
1968 "Use dark, light, or auto",
1969 ));
1970 }
1971 };
1972 self.manager
1973 .set_emulated_media(
1974 media,
1975 Some(vec![("prefers-color-scheme".into(), value.into())]),
1976 )
1977 .await
1978 .map_err(|e| {
1979 CliError::new(ErrorKind::Browser, format!("emulate color-scheme: {e}"))
1980 })?;
1981 } else if let Some(m) = media {
1982 self.manager
1983 .set_emulated_media(Some(m), None)
1984 .await
1985 .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate media: {e}")))?;
1986 }
1987
1988 if let Some(headers_raw) = extra_headers_json {
1989 let map: HashMap<String, String> = if headers_raw.trim().is_empty() {
1990 HashMap::new()
1991 } else {
1992 serde_json::from_str(headers_raw).map_err(|e| {
1993 CliError::with_suggestion(
1994 ErrorKind::Usage,
1995 format!("invalid extra-headers JSON: {e}"),
1996 r#"Pass object JSON e.g. {"X-Custom":"1"}"#,
1997 )
1998 })?
1999 };
2000 network::set_extra_headers(&self.manager.client, &session_id, &map)
2001 .await
2002 .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate headers: {e}")))?;
2003 }
2004
2005 let mut applied_viewport = None;
2006 if let Some(vp) = viewport {
2007 let spec = crate::constants::parse_viewport_spec(vp).map_err(|e| {
2008 CliError::with_suggestion(
2009 ErrorKind::Usage,
2010 e,
2011 "Format: WxHxDPR[,mobile][,touch][,landscape]",
2012 )
2013 })?;
2014 self.manager
2015 .set_viewport(
2016 spec.width,
2017 spec.height,
2018 spec.device_scale_factor,
2019 spec.mobile,
2020 )
2021 .await
2022 .map_err(|e| CliError::new(ErrorKind::Browser, format!("emulate viewport: {e}")))?;
2023 applied_viewport = Some(json!({
2024 "width": spec.width,
2025 "height": spec.height,
2026 "device_scale_factor": spec.device_scale_factor,
2027 "mobile": spec.mobile,
2028 "has_touch": spec.has_touch,
2029 "is_landscape": spec.is_landscape,
2030 }));
2031 }
2032
2033 Ok(json!({
2034 "emulated": true,
2035 "user_agent": user_agent,
2036 "locale": locale,
2037 "timezone": timezone,
2038 "offline": offline || applied_network == Some("Offline"),
2039 "latitude": latitude,
2040 "longitude": longitude,
2041 "media": media,
2042 "network_conditions": applied_network,
2043 "cpu_throttling_rate": cpu_throttling_rate,
2044 "color_scheme": color_scheme,
2045 "extra_headers": extra_headers_json.is_some(),
2046 "viewport": applied_viewport,
2047 }))
2048 }
2049
2050 pub async fn resize(
2051 &mut self,
2052 width: i32,
2053 height: i32,
2054 scale: f64,
2055 mobile: bool,
2056 ) -> Result<Value, CliError> {
2057 self.drain_events();
2058 self.manager
2059 .set_viewport(width, height, scale, mobile)
2060 .await
2061 .map_err(|e| CliError::new(ErrorKind::Browser, format!("resize failed: {e}")))?;
2062 Ok(json!({
2063 "width": width,
2064 "height": height,
2065 "scale": scale,
2066 "mobile": mobile,
2067 }))
2068 }
2069
2070 pub async fn perf_start(
2071 &mut self,
2072 path: Option<&Path>,
2073 reload: bool,
2074 auto_stop: bool,
2075 ) -> Result<Value, CliError> {
2076 self.drain_events();
2077 let session_id = self
2078 .manager
2079 .active_session_id()
2080 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
2081 .to_string();
2082 self.trace_chunks.clear();
2083 self.manager
2084 .client
2085 .send_command(
2086 "Tracing.start",
2087 Some(json!({
2088 "categories": "devtools.timeline,v8.execute,blink.user_timing,disabled-by-default-devtools.timeline",
2089 "transferMode": "ReportEvents",
2090 })),
2091 None,
2092 )
2093 .await
2094 .map_err(|e| CliError::new(ErrorKind::Browser, format!("perf start: {e}")))?;
2095 let _ = self
2096 .manager
2097 .client
2098 .send_command_no_params("Performance.enable", Some(&session_id))
2099 .await;
2100 self.perf_active = true;
2101 if reload {
2102 let _ = self.reload(false).await?;
2103 }
2104 let mut out = json!({
2105 "perf": "start",
2106 "path": path.map(|p| p.to_string_lossy().to_string()),
2107 "reload": reload,
2108 "auto_stop": auto_stop,
2109 });
2110 if auto_stop {
2111 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
2113 let stop = self.perf_stop(path).await?;
2114 if let Some(obj) = out.as_object_mut() {
2115 obj.insert("auto_stopped".into(), json!(true));
2116 obj.insert("stop".into(), stop);
2117 }
2118 }
2119 Ok(out)
2120 }
2121
2122 pub async fn perf_stop(&mut self, path: Option<&Path>) -> Result<Value, CliError> {
2123 self.pump_events().await;
2124 self.tracing_complete = false;
2125 if self.perf_active {
2126 let _ = self
2127 .manager
2128 .client
2129 .send_command("Tracing.end", None, None)
2130 .await;
2131 self.perf_active = false;
2132 }
2133 for _ in 0..100 {
2135 self.pump_events().await;
2136 if self.tracing_complete && !self.trace_chunks.is_empty() {
2137 for _ in 0..5 {
2138 self.pump_events().await;
2139 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2140 }
2141 break;
2142 }
2143 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2144 }
2145 let body = self.trace_chunks.join("\n");
2146 let chunks = self.trace_chunks.len();
2147 self.last_trace_body = Some(body.clone());
2148 let mut out_path = path.map(|p| p.to_path_buf());
2149 if out_path.is_none() {
2150 let stamp = std::time::SystemTime::now()
2152 .duration_since(std::time::UNIX_EPOCH)
2153 .map(|d| d.as_millis())
2154 .unwrap_or(0);
2155 out_path = Some(PathBuf::from(format!("trace-{stamp}.ndjson")));
2156 }
2157 if let Some(ref p) = out_path {
2158 if let Some(parent) = p.parent() {
2159 if !parent.as_os_str().is_empty() {
2160 std::fs::create_dir_all(parent).map_err(|e| {
2161 CliError::new(ErrorKind::Io, format!("perf stop mkdir: {e}"))
2162 })?;
2163 }
2164 }
2165 std::fs::write(p, body.as_bytes())
2166 .map_err(|e| CliError::new(ErrorKind::Io, format!("perf stop write: {e}")))?;
2167 self.last_trace_path = Some(p.clone());
2168 }
2169 self.trace_chunks.clear();
2170 self.tracing_complete = false;
2171 let set_id = format!(
2173 "set-{}",
2174 std::time::SystemTime::now()
2175 .duration_since(std::time::UNIX_EPOCH)
2176 .map(|d| d.as_millis())
2177 .unwrap_or(0)
2178 );
2179 Ok(json!({
2180 "perf": "stop",
2181 "path": out_path.map(|p| p.to_string_lossy().to_string()),
2182 "events": chunks,
2183 "available_insight_sets": [{
2184 "insight_set_id": set_id,
2185 "insights": [
2186 "DocumentLatency",
2187 "LCPBreakdown",
2188 "CLSCulprits",
2189 "INPBreakdown",
2190 "RenderBlocking",
2191 "ThirdParties"
2192 ]
2193 }],
2194 }))
2195 }
2196
2197 pub async fn perf_insight(
2198 &mut self,
2199 name: Option<&str>,
2200 insight_set_id: Option<&str>,
2201 ) -> Result<Value, CliError> {
2202 self.pump_events().await;
2203 let session_id = self
2204 .manager
2205 .active_session_id()
2206 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
2207 .to_string();
2208 let live_metrics = self
2209 .manager
2210 .client
2211 .send_command("Performance.getMetrics", None, Some(&session_id))
2212 .await
2213 .ok();
2214
2215 let offline = if let Some(ref p) = self.last_trace_path {
2216 crate::native::perf_insight::analyze_file(p, name).ok()
2217 } else if let Some(ref body) = self.last_trace_body {
2218 crate::native::perf_insight::analyze_text(body, name, None).ok()
2219 } else {
2220 None
2221 };
2222
2223 Ok(json!({
2224 "perf": "insight",
2225 "name": name,
2226 "insight_name": name,
2227 "insight_set_id": insight_set_id,
2228 "live_metrics": live_metrics,
2229 "trace_insight": offline,
2230 "trace_path": self.last_trace_path.as_ref().map(|p| p.to_string_lossy().to_string()),
2231 }))
2232 }
2233
2234 pub fn perf_insight_file(path: &Path, name: Option<&str>) -> Result<Value, CliError> {
2236 crate::native::perf_insight::analyze_file(path, name).map_err(|e| {
2237 CliError::with_suggestion(ErrorKind::Io, e, "Pass a path produced by perf stop --path")
2238 })
2239 }
2240
2241 pub async fn screencast_start(&mut self, path: Option<&Path>) -> Result<Value, CliError> {
2242 self.pump_events().await;
2243 let session_id = self
2244 .manager
2245 .active_session_id()
2246 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
2247 .to_string();
2248 self.screencast_frames.clear();
2249 self.screencast_ack_ids.clear();
2250 let dir = path.map(|p| p.to_path_buf()).unwrap_or_else(|| {
2251 let stamp = std::time::SystemTime::now()
2252 .duration_since(std::time::UNIX_EPOCH)
2253 .map(|d| d.as_millis())
2254 .unwrap_or(0);
2255 PathBuf::from(format!("screencast-{stamp}"))
2256 });
2257 std::fs::create_dir_all(&dir)
2258 .map_err(|e| CliError::new(ErrorKind::Io, format!("screencast dir: {e}")))?;
2259 self.screencast_dir = Some(dir.clone());
2260 let _ = self
2262 .manager
2263 .client
2264 .send_command_no_params("Page.enable", Some(&session_id))
2265 .await;
2266 self.manager
2267 .client
2268 .send_command(
2269 "Page.startScreencast",
2270 Some(json!({
2271 "format": "png",
2272 "quality": 60,
2273 "maxWidth": 1280,
2274 "maxHeight": 720,
2275 "everyNthFrame": 1,
2276 })),
2277 Some(&session_id),
2278 )
2279 .await
2280 .map_err(|e| CliError::new(ErrorKind::Browser, format!("screencast start: {e}")))?;
2281 self.screencast_active = true;
2282 for _ in 0..15 {
2284 self.pump_events().await;
2285 tokio::time::sleep(std::time::Duration::from_millis(40)).await;
2286 }
2287 Ok(json!({
2288 "screencast": "start",
2289 "dir": dir.to_string_lossy(),
2290 "note": "Frames buffered in process; stop writes PNG files + manifest.json",
2291 "frames_buffered": self.screencast_frames.len(),
2292 }))
2293 }
2294
2295 pub async fn screencast_stop(&mut self, path: Option<&Path>) -> Result<Value, CliError> {
2296 for _ in 0..40 {
2297 self.pump_events().await;
2298 tokio::time::sleep(std::time::Duration::from_millis(30)).await;
2299 }
2300 let session_id = self
2301 .manager
2302 .active_session_id()
2303 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
2304 .to_string();
2305 if self.screencast_active {
2306 let _ = self
2307 .manager
2308 .client
2309 .send_command("Page.stopScreencast", None, Some(&session_id))
2310 .await;
2311 self.screencast_active = false;
2312 }
2313 self.pump_events().await;
2314
2315 let dir = self.screencast_dir.clone().unwrap_or_else(|| {
2316 PathBuf::from(format!(
2317 "screencast-{}",
2318 std::time::SystemTime::now()
2319 .duration_since(std::time::UNIX_EPOCH)
2320 .map(|d| d.as_millis())
2321 .unwrap_or(0)
2322 ))
2323 });
2324 std::fs::create_dir_all(&dir)
2325 .map_err(|e| CliError::new(ErrorKind::Io, format!("screencast stop mkdir: {e}")))?;
2326
2327 use base64::Engine;
2328 let engine = base64::engine::general_purpose::STANDARD;
2329 let mut written = 0u64;
2330 let mut paths: Vec<String> = Vec::new();
2331 for (i, b64) in self.screencast_frames.iter().enumerate() {
2332 let bytes = match engine.decode(b64) {
2333 Ok(b) => b,
2334 Err(_) => continue,
2335 };
2336 let name = format!("frame-{:05}.png", i + 1);
2337 let out = dir.join(&name);
2338 if std::fs::write(&out, &bytes).is_ok() {
2339 written += 1;
2340 paths.push(out.to_string_lossy().into_owned());
2341 }
2342 }
2343 let video_path = path.map(|p| p.to_path_buf()).or_else(|| {
2344 self.screencast_dir.as_ref().and_then(|d| {
2346 let s = d.to_string_lossy();
2347 if s.ends_with(".webm") || s.ends_with(".mp4") {
2348 Some(d.clone())
2349 } else {
2350 None
2351 }
2352 })
2353 });
2354 let mut video_out: Option<String> = None;
2355 let mut encode_note: Option<String> = None;
2356 if let Some(ref vp) = video_path {
2357 let ext = vp
2358 .extension()
2359 .and_then(|e| e.to_str())
2360 .unwrap_or("mp4")
2361 .to_ascii_lowercase();
2362 let is_video = ext == "webm" || ext == "mp4";
2363 if is_video && written > 0 {
2364 if let Some(parent) = vp.parent() {
2365 if !parent.as_os_str().is_empty() {
2366 let _ = std::fs::create_dir_all(parent);
2367 }
2368 }
2369 let pattern = dir.join("frame-%05d.png");
2370 let vcodec = if ext == "webm" {
2371 "libvpx-vp9"
2372 } else {
2373 "libx264"
2374 };
2375 let mut cmd = std::process::Command::new("ffmpeg");
2376 cmd.arg("-y")
2377 .arg("-framerate")
2378 .arg("10")
2379 .arg("-i")
2380 .arg(&pattern)
2381 .arg("-c:v")
2382 .arg(vcodec)
2383 .arg("-pix_fmt")
2384 .arg("yuv420p")
2385 .arg(vp);
2386 match cmd.output() {
2387 Ok(out) if out.status.success() => {
2388 video_out = Some(vp.to_string_lossy().into_owned());
2389 encode_note = Some("encoded via ffmpeg".into());
2390 }
2391 Ok(out) => {
2392 encode_note = Some(format!(
2393 "ffmpeg failed: {}",
2394 String::from_utf8_lossy(&out.stderr)
2395 ));
2396 }
2397 Err(e) => {
2398 encode_note =
2399 Some(format!("ffmpeg not available: {e}; PNG frames kept in dir"));
2400 }
2401 }
2402 }
2403 }
2404 let manifest = json!({
2405 "format": "png",
2406 "frame_count": written,
2407 "frames": paths,
2408 "video": video_out,
2409 "encode_note": encode_note,
2410 "ffmpeg_hint": format!(
2411 "ffmpeg -y -framerate 10 -i {}/frame-%05d.png -c:v libx264 -pix_fmt yuv420p {}.mp4",
2412 dir.display(),
2413 dir.display()
2414 ),
2415 });
2416 let manifest_path = dir.join("manifest.json");
2417 let _ = std::fs::write(
2418 &manifest_path,
2419 serde_json::to_vec_pretty(&manifest).unwrap_or_default(),
2420 );
2421 self.screencast_frames.clear();
2422 self.screencast_ack_ids.clear();
2423 Ok(json!({
2424 "screencast": "stop",
2425 "dir": dir.to_string_lossy(),
2426 "frame_count": written,
2427 "manifest": manifest_path.to_string_lossy(),
2428 "video": video_out,
2429 "encode_note": encode_note,
2430 }))
2431 }
2432
2433 pub async fn heap_take(&mut self, path: &Path) -> Result<Value, CliError> {
2434 self.drain_events();
2435 let session_id = self
2436 .manager
2437 .active_session_id()
2438 .map_err(|e| CliError::new(ErrorKind::Browser, e))?
2439 .to_string();
2440 self.heap_chunks.clear();
2441 self.heap_snapshot_finished = false;
2442 let _ = self
2443 .manager
2444 .client
2445 .send_command_no_params("HeapProfiler.enable", Some(&session_id))
2446 .await;
2447 self.manager
2448 .client
2449 .send_command(
2450 "HeapProfiler.takeHeapSnapshot",
2451 Some(json!({ "reportProgress": true })),
2452 Some(&session_id),
2453 )
2454 .await
2455 .map_err(|e| CliError::new(ErrorKind::Browser, format!("heap take: {e}")))?;
2456 for _ in 0..200 {
2458 self.drain_events();
2459 if self.heap_snapshot_finished && !self.heap_chunks.is_empty() {
2460 for _ in 0..10 {
2462 self.drain_events();
2463 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2464 }
2465 break;
2466 }
2467 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2468 }
2469 for _ in 0..20 {
2471 self.drain_events();
2472 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2473 }
2474 if let Some(parent) = path.parent() {
2475 if !parent.as_os_str().is_empty() {
2476 std::fs::create_dir_all(parent)
2477 .map_err(|e| CliError::new(ErrorKind::Io, format!("heap take mkdir: {e}")))?;
2478 }
2479 }
2480 let body = self.heap_chunks.join("");
2481 let bytes = body.len();
2482 if bytes == 0 {
2483 return Err(CliError::with_suggestion(
2484 ErrorKind::Browser,
2485 "heap take produced empty snapshot (no HeapProfiler chunks received)",
2486 "Ensure Chrome supports HeapProfiler; re-run doctor; check event forwarders",
2487 ));
2488 }
2489 std::fs::write(path, body.as_bytes())
2490 .map_err(|e| CliError::new(ErrorKind::Io, format!("heap take write: {e}")))?;
2491 self.heap_chunks.clear();
2492 self.heap_snapshot_finished = false;
2493 Ok(json!({
2494 "heap": "take",
2495 "path": path.to_string_lossy(),
2496 "bytes": bytes,
2497 }))
2498 }
2499
2500 pub fn heap_file_summary(path: &Path) -> Result<Value, CliError> {
2501 crate::native::heap_snapshot::summarize(path).map_err(|e| {
2502 CliError::with_suggestion(
2503 ErrorKind::Io,
2504 e,
2505 "Pass a path produced by heap take (.heapsnapshot JSON)",
2506 )
2507 })
2508 }
2509
2510 pub fn heap_close(path: &Path) -> Result<Value, CliError> {
2511 crate::native::heap_snapshot::close_snapshot(path).map_err(|e| {
2512 CliError::with_suggestion(
2513 ErrorKind::Io,
2514 e,
2515 "Pass a path produced by heap take (.heapsnapshot JSON)",
2516 )
2517 })
2518 }
2519
2520 pub fn heap_compare(base: &Path, current: &Path) -> Result<Value, CliError> {
2521 crate::native::heap_snapshot::compare(base, current).map_err(|e| {
2522 CliError::with_suggestion(ErrorKind::Io, e, "Pass two paths produced by heap take")
2523 })
2524 }
2525
2526 pub fn heap_details(path: &Path) -> Result<Value, CliError> {
2527 crate::native::heap_snapshot::details(path).map_err(|e| {
2528 CliError::with_suggestion(ErrorKind::Io, e, "Pass a valid .heapsnapshot path")
2529 })
2530 }
2531
2532 pub fn heap_dup_strings(path: &Path) -> Result<Value, CliError> {
2533 crate::native::heap_snapshot::duplicate_strings(path).map_err(|e| {
2534 CliError::with_suggestion(ErrorKind::Io, e, "Pass a valid .heapsnapshot path")
2535 })
2536 }
2537
2538 pub fn heap_class_nodes(path: &Path, id: u64) -> Result<Value, CliError> {
2539 crate::native::heap_snapshot::class_nodes(path, id).map_err(|e| {
2540 CliError::with_suggestion(
2541 ErrorKind::Io,
2542 e,
2543 "Pass a valid .heapsnapshot path and class id",
2544 )
2545 })
2546 }
2547
2548 pub fn heap_node_op(path: &Path, node: u64, op: &str) -> Result<Value, CliError> {
2549 crate::native::heap_snapshot::node_op(path, node, op).map_err(|e| {
2550 CliError::with_suggestion(
2551 ErrorKind::Io,
2552 e,
2553 "Pass a valid .heapsnapshot path and node id (or 0-based index)",
2554 )
2555 })
2556 }
2557
2558 pub fn heap_object_details(path: &Path, node: u64) -> Result<Value, CliError> {
2560 crate::native::heap_snapshot::object_details(path, node).map_err(|e| {
2561 CliError::with_suggestion(
2562 ErrorKind::Io,
2563 e,
2564 "Pass a valid .heapsnapshot path and node id (or 0-based index)",
2565 )
2566 })
2567 }
2568
2569 pub async fn extension_list(&mut self) -> Result<Value, CliError> {
2570 self.pump_events().await;
2571 let targets = self
2572 .manager
2573 .client
2574 .send_command("Target.getTargets", None, None)
2575 .await
2576 .map_err(|e| CliError::new(ErrorKind::Browser, format!("extension list: {e}")))?;
2577 let list = targets
2578 .get("targetInfos")
2579 .and_then(|v| v.as_array())
2580 .cloned()
2581 .unwrap_or_default();
2582 let extensions: Vec<Value> = list
2583 .into_iter()
2584 .filter(|t| {
2585 t.get("url")
2586 .and_then(|u| u.as_str())
2587 .map(|u| u.starts_with("chrome-extension://"))
2588 .unwrap_or(false)
2589 || t.get("type").and_then(|x| x.as_str()) == Some("service_worker")
2590 })
2591 .map(|t| {
2592 let url = t.get("url").and_then(|u| u.as_str()).unwrap_or("");
2593 let id = url
2594 .strip_prefix("chrome-extension://")
2595 .and_then(|rest| rest.split('/').next())
2596 .unwrap_or("")
2597 .to_string();
2598 json!({
2599 "id": id,
2600 "url": url,
2601 "type": t.get("type"),
2602 "title": t.get("title"),
2603 "targetId": t.get("targetId"),
2604 })
2605 })
2606 .collect();
2607 Ok(json!({ "extensions": extensions, "count": extensions.len() }))
2608 }
2609
2610 pub async fn extension_reload(&mut self, id: &str) -> Result<Value, CliError> {
2612 self.pump_events().await;
2613 let listed = self.extension_list().await?;
2614 let targets = listed
2615 .get("extensions")
2616 .and_then(|v| v.as_array())
2617 .cloned()
2618 .unwrap_or_default();
2619 let match_t = targets.iter().find(|t| {
2620 t.get("id")
2621 .and_then(|v| v.as_str())
2622 .map(|s| s == id || s.starts_with(id) || id.contains(s))
2623 .unwrap_or(false)
2624 });
2625 let Some(t) = match_t else {
2626 return Err(CliError::with_suggestion(
2627 ErrorKind::NoInput,
2628 format!("extension id not found: {id}"),
2629 "Run extension list after extension install <unpacked-dir>",
2630 ));
2631 };
2632 let target_id = t
2633 .get("targetId")
2634 .and_then(|v| v.as_str())
2635 .ok_or_else(|| CliError::new(ErrorKind::Browser, "missing targetId"))?
2636 .to_string();
2637 let _ = self
2639 .manager
2640 .client
2641 .send_command(
2642 "Target.closeTarget",
2643 Some(json!({ "targetId": target_id })),
2644 None,
2645 )
2646 .await;
2647 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2648 let again = self.extension_list().await?;
2649 Ok(json!({
2650 "reloaded": id,
2651 "closed_target": target_id,
2652 "after": again,
2653 "one_shot": true,
2654 "ok": true,
2655 "note": "one-shot SW restart via Target.closeTarget; install path is --load-extension on the same invocation",
2656 }))
2657 }
2658
2659 pub async fn extension_trigger(&mut self, id: &str) -> Result<Value, CliError> {
2660 self.pump_events().await;
2661 let listed = self.extension_list().await?;
2662 let targets = listed
2663 .get("extensions")
2664 .and_then(|v| v.as_array())
2665 .cloned()
2666 .unwrap_or_default();
2667 let match_t = targets.iter().find(|t| {
2668 t.get("id")
2669 .and_then(|v| v.as_str())
2670 .map(|s| s == id || s.starts_with(id))
2671 .unwrap_or(false)
2672 && t.get("type").and_then(|v| v.as_str()) == Some("service_worker")
2673 });
2674 let Some(t) = match_t else {
2675 return Err(CliError::with_suggestion(
2676 ErrorKind::NoInput,
2677 format!("extension service_worker not found for id: {id}"),
2678 "Use extension list; trigger requires a service_worker target",
2679 ));
2680 };
2681 let target_id = t
2682 .get("targetId")
2683 .and_then(|v| v.as_str())
2684 .ok_or_else(|| CliError::new(ErrorKind::Browser, "missing targetId"))?
2685 .to_string();
2686 let attach = self
2688 .manager
2689 .client
2690 .send_command(
2691 "Target.attachToTarget",
2692 Some(json!({ "targetId": target_id, "flatten": true })),
2693 None,
2694 )
2695 .await
2696 .map_err(|e| CliError::new(ErrorKind::Browser, format!("attach extension SW: {e}")))?;
2697 let session = attach
2698 .get("sessionId")
2699 .and_then(|v| v.as_str())
2700 .map(|s| s.to_string());
2701 let eval = self
2702 .manager
2703 .client
2704 .send_command(
2705 "Runtime.evaluate",
2706 Some(json!({
2707 "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) }; } })()",
2708 "returnByValue": true,
2709 "awaitPromise": true,
2710 })),
2711 session.as_deref(),
2712 )
2713 .await;
2714 Ok(json!({
2715 "triggered": id,
2716 "targetId": target_id,
2717 "evaluate": eval.unwrap_or(Value::Null),
2718 "one_shot": true,
2719 "ok": true,
2720 "note": "best-effort SW Runtime.evaluate in the same process; popup UI may need headed Chrome",
2721 }))
2722 }
2723
2724 pub async fn devtools3p_list(&mut self) -> Result<Value, CliError> {
2726 self.pump_events().await;
2727 let expr = r#"(() => {
2728 return new Promise((resolve) => {
2729 if (!window.__dtmcp) window.__dtmcp = {};
2730 window.__dtmcp.toolGroups = [];
2731 const groups = [];
2732 const event = new CustomEvent('devtoolstooldiscovery');
2733 event.respondWith = (toolGroup) => {
2734 if (!toolGroup || typeof toolGroup.name !== 'string' || !Array.isArray(toolGroup.tools)) {
2735 return;
2736 }
2737 const tools = [];
2738 for (const tool of toolGroup.tools) {
2739 if (!tool || typeof tool.name !== 'string') continue;
2740 tools.push({
2741 name: tool.name,
2742 description: typeof tool.description === 'string' ? tool.description : '',
2743 inputSchema: tool.inputSchema || {},
2744 });
2745 }
2746 const g = {
2747 name: toolGroup.name,
2748 description: typeof toolGroup.description === 'string' ? toolGroup.description : '',
2749 tools,
2750 };
2751 groups.push(g);
2752 window.__dtmcp.toolGroups.push({
2753 name: g.name,
2754 description: g.description,
2755 tools: toolGroup.tools,
2756 });
2757 if (!window.__dtmcp.executeTool) {
2758 window.__dtmcp.executeTool = async (toolName, args) => {
2759 for (const group of (window.__dtmcp.toolGroups || [])) {
2760 const t = (group.tools || []).find((x) => x.name === toolName);
2761 if (t && typeof t.execute === 'function') {
2762 return await t.execute(args || {});
2763 }
2764 }
2765 throw new Error('Tool ' + toolName + ' not found');
2766 };
2767 }
2768 };
2769 window.dispatchEvent(event);
2770 setTimeout(() => resolve(groups), 0);
2771 });
2772 })()"#;
2773 let result = self.eval(expr, None, Some("accept"), None).await?;
2774 let groups = result
2775 .get("result")
2776 .cloned()
2777 .or_else(|| result.get("value").cloned())
2778 .unwrap_or(result);
2779 let tools_flat: Vec<Value> = groups
2780 .as_array()
2781 .map(|arr| {
2782 arr.iter()
2783 .flat_map(|g| {
2784 g.get("tools")
2785 .and_then(|t| t.as_array())
2786 .cloned()
2787 .unwrap_or_default()
2788 })
2789 .collect()
2790 })
2791 .unwrap_or_default();
2792 Ok(json!({
2793 "groups": groups,
2794 "tools": tools_flat,
2795 "count": tools_flat.len(),
2796 "available": true,
2797 }))
2798 }
2799
2800 pub async fn devtools3p_exec(
2801 &mut self,
2802 name: &str,
2803 params_json: Option<&str>,
2804 ) -> Result<Value, CliError> {
2805 let _ = self.devtools3p_list().await?;
2806 let params = params_json.unwrap_or("{}");
2807 let parsed: Value = serde_json::from_str(params).map_err(|e| {
2809 CliError::with_suggestion(
2810 ErrorKind::Usage,
2811 format!("invalid params JSON: {e}"),
2812 r#"Pass --params '{"key":"value"}'"#,
2813 )
2814 })?;
2815 if !parsed.is_object() {
2816 return Err(CliError::with_suggestion(
2817 ErrorKind::Usage,
2818 "params must be a JSON object",
2819 r#"Pass --params '{"key":"value"}'"#,
2820 ));
2821 }
2822 let name_js = serde_json::to_string(name).unwrap_or_else(|_| "\"\"".into());
2823 let params_js = parsed.to_string();
2824 let expr = format!(
2825 r#"(async () => {{
2826 if (!window.__dtmcp || typeof window.__dtmcp.executeTool !== 'function') {{
2827 throw new Error('No third-party tools discovered on page');
2828 }}
2829 const out = await window.__dtmcp.executeTool({name_js}, {params_js});
2830 try {{ return JSON.parse(JSON.stringify(out)); }} catch (_) {{ return String(out); }}
2831 }})()"#
2832 );
2833 let result = self.eval(&expr, None, Some("accept"), None).await?;
2834 if result.get("exceptionDetails").is_some() {
2835 return Err(CliError::with_suggestion(
2836 ErrorKind::NoInput,
2837 format!("devtools3p exec {name} failed"),
2838 "List tools with browser-automation-cli --category-third-party devtools3p list --url <page>",
2839 ));
2840 }
2841 let value = result
2842 .get("result")
2843 .cloned()
2844 .or_else(|| result.get("value").cloned())
2845 .unwrap_or(result);
2846 Ok(json!({
2847 "name": name,
2848 "result": value,
2849 "ok": true,
2850 }))
2851 }
2852
2853 pub async fn webmcp_list(&mut self) -> Result<Value, CliError> {
2855 self.pump_events().await;
2856 let expr = r#"(() => {
2857 const tools = [];
2858 // Declarative form-based tools (test harness / early WebMCP)
2859 document.querySelectorAll('form[toolname]').forEach((form) => {
2860 tools.push({
2861 name: form.getAttribute('toolname') || '',
2862 description: form.getAttribute('tooldescription') || '',
2863 source: 'form',
2864 });
2865 });
2866 // Future navigator surface (best-effort)
2867 try {
2868 if (navigator.modelContext && typeof navigator.modelContext.listTools === 'function') {
2869 // sync list not always available; ignore
2870 }
2871 } catch (_) {}
2872 if (window.__webmcpTools && Array.isArray(window.__webmcpTools)) {
2873 for (const t of window.__webmcpTools) {
2874 if (t && t.name) tools.push({ name: t.name, description: t.description || '', source: 'window' });
2875 }
2876 }
2877 return tools;
2878 })()"#;
2879 let result = self.eval(expr, None, Some("accept"), None).await?;
2880 let tools = result
2881 .get("result")
2882 .cloned()
2883 .or_else(|| result.get("value").cloned())
2884 .unwrap_or(result);
2885 let count = tools.as_array().map(|a| a.len()).unwrap_or(0);
2886 Ok(json!({
2887 "tools": tools,
2888 "count": count,
2889 "available": true,
2890 "note": "Requires Chrome with WebMCP/DevToolsWebMCPSupport for full surface; form[toolname] always listed",
2891 }))
2892 }
2893
2894 pub async fn webmcp_exec(
2895 &mut self,
2896 name: &str,
2897 input_json: Option<&str>,
2898 ) -> Result<Value, CliError> {
2899 let input = input_json.unwrap_or("{}");
2900 let parsed: Value = serde_json::from_str(input).map_err(|e| {
2901 CliError::with_suggestion(
2902 ErrorKind::Usage,
2903 format!("invalid input JSON: {e}"),
2904 r#"Pass --input '{"key":"value"}'"#,
2905 )
2906 })?;
2907 let name_js = serde_json::to_string(name).unwrap_or_else(|_| "\"\"".into());
2908 let input_js = parsed.to_string();
2909 let expr = format!(
2910 r#"(async () => {{
2911 const toolName = {name_js};
2912 const input = {input_js};
2913 // Form-based tools
2914 const form = document.querySelector('form[toolname="' + CSS.escape(toolName) + '"]')
2915 || document.querySelector('form[toolname="' + toolName + '"]');
2916 if (form) {{
2917 return await new Promise((resolve, reject) => {{
2918 const handler = (event) => {{
2919 event.preventDefault();
2920 try {{
2921 if (typeof event.respondWith === 'function') {{
2922 // page may set respondWith on submit
2923 }}
2924 }} catch (_) {{}}
2925 }};
2926 form.addEventListener('submit', handler, {{ once: true }});
2927 // Prefer page-defined onsubmit
2928 if (typeof form.onsubmit === 'function') {{
2929 const fake = {{
2930 preventDefault() {{}},
2931 respondWith(v) {{ resolve({{ status: 'Completed', output: v }}); }},
2932 }};
2933 try {{
2934 form.onsubmit(fake);
2935 setTimeout(() => resolve({{ status: 'Completed', output: null }}), 0);
2936 }} catch (e) {{
2937 reject(e);
2938 }}
2939 return;
2940 }}
2941 form.requestSubmit ? form.requestSubmit() : form.submit();
2942 setTimeout(() => resolve({{ status: 'Completed', output: null, note: 'form submitted' }}), 50);
2943 }});
2944 }}
2945 if (window.__webmcpTools) {{
2946 const t = window.__webmcpTools.find((x) => x.name === toolName);
2947 if (t && typeof t.execute === 'function') {{
2948 const out = await t.execute(input);
2949 return {{ status: 'Completed', output: out }};
2950 }}
2951 }}
2952 throw new Error('Tool ' + toolName + ' not found');
2953 }})()"#
2954 );
2955 let result = self.eval(&expr, None, Some("accept"), None).await?;
2956 if result.get("exceptionDetails").is_some() {
2957 let msg = result
2958 .pointer("/exceptionDetails/exception/description")
2959 .or_else(|| result.pointer("/exceptionDetails/text"))
2960 .and_then(|v| v.as_str())
2961 .unwrap_or("tool not found");
2962 return Err(CliError::with_suggestion(
2963 ErrorKind::NoInput,
2964 format!("webmcp exec {name}: {msg}"),
2965 "List tools first; page must expose form[toolname] or __webmcpTools",
2966 ));
2967 }
2968 let value = result
2969 .get("result")
2970 .cloned()
2971 .or_else(|| result.get("value").cloned())
2972 .unwrap_or(result);
2973 Ok(json!({
2974 "name": name,
2975 "result": value,
2976 "ok": true,
2977 }))
2978 }
2979
2980 pub async fn shutdown(mut self) -> Result<(), CliError> {
2982 self.console_log.clear();
2983 self.network_log.clear();
2984 self.heap_chunks.clear();
2985 self.trace_chunks.clear();
2986 self.screencast_frames.clear();
2987 self.screencast_ack_ids.clear();
2988 self.screencast_dir = None;
2989 self.last_trace_body = None;
2990 self.ref_map.clear();
2991 self.manager.close().await.map_err(|e| {
2992 CliError::with_suggestion(
2993 ErrorKind::Browser,
2994 format!("Browser close failed: {e}"),
2995 "Process reaped by chromiumoxide finalize or Lightpanda process Drop",
2996 )
2997 })
2998 }
2999}
3000
3001fn verify_image_magic(path: &Path, format: &str) -> bool {
3002 let Ok(bytes) = std::fs::read(path) else {
3003 return false;
3004 };
3005 match format {
3006 "png" => bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]),
3007 "jpeg" | "jpg" => bytes.starts_with(&[0xFF, 0xD8, 0xFF]),
3008 "webp" => {
3009 bytes.len() >= 12
3010 && bytes[0..4] == [0x52, 0x49, 0x46, 0x46]
3011 && bytes[8..12] == [0x57, 0x45, 0x42, 0x50]
3012 }
3013 _ => !bytes.is_empty(),
3014 }
3015}
3016
3017pub fn tree_to_at_refs(tree: &str) -> String {
3019 let mut out = String::with_capacity(tree.len());
3020 let bytes = tree.as_bytes();
3021 let mut i = 0;
3022 while i < bytes.len() {
3023 if bytes[i..].starts_with(b"ref=") && i + 4 < bytes.len() && bytes[i + 4] == b'e' {
3024 out.push('@');
3025 i += 4;
3026 while i < bytes.len() && bytes[i].is_ascii_alphanumeric() {
3027 out.push(bytes[i] as char);
3028 i += 1;
3029 }
3030 continue;
3031 }
3032 out.push(bytes[i] as char);
3033 i += 1;
3034 }
3035 out
3036}
3037
3038fn normalize_eval_expression(
3045 expression: &str,
3046 args_json: Option<&str>,
3047) -> Result<String, CliError> {
3048 if let Some(args_raw) = args_json {
3049 let uids: Vec<String> = serde_json::from_str(args_raw).map_err(|e| {
3050 CliError::with_suggestion(
3051 ErrorKind::Usage,
3052 format!("eval --args must be a JSON array of uids: {e}"),
3053 r#"Example: --args '["@e1","@e2"]'"#,
3054 )
3055 })?;
3056 let args_js: Vec<String> = uids
3057 .iter()
3058 .map(|u| {
3059 let cleaned = u.trim().trim_start_matches('@');
3060 format!("\"{cleaned}\"")
3061 })
3062 .collect();
3063 let joined = args_js.join(",");
3064 return Ok(format!("({expression})({joined})"));
3065 }
3066
3067 let trimmed = expression.trim();
3068 let for_detect = trimmed.trim_end_matches(';').trim_end();
3070 if for_detect.ends_with(")()") {
3072 return Ok(expression.to_string());
3073 }
3074
3075 let head = trimmed.trim_start();
3076 let is_bare_callable = head.starts_with("function")
3077 || head.starts_with("async function")
3078 || (head.starts_with("async") && trimmed.contains("=>"))
3079 || (head.starts_with('(') && trimmed.contains("=>"));
3080
3081 if is_bare_callable {
3082 return Ok(format!("({expression})()"));
3084 }
3085
3086 Ok(expression.to_string())
3087}
3088
3089fn mark_launched(life: &Lifecycle, pid: Option<u32>) {
3090 if let Ok(mut ledger) = life.ledger.lock() {
3091 ledger.chrome_launched = true;
3092 ledger.chrome_pid = pid;
3093 }
3094}
3095
3096fn mark_closed(life: &Lifecycle) {
3097 if let Ok(mut ledger) = life.ledger.lock() {
3098 ledger.chrome_launched = false;
3099 ledger.chrome_pid = None;
3100 ledger.profile_dir = None;
3101 }
3102}
3103
3104async fn launch_marked(life: &Lifecycle, capture: CaptureOpts) -> Result<OneShotSession, CliError> {
3105 let session = OneShotSession::launch_headless_with_capture(capture).await?;
3106 mark_launched(life, session.chrome_pid());
3107 Ok(session)
3108}
3109
3110#[cfg(test)]
3111mod eval_normalize_tests {
3112 use super::normalize_eval_expression;
3113
3114 #[test]
3115 fn leaves_invoked_iife_alone() {
3116 let e = "(() => { return 9; })()";
3117 assert_eq!(normalize_eval_expression(e, None).unwrap(), e);
3118 let e2 = "(async () => 1)()";
3119 assert_eq!(normalize_eval_expression(e2, None).unwrap(), e2);
3120 let e3 = "(function(){ return 2; })()";
3121 assert_eq!(normalize_eval_expression(e3, None).unwrap(), e3);
3122 }
3123
3124 #[test]
3125 fn wraps_bare_arrow_once() {
3126 assert_eq!(
3127 normalize_eval_expression("() => 7", None).unwrap(),
3128 "(() => 7)()"
3129 );
3130 assert_eq!(
3131 normalize_eval_expression("async () => 3", None).unwrap(),
3132 "(async () => 3)()"
3133 );
3134 }
3135
3136 #[test]
3137 fn leaves_plain_expression() {
3138 assert_eq!(normalize_eval_expression("1+1", None).unwrap(), "1+1");
3139 assert_eq!(normalize_eval_expression("(1+1)", None).unwrap(), "(1+1)");
3140 assert_eq!(
3141 normalize_eval_expression("document.title", None).unwrap(),
3142 "document.title"
3143 );
3144 }
3145
3146 #[test]
3147 fn args_force_call() {
3148 let out = normalize_eval_expression("(el) => el", Some(r#"["@e1"]"#)).unwrap();
3149 assert_eq!(out, r#"((el) => el)("e1")"#);
3150 }
3151}
3152
3153async fn finish(
3154 life: &Lifecycle,
3155 session: OneShotSession,
3156 work_res: Result<Value, CliError>,
3157) -> Result<Value, CliError> {
3158 let close_res = session.shutdown().await;
3159 mark_closed(life);
3160 match (work_res, close_res) {
3161 (Ok(v), Ok(())) => Ok(v),
3162 (Err(e), _) => Err(e),
3163 (Ok(_), Err(e)) => Err(e),
3164 }
3165}
3166
3167pub async fn run_goto(life: &Lifecycle, url: &str) -> Result<Value, CliError> {
3168 run_goto_with_robots(
3169 life,
3170 url,
3171 CaptureOpts::default(),
3172 crate::robots::RobotsPolicy::Honor,
3173 )
3174 .await
3175}
3176
3177pub async fn run_goto_with_robots(
3178 life: &Lifecycle,
3179 url: &str,
3180 capture: CaptureOpts,
3181 robots: crate::robots::RobotsPolicy,
3182) -> Result<Value, CliError> {
3183 let mut session = launch_marked(life, capture).await?;
3184 let work = session.goto(url, robots).await;
3185 finish(life, session, work).await
3186}
3187
3188pub async fn run_scrape(
3189 life: &Lifecycle,
3190 url: &str,
3191 robots: crate::robots::RobotsPolicy,
3192 capture: CaptureOpts,
3193) -> Result<Value, CliError> {
3194 let mut session = launch_marked(life, capture).await?;
3195 let work = session.scrape(url, robots).await;
3196 finish(life, session, work).await
3197}
3198
3199pub async fn run_goto_capture(
3200 life: &Lifecycle,
3201 url: &str,
3202 capture: CaptureOpts,
3203) -> Result<Value, CliError> {
3204 run_goto_with_robots(life, url, capture, crate::robots::RobotsPolicy::Honor).await
3205}
3206
3207pub async fn run_view(
3208 life: &Lifecycle,
3209 verbose: bool,
3210 capture: CaptureOpts,
3211) -> Result<Value, CliError> {
3212 let mut session = launch_marked(life, capture).await?;
3213 let work = async {
3214 let _ = session
3215 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
3216 .await?;
3217 session.view(verbose).await
3218 }
3219 .await;
3220 finish(life, session, work).await
3221}
3222
3223pub async fn run_press(
3224 life: &Lifecycle,
3225 target: &str,
3226 dblclick: bool,
3227 include_snapshot: bool,
3228 capture: CaptureOpts,
3229) -> Result<Value, CliError> {
3230 let mut session = launch_marked(life, capture).await?;
3231 let work = async {
3232 let _ = session
3233 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
3234 .await?;
3235 session.press(target, dblclick, include_snapshot).await
3236 }
3237 .await;
3238 finish(life, session, work).await
3239}
3240
3241pub async fn run_write(
3242 life: &Lifecycle,
3243 target: &str,
3244 value: &str,
3245 include_snapshot: bool,
3246 capture: CaptureOpts,
3247) -> Result<Value, CliError> {
3248 let mut session = launch_marked(life, capture).await?;
3249 let work = async {
3250 let _ = session
3251 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
3252 .await?;
3253 session.write(target, value, include_snapshot).await
3254 }
3255 .await;
3256 finish(life, session, work).await
3257}
3258
3259pub async fn run_keys(
3260 life: &Lifecycle,
3261 key: &str,
3262 include_snapshot: bool,
3263 capture: CaptureOpts,
3264) -> Result<Value, CliError> {
3265 let mut session = launch_marked(life, capture).await?;
3266 let work = async {
3267 let _ = session
3268 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
3269 .await?;
3270 session.keys(key, include_snapshot).await
3271 }
3272 .await;
3273 finish(life, session, work).await
3274}
3275
3276pub async fn run_type(
3277 life: &Lifecycle,
3278 target: Option<&str>,
3279 text: &str,
3280 clear: bool,
3281 submit: Option<&str>,
3282 focus_only: bool,
3283 capture: CaptureOpts,
3284) -> Result<Value, CliError> {
3285 let mut session = launch_marked(life, capture).await?;
3286 let work = async {
3287 let _ = session
3288 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
3289 .await?;
3290 session
3291 .type_text(target, text, clear, submit, focus_only)
3292 .await
3293 }
3294 .await;
3295 finish(life, session, work).await
3296}
3297
3298pub async fn run_with_session<F, Fut>(
3299 life: &Lifecycle,
3300 capture: CaptureOpts,
3301 work: F,
3302) -> Result<Value, CliError>
3303where
3304 F: FnOnce(OneShotSession) -> Fut,
3305 Fut: std::future::Future<Output = Result<(OneShotSession, Value), CliError>>,
3306{
3307 let session = launch_marked(life, capture).await?;
3308 match work(session).await {
3309 Ok((session, value)) => finish(life, session, Ok(value)).await,
3310 Err(e) => {
3311 mark_closed(life);
3312 Err(e)
3313 }
3314 }
3315}
3316
3317pub fn block_on_browser<F, T>(fut: F) -> Result<T, CliError>
3319where
3320 F: std::future::Future<Output = Result<T, CliError>>,
3321{
3322 block_on_browser_timeout(fut, 0)
3323}
3324
3325pub fn block_on_browser_timeout<F, T>(fut: F, timeout_secs: u64) -> Result<T, CliError>
3327where
3328 F: std::future::Future<Output = Result<T, CliError>>,
3329{
3330 let rt = tokio::runtime::Builder::new_multi_thread()
3331 .enable_all()
3332 .worker_threads(2)
3333 .thread_name("bac-browser")
3334 .build()
3335 .map_err(|e| {
3336 CliError::new(
3337 ErrorKind::Software,
3338 format!("Failed to create tokio runtime: {e}"),
3339 )
3340 })?;
3341 if timeout_secs == 0 {
3342 return rt.block_on(fut);
3343 }
3344 rt.block_on(async {
3345 match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), fut).await {
3346 Ok(inner) => inner,
3347 Err(_) => Err(CliError::with_suggestion(
3348 ErrorKind::Timeout,
3349 format!("operation exceeded --timeout {timeout_secs}s"),
3350 "Raise --timeout or reduce wait/navigation work",
3351 )),
3352 }
3353 })
3354}
3355
3356#[cfg(test)]
3357mod tests {
3358 use super::{is_internal_browser_url, is_noise_network_url, tree_to_at_refs};
3359 use crate::native::browser::WaitUntil;
3360 use serde_json::json;
3361
3362 #[test]
3363 fn tree_to_at_refs_rewrites_markers() {
3364 let raw = r#"- link "Home" [ref=e1]
3365 - button "Go" [checked=false, ref=e2]
3366"#;
3367 let out = tree_to_at_refs(raw);
3368 assert!(out.contains("[@e1]"), "out={out}");
3369 assert!(out.contains("@e2"), "out={out}");
3370 }
3371
3372 #[test]
3373 fn internal_browser_urls_filtered() {
3374 assert!(is_internal_browser_url("chrome://new-tab-page/"));
3375 assert!(is_internal_browser_url("chrome-extension://abc/x.js"));
3376 assert!(is_internal_browser_url("devtools://devtools/bundled/"));
3377 assert!(!is_internal_browser_url("https://example.com/"));
3378 assert!(!is_internal_browser_url("about:blank"));
3379 assert!(is_noise_network_url(
3380 "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
3381 ));
3382 assert!(is_noise_network_url("blob:https://example.com/uuid"));
3383 assert!(is_noise_network_url("chrome://new-tab-page/"));
3384 assert!(!is_noise_network_url("https://example.com/"));
3385 }
3386
3387 #[test]
3388 fn wait_until_tokens_parse() {
3389 assert_eq!(
3390 WaitUntil::parse_token("networkidle"),
3391 WaitUntil::NetworkIdle
3392 );
3393 assert_eq!(
3394 WaitUntil::parse_token("domcontentloaded"),
3395 WaitUntil::DomContentLoaded
3396 );
3397 assert_eq!(WaitUntil::parse_token("load"), WaitUntil::Load);
3398 assert_eq!(WaitUntil::parse_token("none"), WaitUntil::None);
3399 }
3400
3401 #[test]
3402 fn net_request_id_resolution_logic() {
3403 let requests = [
3404 json!({"requestId": "rid-1", "method": "GET", "url": "https://a.example/"}),
3405 json!({"requestId": "rid-2", "method": "POST", "url": "https://b.example/"}),
3406 ];
3407 let by_index = requests.get(1).unwrap();
3408 assert_eq!(by_index["requestId"], "rid-2");
3409 let by_rid = requests.iter().find(|r| r["requestId"] == "rid-1").unwrap();
3410 assert_eq!(by_rid["url"], "https://a.example/");
3411 let idx: usize = "0".parse().unwrap();
3413 assert_eq!(requests[idx]["requestId"], "rid-1");
3414 }
3415}