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