1#![allow(missing_docs)]
3
4mod meta;
5mod run;
6
7use std::path::Path;
8
9use crate::browser::{
10 block_on_browser_timeout, run_goto_with_robots, run_keys, run_press, run_scrape, run_type,
11 run_view, run_write, CaptureOpts, OneShotSession,
12};
13use crate::cli::{
14 AssertKind, Cli, Commands, CompletionShell, ConsoleAction, CookieAction, Devtools3pAction,
15 DialogAction, ExtensionAction, GrabFormat, HeapAction, NetAction, PageAction, PerfAction,
16 ScreencastAction, WebmcpAction,
17};
18use crate::envelope::{print_error_json, print_success_json};
19use crate::error::{CliError, ErrorKind};
20use crate::lifecycle::Lifecycle;
21use crate::robots::RobotsPolicy;
22
23pub fn dispatch(cli: Cli, life: &Lifecycle) -> i32 {
25 let json = cli.globals.json
26 || matches!(
27 &cli.command,
28 Commands::Doctor { json: true, .. } | Commands::Commands { json: true }
29 );
30 let capture = CaptureOpts {
31 console: cli.globals.capture_console,
32 network: cli.globals.capture_network,
33 };
34 let timeout_secs = cli.globals.timeout;
35 let artifacts = cli.globals.artifacts_dir.clone();
36 let category_memory = cli.globals.category_memory;
37 let category_extensions = cli.globals.category_extensions;
38 let category_third_party = cli.globals.category_third_party;
39 let category_webmcp = cli.globals.category_webmcp;
40 let experimental_screencast = cli.globals.experimental_screencast;
41 let experimental_vision = cli.globals.experimental_vision;
42 let robots =
43 match RobotsPolicy::from_flags(cli.globals.ignore_robots, cli.globals.i_accept_robots_risk)
44 {
45 Ok(p) => p,
46 Err(e) => {
47 life.finalize();
48 return emit_err(&e, json);
49 }
50 };
51
52 let code = match cli.command {
53 Commands::Doctor {
54 offline,
55 quick,
56 fix,
57 json: doc_json,
58 } => crate::doctor::run_doctor(crate::doctor::DoctorOptions {
59 offline,
60 quick,
61 fix,
62 json: json || doc_json,
63 }),
64 Commands::Commands { json: cmd_json } => match meta::list_commands(json || cmd_json) {
65 Ok(()) => 0,
66 Err(e) => emit_err(&e, json || cmd_json),
67 },
68 Commands::Schema { cmd } => match meta::schema_for_cmd(&cmd, json) {
69 Ok(()) => 0,
70 Err(e) => emit_err(&e, json),
71 },
72 Commands::Version => match handle_version(json) {
73 Ok(()) => 0,
74 Err(e) => emit_err(&e, json),
75 },
76 Commands::Goto {
77 url,
78 init_script,
79 handle_before_unload,
80 navigation_timeout_ms,
81 } => {
82 match handle_goto(
83 life,
84 &url,
85 robots,
86 capture,
87 timeout_secs,
88 json,
89 init_script.as_deref(),
90 handle_before_unload,
91 navigation_timeout_ms,
92 ) {
93 Ok(()) => 0,
94 Err(e) => emit_err(&e, json),
95 }
96 }
97 Commands::View { verbose, path } => {
98 match handle_view(life, verbose, path.as_deref(), capture, timeout_secs, json) {
99 Ok(()) => 0,
100 Err(e) => emit_err(&e, json),
101 }
102 }
103 Commands::Press {
104 target,
105 dblclick,
106 include_snapshot,
107 } => {
108 match handle_press(
109 life,
110 &target,
111 dblclick,
112 include_snapshot,
113 capture,
114 timeout_secs,
115 json,
116 ) {
117 Ok(()) => 0,
118 Err(e) => emit_err(&e, json),
119 }
120 }
121 Commands::ClickAt {
122 x,
123 y,
124 dblclick,
125 include_snapshot,
126 } => {
127 if !experimental_vision {
128 let e = CliError::with_suggestion(
129 ErrorKind::Usage,
130 "click-at requires --experimental-vision",
131 "Pass --experimental-vision (or BROWSER_AUTOMATION_CLI_EXPERIMENTAL_VISION=1)",
132 );
133 emit_err(&e, json)
134 } else {
135 match handle_click_at(
136 life,
137 x,
138 y,
139 dblclick,
140 include_snapshot,
141 capture,
142 timeout_secs,
143 json,
144 ) {
145 Ok(()) => 0,
146 Err(e) => emit_err(&e, json),
147 }
148 }
149 }
150 Commands::Write {
151 target,
152 value,
153 include_snapshot,
154 } => {
155 match handle_write(
156 life,
157 &target,
158 &value,
159 include_snapshot,
160 capture,
161 timeout_secs,
162 json,
163 ) {
164 Ok(()) => 0,
165 Err(e) => emit_err(&e, json),
166 }
167 }
168 Commands::Keys {
169 key,
170 include_snapshot,
171 } => match handle_keys(life, &key, include_snapshot, capture, timeout_secs, json) {
172 Ok(()) => 0,
173 Err(e) => emit_err(&e, json),
174 },
175 Commands::Type {
176 target,
177 text,
178 clear,
179 submit,
180 focus_only,
181 } => match handle_type(
182 life,
183 target.as_deref(),
184 &text,
185 clear,
186 submit.as_deref(),
187 focus_only,
188 capture,
189 timeout_secs,
190 json,
191 ) {
192 Ok(()) => 0,
193 Err(e) => emit_err(&e, json),
194 },
195 Commands::Wait {
196 ms,
197 text,
198 selector,
199 state,
200 wait_timeout_ms,
201 include_snapshot,
202 } => {
203 match handle_wait(
204 life,
205 ms,
206 &text,
207 selector.as_deref(),
208 state.as_deref(),
209 wait_timeout_ms,
210 include_snapshot,
211 capture,
212 timeout_secs,
213 json,
214 ) {
215 Ok(()) => 0,
216 Err(e) => emit_err(&e, json),
217 }
218 }
219 Commands::Hover {
220 target,
221 include_snapshot,
222 } => match handle_hover(life, &target, include_snapshot, capture, timeout_secs, json) {
223 Ok(()) => 0,
224 Err(e) => emit_err(&e, json),
225 },
226 Commands::Drag {
227 from,
228 to,
229 include_snapshot,
230 } => {
231 match handle_drag(
232 life,
233 &from,
234 &to,
235 include_snapshot,
236 capture,
237 timeout_secs,
238 json,
239 ) {
240 Ok(()) => 0,
241 Err(e) => emit_err(&e, json),
242 }
243 }
244 Commands::FillForm {
245 json: fields_json,
246 include_snapshot,
247 } => {
248 match handle_fill_form(
249 life,
250 &fields_json,
251 include_snapshot,
252 capture,
253 timeout_secs,
254 json,
255 ) {
256 Ok(()) => 0,
257 Err(e) => emit_err(&e, json),
258 }
259 }
260 Commands::Upload {
261 target,
262 path,
263 include_snapshot,
264 } => {
265 match handle_upload(
266 life,
267 &target,
268 &path,
269 include_snapshot,
270 capture,
271 timeout_secs,
272 json,
273 ) {
274 Ok(()) => 0,
275 Err(e) => emit_err(&e, json),
276 }
277 }
278 Commands::Back => match handle_history(life, "back", capture, timeout_secs, json) {
279 Ok(()) => 0,
280 Err(e) => emit_err(&e, json),
281 },
282 Commands::Forward => match handle_history(life, "forward", capture, timeout_secs, json) {
283 Ok(()) => 0,
284 Err(e) => emit_err(&e, json),
285 },
286 Commands::Reload {
287 ignore_cache,
288 init_script,
289 handle_before_unload,
290 } => {
291 match handle_reload(
292 life,
293 ignore_cache,
294 init_script.as_deref(),
295 handle_before_unload,
296 capture,
297 timeout_secs,
298 json,
299 ) {
300 Ok(()) => 0,
301 Err(e) => emit_err(&e, json),
302 }
303 }
304 Commands::Eval {
305 expression,
306 args,
307 dialog_action,
308 file_path,
309 } => {
310 match handle_eval(
311 life,
312 &expression,
313 args.as_deref(),
314 dialog_action.as_deref(),
315 file_path.as_deref(),
316 capture,
317 timeout_secs,
318 json,
319 ) {
320 Ok(()) => 0,
321 Err(e) => emit_err(&e, json),
322 }
323 }
324 Commands::Grab {
325 path,
326 format,
327 full_page,
328 quality,
329 element,
330 } => match handle_grab(
331 life,
332 path.as_deref(),
333 format,
334 full_page,
335 quality,
336 element.as_deref(),
337 artifacts.as_deref(),
338 capture,
339 timeout_secs,
340 json,
341 ) {
342 Ok(()) => 0,
343 Err(e) => emit_err(&e, json),
344 },
345 Commands::Run { script } => {
346 let flags = run::RunFlags {
347 experimental_vision,
348 experimental_screencast,
349 category_memory,
350 };
351 match handle_run(life, &script, robots, capture, timeout_secs, json, flags) {
352 Ok(()) => 0,
353 Err(e) => emit_err(&e, json),
354 }
355 }
356 Commands::Exec { args } => {
357 let (args, json_from_trail) = peel_trailing_globals(&args);
360 let json = json || json_from_trail;
361 let flags = run::RunFlags {
362 experimental_vision,
363 experimental_screencast,
364 category_memory,
365 };
366 match handle_exec(life, &args, robots, capture, timeout_secs, json, flags) {
367 Ok(()) => 0,
368 Err(e) => emit_err(&e, json),
369 }
370 }
371 Commands::Extract { target, attr } => {
372 match handle_extract(life, &target, attr.as_deref(), capture, timeout_secs, json) {
373 Ok(()) => 0,
374 Err(e) => emit_err(&e, json),
375 }
376 }
377 Commands::Text { target } => {
378 match handle_text(life, &target, capture, timeout_secs, json) {
379 Ok(()) => 0,
380 Err(e) => emit_err(&e, json),
381 }
382 }
383 Commands::Scroll {
384 target,
385 delta_x,
386 delta_y,
387 } => match handle_scroll(
388 life,
389 target.as_deref(),
390 delta_x,
391 delta_y,
392 capture,
393 timeout_secs,
394 json,
395 ) {
396 Ok(()) => 0,
397 Err(e) => emit_err(&e, json),
398 },
399 Commands::Cookie { action } => {
400 match handle_cookie(life, action, capture, timeout_secs, json) {
401 Ok(()) => 0,
402 Err(e) => emit_err(&e, json),
403 }
404 }
405 Commands::Attr { target, name } => {
406 match handle_attr(life, &target, &name, capture, timeout_secs, json) {
407 Ok(()) => 0,
408 Err(e) => emit_err(&e, json),
409 }
410 }
411 Commands::Assert { kind } => match handle_assert(life, kind, capture, timeout_secs, json) {
412 Ok(()) => 0,
413 Err(e) => emit_err(&e, json),
414 },
415 Commands::Console { action } => {
416 match handle_console(life, action, capture, timeout_secs, json) {
417 Ok(()) => 0,
418 Err(e) => emit_err(&e, json),
419 }
420 }
421 Commands::Net { action } => match handle_net(life, action, capture, timeout_secs, json) {
422 Ok(()) => 0,
423 Err(e) => emit_err(&e, json),
424 },
425 Commands::Page { action } => match handle_page(life, action, capture, timeout_secs, json) {
426 Ok(()) => 0,
427 Err(e) => emit_err(&e, json),
428 },
429 Commands::Dialog { action } => {
430 match handle_dialog(life, action, capture, timeout_secs, json) {
431 Ok(()) => 0,
432 Err(e) => emit_err(&e, json),
433 }
434 }
435 Commands::Scrape { url } => {
436 match handle_scrape(life, &url, robots, capture, timeout_secs, json) {
437 Ok(()) => 0,
438 Err(e) => emit_err(&e, json),
439 }
440 }
441 Commands::Emulate {
442 user_agent,
443 locale,
444 timezone,
445 offline,
446 latitude,
447 longitude,
448 media,
449 network_conditions,
450 cpu_throttling_rate,
451 color_scheme,
452 extra_headers,
453 viewport,
454 } => match handle_emulate(
455 life,
456 user_agent.as_deref(),
457 locale.as_deref(),
458 timezone.as_deref(),
459 offline,
460 latitude,
461 longitude,
462 media.as_deref(),
463 network_conditions.as_deref(),
464 cpu_throttling_rate,
465 color_scheme.as_deref(),
466 extra_headers.as_deref(),
467 viewport.as_deref(),
468 capture,
469 timeout_secs,
470 json,
471 ) {
472 Ok(()) => 0,
473 Err(e) => emit_err(&e, json),
474 },
475 Commands::Resize {
476 width,
477 height,
478 scale,
479 mobile,
480 } => match handle_resize(
481 life,
482 width,
483 height,
484 scale,
485 mobile,
486 capture,
487 timeout_secs,
488 json,
489 ) {
490 Ok(()) => 0,
491 Err(e) => emit_err(&e, json),
492 },
493 Commands::Perf { action } => match handle_perf(life, action, capture, timeout_secs, json) {
494 Ok(()) => 0,
495 Err(e) => emit_err(&e, json),
496 },
497 Commands::Lighthouse {
498 url,
499 out_dir,
500 device,
501 mode,
502 lighthouse_path,
503 } => match handle_lighthouse(
504 &url,
505 out_dir.as_deref(),
506 &device,
507 &mode,
508 lighthouse_path.as_deref(),
509 json,
510 ) {
511 Ok(()) => 0,
512 Err(e) => emit_err(&e, json),
513 },
514 Commands::Screencast { action } => {
515 if !experimental_screencast {
516 emit_err(
517 &CliError::with_suggestion(
518 ErrorKind::Usage,
519 "screencast requires --experimental-screencast",
520 "Pass --experimental-screencast on the same invocation",
521 ),
522 json,
523 )
524 } else {
525 match handle_screencast(life, action, capture, timeout_secs, json) {
526 Ok(()) => 0,
527 Err(e) => emit_err(&e, json),
528 }
529 }
530 }
531 Commands::Heap { action } => {
532 let deep = !matches!(
533 &action,
534 HeapAction::Take { .. } | HeapAction::Summary { .. } | HeapAction::Close { .. }
535 );
536 if deep && !category_memory {
537 emit_err(
538 &CliError::with_suggestion(
539 ErrorKind::Usage,
540 "deep heap tools require --category-memory",
541 "Pass --category-memory (heap take/summary/close work without deep graph ops)",
542 ),
543 json,
544 )
545 } else {
546 match handle_heap(life, action, capture, timeout_secs, json) {
547 Ok(()) => 0,
548 Err(e) => emit_err(&e, json),
549 }
550 }
551 }
552 Commands::Extension { action } => {
553 if !category_extensions {
554 emit_err(
555 &CliError::with_suggestion(
556 ErrorKind::Usage,
557 "extension tools require --category-extensions",
558 "Pass --category-extensions on the same invocation",
559 ),
560 json,
561 )
562 } else {
563 match handle_extension(life, action, capture, timeout_secs, json) {
564 Ok(()) => 0,
565 Err(e) => emit_err(&e, json),
566 }
567 }
568 }
569 Commands::Devtools3p { action } => {
570 if !category_third_party {
571 emit_err(
572 &CliError::with_suggestion(
573 ErrorKind::Usage,
574 "devtools3p requires --category-third-party",
575 "Pass --category-third-party on the same invocation",
576 ),
577 json,
578 )
579 } else {
580 match handle_devtools3p(life, action, capture, timeout_secs, json) {
581 Ok(()) => 0,
582 Err(e) => emit_err(&e, json),
583 }
584 }
585 }
586 Commands::Webmcp { action } => {
587 if !category_webmcp {
588 emit_err(
589 &CliError::with_suggestion(
590 ErrorKind::Usage,
591 "webmcp requires --category-webmcp",
592 "Pass --category-webmcp on the same invocation",
593 ),
594 json,
595 )
596 } else {
597 match handle_webmcp(life, action, capture, timeout_secs, json) {
598 Ok(()) => 0,
599 Err(e) => emit_err(&e, json),
600 }
601 }
602 }
603 Commands::Completions { shell } => match handle_completions(shell) {
604 Ok(()) => 0,
605 Err(e) => emit_err(&e, json),
606 },
607 };
608
609 life.finalize();
610 code
611}
612
613fn handle_version(json: bool) -> Result<(), CliError> {
614 let data = serde_json::json!({
615 "name": "browser-automation-cli",
616 "version": env!("CARGO_PKG_VERSION"),
617 });
618 emit_ok(data, json, |d| {
619 println!(
620 "{}",
621 d.get("version").and_then(|v| v.as_str()).unwrap_or("")
622 );
623 })
624}
625
626fn emit_ok<F>(data: serde_json::Value, json: bool, text: F) -> Result<(), CliError>
627where
628 F: FnOnce(&serde_json::Value),
629{
630 if json {
631 print_success_json(data)?;
632 } else {
633 text(&data);
634 }
635 Ok(())
636}
637
638fn emit_err(err: &CliError, json: bool) -> i32 {
639 if json {
640 let _ = print_error_json(err);
641 } else {
642 eprintln!("error: {err}");
643 if let Some(s) = err.suggestion() {
644 eprintln!("suggestion: {s}");
645 }
646 }
647 err.exit_code() as i32
648}
649
650fn peel_trailing_globals(args: &[String]) -> (Vec<String>, bool) {
652 let mut json = false;
653 let mut out = Vec::with_capacity(args.len());
654 for a in args {
655 match a.as_str() {
656 "--json" => json = true,
657 other => out.push(other.to_string()),
658 }
659 }
660 (out, json)
661}
662
663#[allow(clippy::too_many_arguments)]
664fn handle_goto(
665 life: &Lifecycle,
666 url: &str,
667 robots: RobotsPolicy,
668 capture: CaptureOpts,
669 timeout_secs: u64,
670 json: bool,
671 init_script: Option<&str>,
672 handle_before_unload: bool,
673 navigation_timeout_ms: Option<u64>,
674) -> Result<(), CliError> {
675 let _ = (init_script, handle_before_unload, navigation_timeout_ms);
676 let data = block_on_browser_timeout(
679 run_goto_with_robots(life, url, capture, robots),
680 timeout_secs,
681 )?;
682 emit_ok(data, json, |d| {
683 let u = d.get("url").and_then(|v| v.as_str()).unwrap_or(url);
684 let t = d.get("title").and_then(|v| v.as_str()).unwrap_or("");
685 println!("ok url={u} title={t}");
686 })
687}
688
689fn handle_view(
690 life: &Lifecycle,
691 verbose: bool,
692 path: Option<&Path>,
693 capture: CaptureOpts,
694 timeout_secs: u64,
695 json: bool,
696) -> Result<(), CliError> {
697 let path_owned = path.map(|p| p.to_path_buf());
698 let data = block_on_browser_timeout(
699 async move {
700 let mut data = run_view(life, verbose, capture).await?;
701 if let Some(p) = path_owned.as_ref() {
702 if let Some(parent) = p.parent() {
703 if !parent.as_os_str().is_empty() {
704 std::fs::create_dir_all(parent).map_err(|e| {
705 CliError::new(ErrorKind::Io, format!("view --path mkdir: {e}"))
706 })?;
707 }
708 }
709 let tree = data.get("tree").and_then(|v| v.as_str()).unwrap_or("");
710 std::fs::write(p, tree.as_bytes())
711 .map_err(|e| CliError::new(ErrorKind::Io, format!("view --path write: {e}")))?;
712 if let Some(obj) = data.as_object_mut() {
713 obj.insert(
714 "path".to_string(),
715 serde_json::Value::String(p.display().to_string()),
716 );
717 }
718 }
719 Ok(data)
720 },
721 timeout_secs,
722 )?;
723 emit_ok(data, json, |d| {
724 if let Some(p) = d.get("path").and_then(|v| v.as_str()) {
725 println!("ok view path={p}");
726 } else if let Some(tree) = d.get("tree").and_then(|v| v.as_str()) {
727 print!("{tree}");
728 if !tree.ends_with('\n') {
729 println!();
730 }
731 } else {
732 println!("ok view");
733 }
734 })
735}
736
737fn handle_press(
738 life: &Lifecycle,
739 target: &str,
740 dblclick: bool,
741 include_snapshot: bool,
742 capture: CaptureOpts,
743 timeout_secs: u64,
744 json: bool,
745) -> Result<(), CliError> {
746 let data = block_on_browser_timeout(
747 run_press(life, target, dblclick, include_snapshot, capture),
748 timeout_secs,
749 )?;
750 emit_ok(data, json, |_| {
751 println!("ok pressed={target} dblclick={dblclick}");
752 })
753}
754
755fn handle_write(
756 life: &Lifecycle,
757 target: &str,
758 value: &str,
759 include_snapshot: bool,
760 capture: CaptureOpts,
761 timeout_secs: u64,
762 json: bool,
763) -> Result<(), CliError> {
764 let data = block_on_browser_timeout(
765 run_write(life, target, value, include_snapshot, capture),
766 timeout_secs,
767 )?;
768 emit_ok(data, json, |_| {
769 println!("ok written={target} len={}", value.len());
770 })
771}
772
773#[allow(clippy::too_many_arguments)]
774fn handle_click_at(
775 life: &Lifecycle,
776 x: f64,
777 y: f64,
778 dblclick: bool,
779 include_snapshot: bool,
780 capture: CaptureOpts,
781 timeout_secs: u64,
782 json: bool,
783) -> Result<(), CliError> {
784 let data = block_on_browser_timeout(
785 async move {
786 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
787 if let Ok(mut ledger) = life.ledger.lock() {
788 ledger.chrome_launched = true;
789 ledger.chrome_pid = session.chrome_pid();
790 }
791 let _ = session
792 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
793 .await?;
794 let r = session.click_at(x, y, dblclick, include_snapshot).await;
795 let close = session.shutdown().await;
796 if let Ok(mut ledger) = life.ledger.lock() {
797 ledger.chrome_launched = false;
798 ledger.chrome_pid = None;
799 }
800 close?;
801 r
802 },
803 timeout_secs,
804 )?;
805 emit_ok(data, json, |_| {
806 println!("ok click-at x={x} y={y} dbl={dblclick}")
807 })
808}
809
810fn handle_keys(
811 life: &Lifecycle,
812 key: &str,
813 include_snapshot: bool,
814 capture: CaptureOpts,
815 timeout_secs: u64,
816 json: bool,
817) -> Result<(), CliError> {
818 let data =
819 block_on_browser_timeout(run_keys(life, key, include_snapshot, capture), timeout_secs)?;
820 emit_ok(data, json, |_| println!("ok key={key}"))
821}
822
823#[allow(clippy::too_many_arguments)]
824fn handle_type(
825 life: &Lifecycle,
826 target: Option<&str>,
827 text: &str,
828 clear: bool,
829 submit: Option<&str>,
830 focus_only: bool,
831 capture: CaptureOpts,
832 timeout_secs: u64,
833 json: bool,
834) -> Result<(), CliError> {
835 if target.is_none() && !focus_only {
836 return Err(CliError::with_suggestion(
837 ErrorKind::Usage,
838 "type requires a target or --focus-only",
839 "Pass TARGET or --focus-only for the focused element",
840 ));
841 }
842 let data = block_on_browser_timeout(
843 run_type(life, target, text, clear, submit, focus_only, capture),
844 timeout_secs,
845 )?;
846 let label = target.unwrap_or("(focused)");
847 emit_ok(data, json, |_| {
848 println!(
849 "ok typed={label} len={} clear={clear} submit={submit:?} focus_only={focus_only}",
850 text.len()
851 );
852 })
853}
854
855#[allow(clippy::too_many_arguments)]
856fn handle_wait(
857 life: &Lifecycle,
858 ms: u64,
859 texts: &[String],
860 selector: Option<&str>,
861 state: Option<&str>,
862 wait_timeout_ms: Option<u64>,
863 include_snapshot: bool,
864 capture: CaptureOpts,
865 timeout_secs: u64,
866 json: bool,
867) -> Result<(), CliError> {
868 let texts_owned = texts.to_vec();
869 let selector_owned = selector.map(|s| s.to_string());
870 let state_owned = state.map(|s| s.to_string());
871 let wait_ms = wait_timeout_ms.or(if ms == 0 { None } else { Some(ms) });
873 let data = block_on_browser_timeout(
874 async move {
875 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
876 if let Ok(mut ledger) = life.ledger.lock() {
877 ledger.chrome_launched = true;
878 ledger.chrome_pid = session.chrome_pid();
879 }
880 let r = if texts_owned.is_empty() {
882 session
883 .wait_for(
884 wait_ms,
885 None,
886 selector_owned.as_deref(),
887 state_owned.as_deref(),
888 include_snapshot,
889 )
890 .await
891 } else {
892 let mut last_err = None;
893 let mut ok = None;
894 for t in &texts_owned {
895 match session
896 .wait_for(
897 wait_ms,
898 Some(t.as_str()),
899 selector_owned.as_deref(),
900 state_owned.as_deref(),
901 false,
902 )
903 .await
904 {
905 Ok(v) => {
906 ok = Some(v);
907 break;
908 }
909 Err(e) => last_err = Some(e),
910 }
911 }
912 match ok {
913 Some(v) => {
914 if include_snapshot {
915 Ok(session
916 .attach_snapshot_if(true, v)
917 .await
918 .unwrap_or_else(|_| serde_json::json!({"waited": true})))
919 } else {
920 Ok(v)
921 }
922 }
923 None => Err(last_err.unwrap_or_else(|| {
924 CliError::new(ErrorKind::Browser, "wait: no text matched")
925 })),
926 }
927 };
928 let close = session.shutdown().await;
929 if let Ok(mut ledger) = life.ledger.lock() {
930 ledger.chrome_launched = false;
931 ledger.chrome_pid = None;
932 }
933 close?;
934 r
935 },
936 timeout_secs,
937 )?;
938 emit_ok(data, json, |d| {
939 println!("ok wait {}", d);
940 })
941}
942
943fn with_session_blank<F, Fut>(
944 life: &Lifecycle,
945 capture: CaptureOpts,
946 timeout_secs: u64,
947 f: F,
948) -> Result<serde_json::Value, CliError>
949where
950 F: FnOnce(OneShotSession) -> Fut + Send + 'static,
951 Fut: std::future::Future<Output = Result<(OneShotSession, serde_json::Value), CliError>> + Send,
952{
953 block_on_browser_timeout(
954 async move {
955 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
956 if let Ok(mut ledger) = life.ledger.lock() {
957 ledger.chrome_launched = true;
958 ledger.chrome_pid = session.chrome_pid();
959 }
960 let _ = session
961 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
962 .await?;
963 let (session, value) = f(session).await?;
964 let close = session.shutdown().await;
965 if let Ok(mut ledger) = life.ledger.lock() {
966 ledger.chrome_launched = false;
967 ledger.chrome_pid = None;
968 }
969 close?;
970 Ok(value)
971 },
972 timeout_secs,
973 )
974}
975
976fn handle_hover(
977 life: &Lifecycle,
978 target: &str,
979 include_snapshot: bool,
980 capture: CaptureOpts,
981 timeout_secs: u64,
982 json: bool,
983) -> Result<(), CliError> {
984 let target = target.to_string();
985 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
986 let v = session.hover(&target, include_snapshot).await?;
987 Ok((session, v))
988 })?;
989 emit_ok(data, json, |_| println!("ok hover"))
990}
991
992fn handle_drag(
993 life: &Lifecycle,
994 from: &str,
995 to: &str,
996 include_snapshot: bool,
997 capture: CaptureOpts,
998 timeout_secs: u64,
999 json: bool,
1000) -> Result<(), CliError> {
1001 let from = from.to_string();
1002 let to = to.to_string();
1003 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1004 let v = session.drag(&from, &to, include_snapshot).await?;
1005 Ok((session, v))
1006 })?;
1007 emit_ok(data, json, |_| println!("ok drag"))
1008}
1009
1010fn handle_fill_form(
1011 life: &Lifecycle,
1012 fields_json: &str,
1013 include_snapshot: bool,
1014 capture: CaptureOpts,
1015 timeout_secs: u64,
1016 json: bool,
1017) -> Result<(), CliError> {
1018 let parsed: serde_json::Value = serde_json::from_str(fields_json).map_err(|e| {
1019 CliError::with_suggestion(
1020 ErrorKind::Usage,
1021 format!("fill-form --json parse error: {e}"),
1022 r##"Pass JSON array: [{"target":"input","value":"x"}] or [{"uid":"@e1","value":"x"}]"##,
1023 )
1024 })?;
1025 let arr = parsed.as_array().ok_or_else(|| {
1026 CliError::with_suggestion(
1027 ErrorKind::Usage,
1028 "fill-form --json must be a JSON array",
1029 r##"[{"target":"input","value":"x"}]"##,
1030 )
1031 })?;
1032 let mut fields = Vec::new();
1033 for item in arr {
1034 let target = item
1035 .get("target")
1036 .or_else(|| item.get("uid"))
1037 .or_else(|| item.get("selector"))
1038 .or_else(|| item.get("ref"))
1039 .and_then(|v| v.as_str())
1040 .ok_or_else(|| {
1041 CliError::new(
1042 ErrorKind::Usage,
1043 "fill-form field missing target/uid/selector/ref",
1044 )
1045 })?
1046 .to_string();
1047 let target = if target.starts_with('e')
1049 && target.len() > 1
1050 && target[1..].chars().all(|c| c.is_ascii_digit())
1051 {
1052 format!("@{target}")
1053 } else {
1054 target
1055 };
1056 let value = item
1057 .get("value")
1058 .or_else(|| item.get("text"))
1059 .and_then(|v| v.as_str())
1060 .ok_or_else(|| CliError::new(ErrorKind::Usage, "fill-form field missing value"))?
1061 .to_string();
1062 fields.push((target, value));
1063 }
1064 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1065 let v = session.fill_form(&fields, include_snapshot).await?;
1066 Ok((session, v))
1067 })?;
1068 emit_ok(data, json, |d| {
1069 let n = d.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
1070 println!("ok fill-form count={n}");
1071 })
1072}
1073
1074fn handle_upload(
1075 life: &Lifecycle,
1076 target: &str,
1077 path: &Path,
1078 include_snapshot: bool,
1079 capture: CaptureOpts,
1080 timeout_secs: u64,
1081 json: bool,
1082) -> Result<(), CliError> {
1083 let target = target.to_string();
1084 let path = path.to_path_buf();
1085 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1086 let v = session.upload(&target, &path, include_snapshot).await?;
1087 Ok((session, v))
1088 })?;
1089 emit_ok(data, json, |d| {
1090 let p = d.get("path").and_then(|v| v.as_str()).unwrap_or("");
1091 println!("ok upload path={p}");
1092 })
1093}
1094
1095fn handle_history(
1096 life: &Lifecycle,
1097 direction: &str,
1098 capture: CaptureOpts,
1099 timeout_secs: u64,
1100 json: bool,
1101) -> Result<(), CliError> {
1102 let direction = direction.to_string();
1103 let direction_label = direction.clone();
1104 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1105 let v = match direction.as_str() {
1106 "back" => session.back().await?,
1107 "forward" => session.forward().await?,
1108 other => {
1109 return Err(CliError::new(
1110 ErrorKind::Usage,
1111 format!("unknown history direction: {other}"),
1112 ))
1113 }
1114 };
1115 Ok((session, v))
1116 })?;
1117 emit_ok(data, json, |_| println!("ok {direction_label}"))
1118}
1119
1120fn handle_reload(
1121 life: &Lifecycle,
1122 ignore_cache: bool,
1123 init_script: Option<&str>,
1124 handle_before_unload: bool,
1125 capture: CaptureOpts,
1126 timeout_secs: u64,
1127 json: bool,
1128) -> Result<(), CliError> {
1129 let init = init_script.map(|s| s.to_string());
1130 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1131 if let Some(ref js) = init {
1132 let _ = session.eval(js, None, Some("accept"), None).await;
1133 }
1134 if handle_before_unload {
1135 let _ = session
1137 .eval(
1138 "window.addEventListener('beforeunload', e => { e.returnValue=''; });",
1139 None,
1140 Some("accept"),
1141 None,
1142 )
1143 .await;
1144 }
1145 let v = session.reload(ignore_cache).await?;
1146 Ok((session, v))
1147 })?;
1148 emit_ok(data, json, |_| {
1149 println!("ok reload ignore_cache={ignore_cache}")
1150 })
1151}
1152
1153#[allow(clippy::too_many_arguments)]
1154fn handle_eval(
1155 life: &Lifecycle,
1156 expression: &str,
1157 args: Option<&str>,
1158 dialog_action: Option<&str>,
1159 file_path: Option<&Path>,
1160 capture: CaptureOpts,
1161 timeout_secs: u64,
1162 json: bool,
1163) -> Result<(), CliError> {
1164 let expr = expression.to_string();
1165 let args_owned = args.map(|s| s.to_string());
1166 let dialog_owned = dialog_action.map(|s| s.to_string());
1167 let path_owned = file_path.map(|p| p.to_path_buf());
1168 let data = block_on_browser_timeout(
1169 async move {
1170 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1171 if let Ok(mut ledger) = life.ledger.lock() {
1172 ledger.chrome_launched = true;
1173 ledger.chrome_pid = session.chrome_pid();
1174 }
1175 let _ = session
1176 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1177 .await?;
1178 let r = session
1179 .eval(
1180 &expr,
1181 args_owned.as_deref(),
1182 dialog_owned.as_deref(),
1183 path_owned.as_deref(),
1184 )
1185 .await;
1186 let close = session.shutdown().await;
1187 if let Ok(mut ledger) = life.ledger.lock() {
1188 ledger.chrome_launched = false;
1189 ledger.chrome_pid = None;
1190 }
1191 close?;
1192 r
1193 },
1194 timeout_secs,
1195 )?;
1196 emit_ok(data, json, |d| {
1197 println!(
1198 "ok eval={}",
1199 d.get("result").unwrap_or(&serde_json::Value::Null)
1200 );
1201 })
1202}
1203
1204#[allow(clippy::too_many_arguments)]
1205fn handle_grab(
1206 life: &Lifecycle,
1207 path: Option<&Path>,
1208 format: GrabFormat,
1209 full_page: bool,
1210 quality: Option<i32>,
1211 element: Option<&str>,
1212 artifacts: Option<&Path>,
1213 capture: CaptureOpts,
1214 timeout_secs: u64,
1215 json: bool,
1216) -> Result<(), CliError> {
1217 let fmt = match format {
1218 GrabFormat::Png => "png",
1219 GrabFormat::Jpeg => "jpeg",
1220 GrabFormat::Webp => "webp",
1221 };
1222 if let Some(a) = artifacts {
1223 std::fs::create_dir_all(a)
1224 .map_err(|e| CliError::new(ErrorKind::Io, format!("artifacts-dir mkdir: {e}")))?;
1225 }
1226 let path_owned = path.map(|p| p.to_path_buf()).or_else(|| {
1227 artifacts.map(|a| {
1228 a.join(format!(
1229 "grab-{}.{}",
1230 std::time::SystemTime::now()
1231 .duration_since(std::time::UNIX_EPOCH)
1232 .map(|d| d.as_millis())
1233 .unwrap_or(0),
1234 fmt
1235 ))
1236 })
1237 });
1238 if let Some(ref p) = path_owned {
1239 if let Some(parent) = p.parent() {
1240 if !parent.as_os_str().is_empty() {
1241 std::fs::create_dir_all(parent)
1242 .map_err(|e| CliError::new(ErrorKind::Io, format!("grab path mkdir: {e}")))?;
1243 }
1244 }
1245 }
1246 let element_owned = element.map(|s| s.to_string());
1247 let data = block_on_browser_timeout(
1248 async move {
1249 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1250 if let Ok(mut ledger) = life.ledger.lock() {
1251 ledger.chrome_launched = true;
1252 ledger.chrome_pid = session.chrome_pid();
1253 }
1254 let _ = session
1255 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1256 .await?;
1257 let r = session
1258 .grab(
1259 path_owned.as_deref(),
1260 fmt,
1261 full_page,
1262 quality,
1263 element_owned.as_deref(),
1264 )
1265 .await;
1266 let close = session.shutdown().await;
1267 if let Ok(mut ledger) = life.ledger.lock() {
1268 ledger.chrome_launched = false;
1269 ledger.chrome_pid = None;
1270 }
1271 close?;
1272 r
1273 },
1274 timeout_secs,
1275 )?;
1276 emit_ok(data, json, |d| {
1277 let p = d.get("path").and_then(|v| v.as_str()).unwrap_or("");
1278 println!("ok grab path={p}");
1279 })
1280}
1281
1282fn handle_run(
1283 life: &Lifecycle,
1284 script: &Path,
1285 robots: RobotsPolicy,
1286 capture: CaptureOpts,
1287 timeout_secs: u64,
1288 json: bool,
1289 flags: run::RunFlags,
1290) -> Result<(), CliError> {
1291 let script = script.to_path_buf();
1292 let data = block_on_browser_timeout(
1293 run::run_script_with_flags(life, &script, robots, capture, flags),
1294 timeout_secs,
1295 )?;
1296 emit_ok(data, json, |d| {
1297 let total = d.get("total").and_then(|v| v.as_u64()).unwrap_or(0);
1298 println!("ok run steps={total}");
1299 })
1300}
1301
1302fn handle_exec(
1303 life: &Lifecycle,
1304 args: &[String],
1305 robots: RobotsPolicy,
1306 capture: CaptureOpts,
1307 timeout_secs: u64,
1308 json: bool,
1309 flags: run::RunFlags,
1310) -> Result<(), CliError> {
1311 if args.is_empty() {
1312 return Err(CliError::with_suggestion(
1313 ErrorKind::Usage,
1314 "exec requires a subcommand (e.g. goto)",
1315 "browser-automation-cli exec goto about:blank",
1316 ));
1317 }
1318 match args[0].as_str() {
1320 "goto" => {
1321 let url = args.get(1).ok_or_else(|| {
1322 CliError::with_suggestion(
1323 ErrorKind::Usage,
1324 "exec goto requires a URL",
1325 "browser-automation-cli exec goto about:blank",
1326 )
1327 })?;
1328 handle_goto(
1329 life,
1330 url,
1331 robots,
1332 capture,
1333 timeout_secs,
1334 json,
1335 None,
1336 false,
1337 None,
1338 )
1339 }
1340 "wait" | "view" | "press" | "write" | "keys" | "type" | "hover" | "back" | "forward"
1341 | "reload" | "eval" | "grab" | "page" | "console" | "net" | "dialog" | "emulate"
1342 | "resize" | "extract" | "text" | "scroll" | "cookie" | "attr" | "assert" | "click-at"
1343 | "drag" | "fill-form" | "upload" | "devtools3p" | "webmcp" | "heap" | "perf"
1344 | "lighthouse" | "screencast" | "extension" => {
1345 let step = run::argv_to_step(args)?;
1346 let data = block_on_browser_timeout(
1347 run::run_one_step(life, step, robots, capture, flags),
1348 timeout_secs,
1349 )?;
1350 emit_ok(data, json, |d| println!("ok exec {d}"))
1351 }
1352 other => Err(CliError::with_suggestion(
1353 ErrorKind::Usage,
1354 format!("unknown exec subcommand: {other}"),
1355 "Use browser-automation-cli exec <cmd> ... or run --script for multi-step NDJSON",
1356 )),
1357 }
1358}
1359
1360fn handle_extract(
1361 life: &Lifecycle,
1362 target: &str,
1363 attr: Option<&str>,
1364 capture: CaptureOpts,
1365 timeout_secs: u64,
1366 json: bool,
1367) -> Result<(), CliError> {
1368 let target = target.to_string();
1369 let attr = attr.map(|s| s.to_string());
1370 let data = block_on_browser_timeout(
1371 async move {
1372 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1373 if let Ok(mut ledger) = life.ledger.lock() {
1374 ledger.chrome_launched = true;
1375 ledger.chrome_pid = session.chrome_pid();
1376 }
1377 let _ = session
1378 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1379 .await?;
1380 let r = session.extract(&target, attr.as_deref()).await;
1381 let close = session.shutdown().await;
1382 if let Ok(mut ledger) = life.ledger.lock() {
1383 ledger.chrome_launched = false;
1384 ledger.chrome_pid = None;
1385 }
1386 close?;
1387 r
1388 },
1389 timeout_secs,
1390 )?;
1391 emit_ok(data, json, |d| println!("ok extract {d}"))
1392}
1393
1394fn handle_attr(
1395 life: &Lifecycle,
1396 target: &str,
1397 name: &str,
1398 capture: CaptureOpts,
1399 timeout_secs: u64,
1400 json: bool,
1401) -> Result<(), CliError> {
1402 handle_extract(life, target, Some(name), capture, timeout_secs, json)
1403}
1404
1405fn handle_assert(
1406 life: &Lifecycle,
1407 kind: AssertKind,
1408 capture: CaptureOpts,
1409 timeout_secs: u64,
1410 json: bool,
1411) -> Result<(), CliError> {
1412 let data = block_on_browser_timeout(
1413 async move {
1414 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1415 if let Ok(mut ledger) = life.ledger.lock() {
1416 ledger.chrome_launched = true;
1417 ledger.chrome_pid = session.chrome_pid();
1418 }
1419 let _ = session
1420 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1421 .await?;
1422 let r = match kind {
1423 AssertKind::Url { value, contains } => session.assert_url(&value, contains).await,
1424 AssertKind::Text { value, target } => {
1425 session.assert_text(&value, target.as_deref()).await
1426 }
1427 AssertKind::Console { level, max } => session.assert_console(&level, max).await,
1428 };
1429 let close = session.shutdown().await;
1430 if let Ok(mut ledger) = life.ledger.lock() {
1431 ledger.chrome_launched = false;
1432 ledger.chrome_pid = None;
1433 }
1434 close?;
1435 r
1436 },
1437 timeout_secs,
1438 )?;
1439 emit_ok(data, json, |_| println!("ok assert"))
1440}
1441
1442fn handle_console(
1443 life: &Lifecycle,
1444 action: ConsoleAction,
1445 capture: CaptureOpts,
1446 timeout_secs: u64,
1447 json: bool,
1448) -> Result<(), CliError> {
1449 let data = block_on_browser_timeout(
1450 async move {
1451 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1452 if let Ok(mut ledger) = life.ledger.lock() {
1453 ledger.chrome_launched = true;
1454 ledger.chrome_pid = session.chrome_pid();
1455 }
1456 let _ = session
1457 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1458 .await?;
1459 let r = match action {
1460 ConsoleAction::List {
1461 page_idx,
1462 page_size,
1463 types,
1464 include_preserved,
1465 service_worker_id,
1466 } => session.console_list(
1467 page_idx,
1468 page_size,
1469 types.as_deref(),
1470 include_preserved,
1471 service_worker_id.as_deref(),
1472 ),
1473 ConsoleAction::Get { id } => session.console_get(id),
1474 ConsoleAction::Clear => session.console_clear(),
1475 ConsoleAction::Dump { path } => session.console_dump(&path),
1476 };
1477 let close = session.shutdown().await;
1478 if let Ok(mut ledger) = life.ledger.lock() {
1479 ledger.chrome_launched = false;
1480 ledger.chrome_pid = None;
1481 }
1482 close?;
1483 r
1484 },
1485 timeout_secs,
1486 )?;
1487 emit_ok(data, json, |d| println!("ok console {d}"))
1488}
1489
1490fn handle_net(
1491 life: &Lifecycle,
1492 action: NetAction,
1493 capture: CaptureOpts,
1494 timeout_secs: u64,
1495 json: bool,
1496) -> Result<(), CliError> {
1497 let data = block_on_browser_timeout(
1498 async move {
1499 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1500 if let Ok(mut ledger) = life.ledger.lock() {
1501 ledger.chrome_launched = true;
1502 ledger.chrome_pid = session.chrome_pid();
1503 }
1504 let _ = session
1505 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1506 .await?;
1507 let r = match action {
1508 NetAction::List {
1509 page_idx,
1510 page_size,
1511 resource_types,
1512 include_preserved,
1513 } => session.net_list(
1514 page_idx,
1515 page_size,
1516 resource_types.as_deref(),
1517 include_preserved,
1518 ),
1519 NetAction::Get {
1520 id,
1521 request_path,
1522 response_path,
1523 } => session.net_get(&id, request_path.as_deref(), response_path.as_deref()),
1524 };
1525 let close = session.shutdown().await;
1526 if let Ok(mut ledger) = life.ledger.lock() {
1527 ledger.chrome_launched = false;
1528 ledger.chrome_pid = None;
1529 }
1530 close?;
1531 r
1532 },
1533 timeout_secs,
1534 )?;
1535 emit_ok(data, json, |d| println!("ok net {d}"))
1536}
1537
1538fn handle_page(
1539 life: &Lifecycle,
1540 action: Option<PageAction>,
1541 capture: CaptureOpts,
1542 timeout_secs: u64,
1543 json: bool,
1544) -> Result<(), CliError> {
1545 let action = action.unwrap_or(PageAction::Info);
1546 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1547 let v = match action {
1548 PageAction::Info => session.page_info().await?,
1549 PageAction::List => session.page_list().await?,
1550 PageAction::New {
1551 url,
1552 background,
1553 isolated_context,
1554 } => {
1555 session
1556 .page_new(url.as_deref(), background, isolated_context)
1557 .await?
1558 }
1559 PageAction::Select {
1560 index,
1561 page_id,
1562 bring_to_front,
1563 } => {
1564 let idx = index.or(page_id).ok_or_else(|| {
1565 CliError::with_suggestion(
1566 ErrorKind::Usage,
1567 "page select requires INDEX or --page-id",
1568 "browser-automation-cli page select 0 --json",
1569 )
1570 })?;
1571 session.page_select(idx, bring_to_front).await?
1572 }
1573 PageAction::Close { index, page_id } => session.page_close(index.or(page_id)).await?,
1574 };
1575 Ok((session, v))
1576 })?;
1577 emit_ok(data, json, |d| {
1578 if let (Some(u), Some(t)) = (
1579 d.get("url").and_then(|v| v.as_str()),
1580 d.get("title").and_then(|v| v.as_str()),
1581 ) {
1582 println!("ok page url={u} title={t}");
1583 } else {
1584 println!("ok page {d}");
1585 }
1586 })
1587}
1588
1589fn handle_text(
1590 life: &Lifecycle,
1591 target: &str,
1592 capture: CaptureOpts,
1593 timeout_secs: u64,
1594 json: bool,
1595) -> Result<(), CliError> {
1596 handle_extract(life, target, None, capture, timeout_secs, json)
1597}
1598
1599fn handle_scroll(
1600 life: &Lifecycle,
1601 target: Option<&str>,
1602 delta_x: f64,
1603 delta_y: f64,
1604 capture: CaptureOpts,
1605 timeout_secs: u64,
1606 json: bool,
1607) -> Result<(), CliError> {
1608 let target_owned = target.map(|s| s.to_string());
1609 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1610 let v = session
1611 .scroll(target_owned.as_deref(), delta_x, delta_y)
1612 .await?;
1613 Ok((session, v))
1614 })?;
1615 emit_ok(data, json, |d| println!("ok scroll {d}"))
1616}
1617
1618fn handle_cookie(
1619 life: &Lifecycle,
1620 action: CookieAction,
1621 capture: CaptureOpts,
1622 timeout_secs: u64,
1623 json: bool,
1624) -> Result<(), CliError> {
1625 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1626 let v = match action {
1627 CookieAction::List { url } => session.cookie_list(url.as_deref()).await?,
1628 CookieAction::Set { json: body } => session.cookie_set(&body).await?,
1629 CookieAction::Clear => session.cookie_clear().await?,
1630 };
1631 Ok((session, v))
1632 })?;
1633 emit_ok(data, json, |d| println!("ok cookie {d}"))
1634}
1635
1636fn handle_dialog(
1637 life: &Lifecycle,
1638 action: DialogAction,
1639 capture: CaptureOpts,
1640 timeout_secs: u64,
1641 json: bool,
1642) -> Result<(), CliError> {
1643 let data = block_on_browser_timeout(
1644 async move {
1645 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1646 if let Ok(mut ledger) = life.ledger.lock() {
1647 ledger.chrome_launched = true;
1648 ledger.chrome_pid = session.chrome_pid();
1649 }
1650 let _ = session
1651 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1652 .await?;
1653 let r = match action {
1654 DialogAction::Accept { text } => session.dialog(true, text.as_deref()).await,
1655 DialogAction::Dismiss => session.dialog(false, None).await,
1656 };
1657 let close = session.shutdown().await;
1658 if let Ok(mut ledger) = life.ledger.lock() {
1659 ledger.chrome_launched = false;
1660 ledger.chrome_pid = None;
1661 }
1662 close?;
1663 r
1664 },
1665 timeout_secs,
1666 )?;
1667 emit_ok(data, json, |_| println!("ok dialog"))
1668}
1669
1670fn handle_scrape(
1671 life: &Lifecycle,
1672 url: &str,
1673 robots: RobotsPolicy,
1674 capture: CaptureOpts,
1675 timeout_secs: u64,
1676 json: bool,
1677) -> Result<(), CliError> {
1678 let data = block_on_browser_timeout(run_scrape(life, url, robots, capture), timeout_secs)?;
1679 emit_ok(data, json, |d| {
1680 let policy = d
1681 .get("robots_policy")
1682 .and_then(|v| v.as_str())
1683 .unwrap_or("honor");
1684 let u = d.get("source_url").and_then(|v| v.as_str()).unwrap_or(url);
1685 println!("ok scrape source_url={u} robots_policy={policy}");
1686 })
1687}
1688
1689#[allow(clippy::too_many_arguments)]
1690fn handle_emulate(
1691 life: &Lifecycle,
1692 user_agent: Option<&str>,
1693 locale: Option<&str>,
1694 timezone: Option<&str>,
1695 offline: bool,
1696 latitude: Option<f64>,
1697 longitude: Option<f64>,
1698 media: Option<&str>,
1699 network_conditions: Option<&str>,
1700 cpu_throttling_rate: Option<f64>,
1701 color_scheme: Option<&str>,
1702 extra_headers: Option<&str>,
1703 viewport: Option<&str>,
1704 capture: CaptureOpts,
1705 timeout_secs: u64,
1706 json: bool,
1707) -> Result<(), CliError> {
1708 let ua = user_agent.map(|s| s.to_string());
1709 let loc = locale.map(|s| s.to_string());
1710 let tz = timezone.map(|s| s.to_string());
1711 let media = media.map(|s| s.to_string());
1712 let net = network_conditions.map(|s| s.to_string());
1713 let scheme = color_scheme.map(|s| s.to_string());
1714 let headers = extra_headers.map(|s| s.to_string());
1715 let vp = viewport.map(|s| s.to_string());
1716 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1717 let v = session
1718 .emulate(
1719 ua.as_deref(),
1720 loc.as_deref(),
1721 tz.as_deref(),
1722 offline,
1723 latitude,
1724 longitude,
1725 media.as_deref(),
1726 net.as_deref(),
1727 cpu_throttling_rate,
1728 scheme.as_deref(),
1729 headers.as_deref(),
1730 vp.as_deref(),
1731 )
1732 .await?;
1733 Ok((session, v))
1734 })?;
1735 emit_ok(data, json, |_| println!("ok emulate"))
1736}
1737
1738#[allow(clippy::too_many_arguments)]
1739fn handle_resize(
1740 life: &Lifecycle,
1741 width: i32,
1742 height: i32,
1743 scale: f64,
1744 mobile: bool,
1745 capture: CaptureOpts,
1746 timeout_secs: u64,
1747 json: bool,
1748) -> Result<(), CliError> {
1749 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1750 let v = session.resize(width, height, scale, mobile).await?;
1751 Ok((session, v))
1752 })?;
1753 emit_ok(data, json, |_| println!("ok resize {width}x{height}"))
1754}
1755
1756fn handle_perf(
1757 life: &Lifecycle,
1758 action: PerfAction,
1759 capture: CaptureOpts,
1760 timeout_secs: u64,
1761 json: bool,
1762) -> Result<(), CliError> {
1763 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1764 let v = match action {
1765 PerfAction::Start {
1766 path,
1767 reload,
1768 auto_stop,
1769 } => {
1770 session
1771 .perf_start(path.as_deref(), reload, auto_stop)
1772 .await?
1773 }
1774 PerfAction::Stop { path } => session.perf_stop(path.as_deref()).await?,
1775 PerfAction::Insight {
1776 name,
1777 insight_set_id,
1778 insight_name,
1779 } => {
1780 let resolved = insight_name.or(name);
1781 session
1782 .perf_insight(resolved.as_deref(), insight_set_id.as_deref())
1783 .await?
1784 }
1785 };
1786 Ok((session, v))
1787 })?;
1788 emit_ok(data, json, |d| println!("ok perf {d}"))
1789}
1790
1791fn handle_lighthouse(
1792 url: &str,
1793 out_dir: Option<&Path>,
1794 device: &str,
1795 mode: &str,
1796 lighthouse_path: Option<&Path>,
1797 json: bool,
1798) -> Result<(), CliError> {
1799 let bin = lighthouse_path
1800 .map(|p| p.display().to_string())
1801 .unwrap_or_else(|| "lighthouse".to_string());
1802 let out = out_dir.map(|p| p.to_path_buf()).unwrap_or_else(|| {
1803 dirs::cache_dir()
1804 .unwrap_or_else(std::env::temp_dir)
1805 .join("browser-automation-cli")
1806 .join("lighthouse")
1807 });
1808 std::fs::create_dir_all(&out)
1809 .map_err(|e| CliError::new(ErrorKind::Io, format!("lighthouse out-dir: {e}")))?;
1810 let form_factor = if device.eq_ignore_ascii_case("mobile") {
1811 "mobile"
1812 } else {
1813 "desktop"
1814 };
1815 let mode_norm = if mode.eq_ignore_ascii_case("snapshot") {
1816 "snapshot"
1817 } else {
1818 "navigation"
1819 };
1820 let html_path = out.join("report.html");
1822 let json_path = out.join("report.json");
1823 let output = std::process::Command::new(&bin)
1824 .arg(url)
1825 .arg("--quiet")
1826 .arg("--output=html")
1827 .arg("--output=json")
1828 .arg(format!("--output-path={}", out.join("report").display()))
1829 .arg(format!("--form-factor={form_factor}"))
1830 .arg("--chrome-flags=--headless=new")
1831 .arg("--only-categories=accessibility,seo,best-practices")
1832 .output()
1833 .map_err(|e| {
1834 CliError::with_suggestion(
1835 ErrorKind::Unavailable,
1836 format!("lighthouse spawn failed: {e}"),
1837 "Install lighthouse (npm i -g lighthouse) or pass --lighthouse-path; doctor reports binary presence",
1838 )
1839 })?;
1840 if !output.status.success() {
1841 let stderr = String::from_utf8_lossy(&output.stderr);
1842 return Err(CliError::with_suggestion(
1843 ErrorKind::Software,
1844 format!("lighthouse exited non-zero: {stderr}"),
1845 "Check URL and lighthouse install",
1846 ));
1847 }
1848 let report_html = if html_path.exists() {
1850 html_path.clone()
1851 } else if out.join("report.report.html").exists() {
1852 out.join("report.report.html")
1853 } else {
1854 html_path.clone()
1855 };
1856 let report_json = if json_path.exists() {
1857 json_path.clone()
1858 } else if out.join("report.report.json").exists() {
1859 out.join("report.report.json")
1860 } else {
1861 out.join("report.json")
1863 };
1864
1865 let mut scores = Vec::new();
1866 let mut passed_audits = 0u64;
1867 let mut failed_audits = 0u64;
1868 if report_json.exists() {
1869 if let Ok(raw) = std::fs::read_to_string(&report_json) {
1870 if let Ok(lhr) = serde_json::from_str::<serde_json::Value>(&raw) {
1871 if let Some(cats) = lhr.get("categories").and_then(|c| c.as_object()) {
1872 for (id, cat) in cats {
1873 scores.push(serde_json::json!({
1874 "id": id,
1875 "title": cat.get("title").and_then(|t| t.as_str()).unwrap_or(id),
1876 "score": cat.get("score"),
1877 }));
1878 }
1879 }
1880 if let Some(audits) = lhr.get("audits").and_then(|a| a.as_object()) {
1881 for a in audits.values() {
1882 if let Some(sc) = a.get("score").and_then(|s| s.as_f64()) {
1883 if sc < 1.0 {
1884 failed_audits += 1;
1885 } else {
1886 passed_audits += 1;
1887 }
1888 }
1889 }
1890 }
1891 }
1892 }
1893 }
1894
1895 let data = serde_json::json!({
1896 "lighthouse": true,
1897 "url": url,
1898 "device": form_factor,
1899 "mode": mode_norm,
1900 "binary": bin,
1901 "out_dir": out.to_string_lossy(),
1902 "reports": {
1903 "html": report_html.to_string_lossy(),
1904 "json": report_json.to_string_lossy(),
1905 },
1906 "scores": scores,
1907 "passed_audits": passed_audits,
1908 "failed_audits": failed_audits,
1909 });
1910 emit_ok(data, json, |d| {
1911 println!(
1912 "ok lighthouse report={}",
1913 d.pointer("/reports/html")
1914 .and_then(|v| v.as_str())
1915 .unwrap_or("")
1916 );
1917 })
1918}
1919
1920fn handle_screencast(
1921 life: &Lifecycle,
1922 action: ScreencastAction,
1923 capture: CaptureOpts,
1924 timeout_secs: u64,
1925 json: bool,
1926) -> Result<(), CliError> {
1927 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1928 let v = match action {
1929 ScreencastAction::Start { path } => session.screencast_start(path.as_deref()).await?,
1930 ScreencastAction::Stop { path } => session.screencast_stop(path.as_deref()).await?,
1931 };
1932 Ok((session, v))
1933 })?;
1934 emit_ok(data, json, |d| println!("ok screencast {d}"))
1935}
1936
1937fn handle_heap(
1938 life: &Lifecycle,
1939 action: HeapAction,
1940 capture: CaptureOpts,
1941 timeout_secs: u64,
1942 json: bool,
1943) -> Result<(), CliError> {
1944 match action {
1945 HeapAction::Take { path } => {
1946 let data =
1947 with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1948 let v = session.heap_take(&path).await?;
1949 Ok((session, v))
1950 })?;
1951 emit_ok(data, json, |d| {
1952 println!(
1953 "ok heap take path={}",
1954 d.get("path").and_then(|v| v.as_str()).unwrap_or("")
1955 );
1956 })
1957 }
1958 HeapAction::Close { path } => {
1959 let data = OneShotSession::heap_close(&path)?;
1960 emit_ok(data, json, |_| {
1961 println!("ok heap close path={}", path.display())
1962 })
1963 }
1964 HeapAction::Compare {
1965 base,
1966 current,
1967 class_index,
1968 } => {
1969 let mut data = OneShotSession::heap_compare(&base, ¤t)?;
1970 if let Some(ci) = class_index {
1971 if let Some(obj) = data.as_object_mut() {
1972 obj.insert("class_index".into(), serde_json::json!(ci));
1973 }
1974 }
1975 emit_ok(data, json, |d| println!("ok heap compare {d}"))
1976 }
1977 HeapAction::Summary { path } => {
1978 let data = OneShotSession::heap_file_summary(&path)?;
1979 emit_ok(data, json, |d| println!("ok heap summary {d}"))
1980 }
1981 HeapAction::Details {
1982 path,
1983 filter_name,
1984 page_idx,
1985 page_size,
1986 } => {
1987 validate_heap_filter_name(filter_name.as_deref())?;
1988 let mut data = OneShotSession::heap_details(&path)?;
1989 paginate_filter_json(
1990 &mut data,
1991 "classes",
1992 filter_name.as_deref(),
1993 page_idx,
1994 page_size,
1995 );
1996 emit_ok(data, json, |d| println!("ok heap details {d}"))
1997 }
1998 HeapAction::DupStrings {
1999 path,
2000 page_idx,
2001 page_size,
2002 } => {
2003 let mut data = OneShotSession::heap_dup_strings(&path)?;
2004 paginate_filter_json(&mut data, "strings", None, page_idx, page_size);
2005 emit_ok(data, json, |d| println!("ok heap dup-strings {d}"))
2006 }
2007 HeapAction::ClassNodes {
2008 path,
2009 id,
2010 filter_name,
2011 page_idx,
2012 page_size,
2013 } => {
2014 validate_heap_filter_name(filter_name.as_deref())?;
2015 let mut data = OneShotSession::heap_class_nodes(&path, id)?;
2016 paginate_filter_json(
2017 &mut data,
2018 "nodes",
2019 filter_name.as_deref(),
2020 page_idx,
2021 page_size,
2022 );
2023 emit_ok(data, json, |d| println!("ok heap class-nodes {d}"))
2024 }
2025 HeapAction::Dominators { path, node } => {
2026 let data = OneShotSession::heap_node_op(&path, node, "dominators")?;
2027 emit_ok(data, json, |d| println!("ok heap dominators {d}"))
2028 }
2029 HeapAction::Edges {
2030 path,
2031 node,
2032 page_idx,
2033 page_size,
2034 } => {
2035 let mut data = OneShotSession::heap_node_op(&path, node, "edges")?;
2036 paginate_filter_json(&mut data, "edges", None, page_idx, page_size);
2037 emit_ok(data, json, |d| println!("ok heap edges {d}"))
2038 }
2039 HeapAction::Retainers {
2040 path,
2041 node,
2042 page_idx,
2043 page_size,
2044 } => {
2045 let mut data = OneShotSession::heap_node_op(&path, node, "retainers")?;
2046 paginate_filter_json(&mut data, "retainers", None, page_idx, page_size);
2047 emit_ok(data, json, |d| println!("ok heap retainers {d}"))
2048 }
2049 HeapAction::Paths {
2050 path,
2051 node,
2052 max_depth,
2053 max_nodes,
2054 max_siblings,
2055 } => {
2056 let data = crate::native::heap_snapshot::node_op_with_limits(
2057 &path,
2058 node,
2059 "paths",
2060 max_depth as usize,
2061 max_siblings.unwrap_or(32) as usize,
2062 max_nodes.unwrap_or(200) as usize,
2063 200,
2064 )
2065 .map_err(|e| {
2066 CliError::with_suggestion(
2067 ErrorKind::Io,
2068 e,
2069 "Pass a valid .heapsnapshot path and node id",
2070 )
2071 })?;
2072 emit_ok(data, json, |d| println!("ok heap paths {d}"))
2073 }
2074 HeapAction::ObjectDetails { path, node } => {
2075 let data = OneShotSession::heap_object_details(&path, node)?;
2076 emit_ok(data, json, |d| println!("ok heap object-details {d}"))
2077 }
2078 }
2079}
2080
2081const HEAP_FILTER_NAME_ENUM: &[&str] = &[
2083 "objectsRetainedByDetachedDomNodes",
2084 "objectsRetainedByConsole",
2085 "objectsRetainedByEventHandlers",
2086 "objectsRetainedByContexts",
2087];
2088
2089fn validate_heap_filter_name(filter_name: Option<&str>) -> Result<(), CliError> {
2090 let Some(f) = filter_name else {
2091 return Ok(());
2092 };
2093 if f.starts_with("objectsRetained")
2095 && !HEAP_FILTER_NAME_ENUM
2096 .iter()
2097 .any(|e| e.eq_ignore_ascii_case(f))
2098 {
2099 return Err(CliError::with_suggestion(
2100 ErrorKind::Usage,
2101 format!("invalid heap --filter-name enum: {f}"),
2102 "Use objectsRetainedByDetachedDomNodes|objectsRetainedByConsole|objectsRetainedByEventHandlers|objectsRetainedByContexts or free-text substring",
2103 ));
2104 }
2105 Ok(())
2106}
2107
2108fn paginate_filter_json(
2110 data: &mut serde_json::Value,
2111 array_key: &str,
2112 filter_name: Option<&str>,
2113 page_idx: Option<usize>,
2114 page_size: Option<usize>,
2115) {
2116 let key = {
2117 if data.get(array_key).and_then(|v| v.as_array()).is_some() {
2118 array_key.to_string()
2119 } else {
2120 let mut found = None;
2121 for alt in ["items", "results", "list"] {
2122 if data.get(alt).and_then(|v| v.as_array()).is_some() {
2123 found = Some(alt.to_string());
2124 break;
2125 }
2126 }
2127 match found {
2128 Some(k) => k,
2129 None => return,
2130 }
2131 }
2132 };
2133
2134 let is_enum_filter = filter_name
2135 .map(|f| {
2136 HEAP_FILTER_NAME_ENUM
2137 .iter()
2138 .any(|e| e.eq_ignore_ascii_case(f))
2139 })
2140 .unwrap_or(false);
2141
2142 if is_enum_filter {
2143 if let Some(obj) = data.as_object_mut() {
2146 obj.insert(
2147 "filter_name".into(),
2148 serde_json::json!(filter_name.unwrap()),
2149 );
2150 obj.insert(
2151 "filter_applied".into(),
2152 serde_json::json!("enum_recorded_offline_not_recomputed"),
2153 );
2154 }
2155 }
2156
2157 let Some(arr) = data.get_mut(&key).and_then(|v| v.as_array_mut()) else {
2158 return;
2159 };
2160 if let Some(f) = filter_name {
2161 if !is_enum_filter {
2162 let f_low = f.to_ascii_lowercase();
2163 arr.retain(|item| {
2164 item.get("name")
2165 .or_else(|| item.get("class_name"))
2166 .or_else(|| item.get("string"))
2167 .and_then(|v| v.as_str())
2168 .map(|s| s.to_ascii_lowercase().contains(&f_low))
2169 .unwrap_or(true)
2170 });
2171 }
2172 }
2173 let total = arr.len();
2174 let page = page_idx.unwrap_or(0);
2175 let size = page_size.unwrap_or(total.max(1));
2176 let start = page.saturating_mul(size).min(total);
2177 let end = (start + size).min(total);
2178 let page_items: Vec<serde_json::Value> = arr[start..end].to_vec();
2179 *arr = page_items;
2180 if let Some(obj) = data.as_object_mut() {
2181 obj.insert("total".into(), serde_json::json!(total));
2182 obj.insert("page_idx".into(), serde_json::json!(page));
2183 obj.insert("page_size".into(), serde_json::json!(size));
2184 }
2185}
2186
2187fn handle_extension(
2188 life: &Lifecycle,
2189 action: ExtensionAction,
2190 capture: CaptureOpts,
2191 timeout_secs: u64,
2192 json: bool,
2193) -> Result<(), CliError> {
2194 match action {
2195 ExtensionAction::List => {
2196 let data =
2197 with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2198 let v = session.extension_list().await?;
2199 Ok((session, v))
2200 })?;
2201 emit_ok(data, json, |d| println!("ok extension list {d}"))
2202 }
2203 ExtensionAction::Install { path } => {
2204 let path_s = path.display().to_string();
2205 let data = block_on_browser_timeout(
2206 async move {
2207 let mut session =
2208 OneShotSession::launch_with_extensions(capture, vec![path_s.clone()])
2209 .await?;
2210 if let Ok(mut ledger) = life.ledger.lock() {
2211 ledger.chrome_launched = true;
2212 ledger.chrome_pid = session.chrome_pid();
2213 }
2214 let mut listed = session.extension_list().await?;
2216 for _ in 0..20 {
2217 let count = listed.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
2218 if count > 0 {
2219 break;
2220 }
2221 tokio::time::sleep(std::time::Duration::from_millis(150)).await;
2222 listed = session.extension_list().await?;
2223 }
2224 let close = session.shutdown().await;
2225 if let Ok(mut ledger) = life.ledger.lock() {
2226 ledger.chrome_launched = false;
2227 ledger.chrome_pid = None;
2228 }
2229 close?;
2230 Ok(serde_json::json!({
2231 "installed_path": path_s,
2232 "load_extension": true,
2233 "targets": listed,
2234 "note": "one-shot: Chrome launched with --load-extension for this process only",
2235 }))
2236 },
2237 timeout_secs,
2238 )?;
2239 emit_ok(data, json, |d| println!("ok extension install {d}"))
2240 }
2241 ExtensionAction::Reload { id, path } => {
2242 let id = id.clone();
2243 let path_s = path.as_ref().map(|p| p.display().to_string());
2244 let data = block_on_browser_timeout(
2245 async move {
2246 let mut session = if let Some(p) = path_s {
2247 OneShotSession::launch_with_extensions(capture, vec![p]).await?
2248 } else {
2249 OneShotSession::launch_headless_with_capture(capture).await?
2250 };
2251 if let Ok(mut ledger) = life.ledger.lock() {
2252 ledger.chrome_launched = true;
2253 ledger.chrome_pid = session.chrome_pid();
2254 }
2255 let v = session.extension_reload(&id).await;
2256 let close = session.shutdown().await;
2257 if let Ok(mut ledger) = life.ledger.lock() {
2258 ledger.chrome_launched = false;
2259 ledger.chrome_pid = None;
2260 }
2261 close?;
2262 v
2263 },
2264 timeout_secs,
2265 )?;
2266 emit_ok(data, json, |d| println!("ok extension reload {d}"))
2267 }
2268 ExtensionAction::Trigger { id, path } => {
2269 let id = id.clone();
2270 let path_s = path.as_ref().map(|p| p.display().to_string());
2271 let data = block_on_browser_timeout(
2272 async move {
2273 let mut session = if let Some(p) = path_s {
2274 OneShotSession::launch_with_extensions(capture, vec![p]).await?
2275 } else {
2276 OneShotSession::launch_headless_with_capture(capture).await?
2277 };
2278 if let Ok(mut ledger) = life.ledger.lock() {
2279 ledger.chrome_launched = true;
2280 ledger.chrome_pid = session.chrome_pid();
2281 }
2282 let v = session.extension_trigger(&id).await;
2283 let close = session.shutdown().await;
2284 if let Ok(mut ledger) = life.ledger.lock() {
2285 ledger.chrome_launched = false;
2286 ledger.chrome_pid = None;
2287 }
2288 close?;
2289 v
2290 },
2291 timeout_secs,
2292 )?;
2293 emit_ok(data, json, |d| println!("ok extension trigger {d}"))
2294 }
2295 ExtensionAction::Uninstall { id } => {
2296 let data = serde_json::json!({
2298 "uninstalled": id,
2299 "persistent": false,
2300 "note": "one-shot CLI does not keep extensions across processes; omit path on next install",
2301 });
2302 emit_ok(data, json, |_| println!("ok extension uninstall id={id}"))
2303 }
2304 }
2305}
2306
2307fn handle_devtools3p(
2308 life: &Lifecycle,
2309 action: Devtools3pAction,
2310 capture: CaptureOpts,
2311 timeout_secs: u64,
2312 json: bool,
2313) -> Result<(), CliError> {
2314 match action {
2315 Devtools3pAction::List { url } => {
2316 let url = url.unwrap_or_else(|| "about:blank".into());
2317 let data =
2318 with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2319 if url != "about:blank" {
2320 let _ = session
2321 .goto(&url, crate::robots::RobotsPolicy::Ignore)
2322 .await?;
2323 }
2324 let v = session.devtools3p_list().await?;
2325 Ok((session, v))
2326 })?;
2327 emit_ok(data, json, |d| println!("ok devtools3p list {d}"))
2328 }
2329 Devtools3pAction::Exec { name, params, url } => {
2330 let url = url.unwrap_or_else(|| "about:blank".into());
2331 let params = params.clone();
2332 let name = name.clone();
2333 let data =
2334 with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2335 if url != "about:blank" {
2336 let _ = session
2337 .goto(&url, crate::robots::RobotsPolicy::Ignore)
2338 .await?;
2339 }
2340 let v = session.devtools3p_exec(&name, params.as_deref()).await?;
2341 Ok((session, v))
2342 })?;
2343 emit_ok(data, json, |d| println!("ok devtools3p exec {d}"))
2344 }
2345 }
2346}
2347
2348fn handle_webmcp(
2349 life: &Lifecycle,
2350 action: WebmcpAction,
2351 capture: CaptureOpts,
2352 timeout_secs: u64,
2353 json: bool,
2354) -> Result<(), CliError> {
2355 match action {
2356 WebmcpAction::List { url } => {
2357 let url = url.unwrap_or_else(|| "about:blank".into());
2358 let data =
2359 with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2360 if url != "about:blank" {
2361 let _ = session
2362 .goto(&url, crate::robots::RobotsPolicy::Ignore)
2363 .await?;
2364 }
2365 let v = session.webmcp_list().await?;
2366 Ok((session, v))
2367 })?;
2368 emit_ok(data, json, |d| println!("ok webmcp list {d}"))
2369 }
2370 WebmcpAction::Exec { name, input, url } => {
2371 let url = url.unwrap_or_else(|| "about:blank".into());
2372 let name = name.clone();
2373 let input = input.clone();
2374 let data =
2375 with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2376 if url != "about:blank" {
2377 let _ = session
2378 .goto(&url, crate::robots::RobotsPolicy::Ignore)
2379 .await?;
2380 }
2381 let v = session.webmcp_exec(&name, input.as_deref()).await?;
2382 Ok((session, v))
2383 })?;
2384 emit_ok(data, json, |d| println!("ok webmcp exec {d}"))
2385 }
2386 }
2387}
2388
2389fn handle_completions(shell: CompletionShell) -> Result<(), CliError> {
2390 use clap::CommandFactory;
2391 use clap_complete::{generate, shells};
2392 use std::io::Write;
2393
2394 let mut cmd = crate::cli::Cli::command();
2395 let bin = "browser-automation-cli";
2396 let mut out = std::io::stdout();
2397 match shell {
2398 CompletionShell::Bash => generate(shells::Bash, &mut cmd, bin, &mut out),
2399 CompletionShell::Zsh => generate(shells::Zsh, &mut cmd, bin, &mut out),
2400 CompletionShell::Fish => generate(shells::Fish, &mut cmd, bin, &mut out),
2401 CompletionShell::Elvish => generate(shells::Elvish, &mut cmd, bin, &mut out),
2402 CompletionShell::Powershell => generate(shells::PowerShell, &mut cmd, bin, &mut out),
2403 }
2404 let _ = out.flush();
2405 Ok(())
2406}