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, ConfigAction, ConsoleAction, CookieAction,
15 Devtools3pAction, DialogAction, ExtensionAction, GrabFormat, HeapAction, MitmAction,
16 NetAction, PageAction, PerfAction, ScreencastAction, WebmcpAction, WorkflowAction,
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 on the same invocation",
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::from_globals(
347 experimental_vision,
348 experimental_screencast,
349 category_memory,
350 category_extensions,
351 category_third_party,
352 category_webmcp,
353 );
354 match handle_run(life, &script, robots, capture, timeout_secs, json, flags) {
355 Ok(()) => 0,
356 Err(e) => emit_err(&e, json),
357 }
358 }
359 Commands::Exec { args } => {
360 let (args, json_from_trail) = peel_trailing_globals(&args);
363 let json = json || json_from_trail;
364 let flags = run::RunFlags::from_globals(
365 experimental_vision,
366 experimental_screencast,
367 category_memory,
368 category_extensions,
369 category_third_party,
370 category_webmcp,
371 );
372 match handle_exec(life, &args, robots, capture, timeout_secs, json, flags) {
373 Ok(()) => 0,
374 Err(e) => emit_err(&e, json),
375 }
376 }
377 Commands::Extract { target, attr } => {
378 match handle_extract(life, &target, attr.as_deref(), capture, timeout_secs, json) {
379 Ok(()) => 0,
380 Err(e) => emit_err(&e, json),
381 }
382 }
383 Commands::Text { target } => {
384 match handle_text(life, &target, capture, timeout_secs, json) {
385 Ok(()) => 0,
386 Err(e) => emit_err(&e, json),
387 }
388 }
389 Commands::Scroll {
390 target,
391 delta_x,
392 delta_y,
393 } => match handle_scroll(
394 life,
395 target.as_deref(),
396 delta_x,
397 delta_y,
398 capture,
399 timeout_secs,
400 json,
401 ) {
402 Ok(()) => 0,
403 Err(e) => emit_err(&e, json),
404 },
405 Commands::Cookie { action } => {
406 match handle_cookie(life, action, capture, timeout_secs, json) {
407 Ok(()) => 0,
408 Err(e) => emit_err(&e, json),
409 }
410 }
411 Commands::Attr { target, name } => {
412 match handle_attr(life, &target, &name, capture, timeout_secs, json) {
413 Ok(()) => 0,
414 Err(e) => emit_err(&e, json),
415 }
416 }
417 Commands::Assert { kind } => match handle_assert(life, kind, capture, timeout_secs, json) {
418 Ok(()) => 0,
419 Err(e) => emit_err(&e, json),
420 },
421 Commands::Console { action } => {
422 match handle_console(life, action, capture, timeout_secs, json) {
423 Ok(()) => 0,
424 Err(e) => emit_err(&e, json),
425 }
426 }
427 Commands::Net { action } => match handle_net(life, action, capture, timeout_secs, json) {
428 Ok(()) => 0,
429 Err(e) => emit_err(&e, json),
430 },
431 Commands::Page { action } => match handle_page(life, action, capture, timeout_secs, json) {
432 Ok(()) => 0,
433 Err(e) => emit_err(&e, json),
434 },
435 Commands::Dialog { action } => {
436 match handle_dialog(life, action, capture, timeout_secs, json) {
437 Ok(()) => 0,
438 Err(e) => emit_err(&e, json),
439 }
440 }
441 Commands::Scrape {
442 url,
443 format,
444 engine,
445 only_main_content,
446 } => {
447 match handle_scrape(
448 life,
449 &url,
450 robots,
451 capture,
452 timeout_secs,
453 json,
454 &format,
455 &engine,
456 only_main_content,
457 ) {
458 Ok(()) => 0,
459 Err(e) => emit_err(&e, json),
460 }
461 }
462 Commands::BatchScrape {
463 urls_file,
464 format,
465 concurrency,
466 } => match handle_batch_scrape(&urls_file, robots, &format, concurrency, json) {
467 Ok(()) => 0,
468 Err(e) => emit_err(&e, json),
469 },
470 Commands::Crawl {
471 url,
472 limit,
473 max_depth,
474 format,
475 same_host,
476 } => match handle_crawl(&url, robots, limit, max_depth, &format, same_host, json) {
477 Ok(()) => 0,
478 Err(e) => emit_err(&e, json),
479 },
480 Commands::Map {
481 url,
482 limit,
483 max_depth,
484 } => match handle_map(&url, robots, limit, max_depth, json) {
485 Ok(()) => 0,
486 Err(e) => emit_err(&e, json),
487 },
488 Commands::Search { query, limit } => match handle_search(&query, robots, limit, json) {
489 Ok(()) => 0,
490 Err(e) => emit_err(&e, json),
491 },
492 Commands::Parse { path } => match handle_parse(&path, json) {
493 Ok(()) => 0,
494 Err(e) => emit_err(&e, json),
495 },
496 Commands::Mitm { action } => match handle_mitm(action, json) {
497 Ok(()) => 0,
498 Err(e) => emit_err(&e, json),
499 },
500 Commands::Workflow { action } => match handle_workflow(action, json) {
501 Ok(()) => 0,
502 Err(e) => emit_err(&e, json),
503 },
504 Commands::Config { action } => match handle_config(action, json) {
505 Ok(()) => 0,
506 Err(e) => emit_err(&e, json),
507 },
508 Commands::Emulate {
509 user_agent,
510 locale,
511 timezone,
512 offline,
513 latitude,
514 longitude,
515 media,
516 network_conditions,
517 cpu_throttling_rate,
518 color_scheme,
519 extra_headers,
520 viewport,
521 } => match handle_emulate(
522 life,
523 user_agent.as_deref(),
524 locale.as_deref(),
525 timezone.as_deref(),
526 offline,
527 latitude,
528 longitude,
529 media.as_deref(),
530 network_conditions.as_deref(),
531 cpu_throttling_rate,
532 color_scheme.as_deref(),
533 extra_headers.as_deref(),
534 viewport.as_deref(),
535 capture,
536 timeout_secs,
537 json,
538 ) {
539 Ok(()) => 0,
540 Err(e) => emit_err(&e, json),
541 },
542 Commands::Resize {
543 width,
544 height,
545 scale,
546 mobile,
547 } => match handle_resize(
548 life,
549 width,
550 height,
551 scale,
552 mobile,
553 capture,
554 timeout_secs,
555 json,
556 ) {
557 Ok(()) => 0,
558 Err(e) => emit_err(&e, json),
559 },
560 Commands::Perf { action } => match handle_perf(life, action, capture, timeout_secs, json) {
561 Ok(()) => 0,
562 Err(e) => emit_err(&e, json),
563 },
564 Commands::Lighthouse {
565 url,
566 out_dir,
567 device,
568 mode,
569 lighthouse_path,
570 } => match handle_lighthouse(
571 &url,
572 out_dir.as_deref(),
573 &device,
574 &mode,
575 lighthouse_path.as_deref(),
576 json,
577 ) {
578 Ok(()) => 0,
579 Err(e) => emit_err(&e, json),
580 },
581 Commands::Screencast { action } => {
582 if !experimental_screencast {
583 emit_err(
584 &CliError::with_suggestion(
585 ErrorKind::Usage,
586 "screencast requires --experimental-screencast",
587 "Pass --experimental-screencast on the same invocation",
588 ),
589 json,
590 )
591 } else {
592 match handle_screencast(life, action, capture, timeout_secs, json) {
593 Ok(()) => 0,
594 Err(e) => emit_err(&e, json),
595 }
596 }
597 }
598 Commands::Heap { action } => {
599 let deep = !matches!(
600 &action,
601 HeapAction::Take { .. } | HeapAction::Summary { .. } | HeapAction::Close { .. }
602 );
603 if deep && !category_memory {
604 emit_err(
605 &CliError::with_suggestion(
606 ErrorKind::Usage,
607 "deep heap tools require --category-memory",
608 "Pass --category-memory (heap take/summary/close work without deep graph ops)",
609 ),
610 json,
611 )
612 } else {
613 match handle_heap(life, action, capture, timeout_secs, json) {
614 Ok(()) => 0,
615 Err(e) => emit_err(&e, json),
616 }
617 }
618 }
619 Commands::Extension { action } => {
620 if !category_extensions {
621 emit_err(
622 &CliError::with_suggestion(
623 ErrorKind::Usage,
624 "extension tools require --category-extensions",
625 "Pass --category-extensions on the same invocation",
626 ),
627 json,
628 )
629 } else {
630 match handle_extension(life, action, capture, timeout_secs, json) {
631 Ok(()) => 0,
632 Err(e) => emit_err(&e, json),
633 }
634 }
635 }
636 Commands::Devtools3p { action } => {
637 if !category_third_party {
638 emit_err(
639 &CliError::with_suggestion(
640 ErrorKind::Usage,
641 "devtools3p requires --category-third-party",
642 "Pass --category-third-party on the same invocation",
643 ),
644 json,
645 )
646 } else {
647 match handle_devtools3p(life, action, capture, timeout_secs, json) {
648 Ok(()) => 0,
649 Err(e) => emit_err(&e, json),
650 }
651 }
652 }
653 Commands::Webmcp { action } => {
654 if !category_webmcp {
655 emit_err(
656 &CliError::with_suggestion(
657 ErrorKind::Usage,
658 "webmcp requires --category-webmcp",
659 "Pass --category-webmcp on the same invocation",
660 ),
661 json,
662 )
663 } else {
664 match handle_webmcp(life, action, capture, timeout_secs, json) {
665 Ok(()) => 0,
666 Err(e) => emit_err(&e, json),
667 }
668 }
669 }
670 Commands::Completions { shell } => match handle_completions(shell) {
671 Ok(()) => 0,
672 Err(e) => emit_err(&e, json),
673 },
674 };
675
676 life.finalize();
677 code
678}
679
680fn handle_version(json: bool) -> Result<(), CliError> {
681 let data = serde_json::json!({
682 "name": "browser-automation-cli",
683 "version": env!("CARGO_PKG_VERSION"),
684 });
685 emit_ok(data, json, |d| {
686 println!(
687 "{}",
688 d.get("version").and_then(|v| v.as_str()).unwrap_or("")
689 );
690 })
691}
692
693fn emit_ok<F>(data: serde_json::Value, json: bool, text: F) -> Result<(), CliError>
694where
695 F: FnOnce(&serde_json::Value),
696{
697 if json {
698 print_success_json(data)?;
699 } else {
700 text(&data);
701 }
702 Ok(())
703}
704
705fn emit_err(err: &CliError, json: bool) -> i32 {
706 if json {
707 let _ = print_error_json(err);
708 } else {
709 eprintln!("error: {err}");
710 if let Some(s) = err.suggestion() {
711 eprintln!("suggestion: {s}");
712 }
713 }
714 err.exit_code() as i32
715}
716
717fn peel_trailing_globals(args: &[String]) -> (Vec<String>, bool) {
719 let mut json = false;
720 let mut out = Vec::with_capacity(args.len());
721 for a in args {
722 match a.as_str() {
723 "--json" => json = true,
724 other => out.push(other.to_string()),
725 }
726 }
727 (out, json)
728}
729
730#[allow(clippy::too_many_arguments)]
731fn handle_goto(
732 life: &Lifecycle,
733 url: &str,
734 robots: RobotsPolicy,
735 capture: CaptureOpts,
736 timeout_secs: u64,
737 json: bool,
738 init_script: Option<&str>,
739 handle_before_unload: bool,
740 navigation_timeout_ms: Option<u64>,
741) -> Result<(), CliError> {
742 let _ = (init_script, handle_before_unload, navigation_timeout_ms);
743 let data = block_on_browser_timeout(
746 run_goto_with_robots(life, url, capture, robots),
747 timeout_secs,
748 )?;
749 emit_ok(data, json, |d| {
750 let u = d.get("url").and_then(|v| v.as_str()).unwrap_or(url);
751 let t = d.get("title").and_then(|v| v.as_str()).unwrap_or("");
752 println!("ok url={u} title={t}");
753 })
754}
755
756fn handle_view(
757 life: &Lifecycle,
758 verbose: bool,
759 path: Option<&Path>,
760 capture: CaptureOpts,
761 timeout_secs: u64,
762 json: bool,
763) -> Result<(), CliError> {
764 let path_owned = path.map(|p| p.to_path_buf());
765 let data = block_on_browser_timeout(
766 async move {
767 let mut data = run_view(life, verbose, capture).await?;
768 if let Some(p) = path_owned.as_ref() {
769 if let Some(parent) = p.parent() {
770 if !parent.as_os_str().is_empty() {
771 std::fs::create_dir_all(parent).map_err(|e| {
772 CliError::new(ErrorKind::Io, format!("view --path mkdir: {e}"))
773 })?;
774 }
775 }
776 let tree = data.get("tree").and_then(|v| v.as_str()).unwrap_or("");
777 std::fs::write(p, tree.as_bytes())
778 .map_err(|e| CliError::new(ErrorKind::Io, format!("view --path write: {e}")))?;
779 if let Some(obj) = data.as_object_mut() {
780 obj.insert(
781 "path".to_string(),
782 serde_json::Value::String(p.display().to_string()),
783 );
784 }
785 }
786 Ok(data)
787 },
788 timeout_secs,
789 )?;
790 emit_ok(data, json, |d| {
791 if let Some(p) = d.get("path").and_then(|v| v.as_str()) {
792 println!("ok view path={p}");
793 } else if let Some(tree) = d.get("tree").and_then(|v| v.as_str()) {
794 print!("{tree}");
795 if !tree.ends_with('\n') {
796 println!();
797 }
798 } else {
799 println!("ok view");
800 }
801 })
802}
803
804fn handle_press(
805 life: &Lifecycle,
806 target: &str,
807 dblclick: bool,
808 include_snapshot: bool,
809 capture: CaptureOpts,
810 timeout_secs: u64,
811 json: bool,
812) -> Result<(), CliError> {
813 let data = block_on_browser_timeout(
814 run_press(life, target, dblclick, include_snapshot, capture),
815 timeout_secs,
816 )?;
817 emit_ok(data, json, |_| {
818 println!("ok pressed={target} dblclick={dblclick}");
819 })
820}
821
822fn handle_write(
823 life: &Lifecycle,
824 target: &str,
825 value: &str,
826 include_snapshot: bool,
827 capture: CaptureOpts,
828 timeout_secs: u64,
829 json: bool,
830) -> Result<(), CliError> {
831 let data = block_on_browser_timeout(
832 run_write(life, target, value, include_snapshot, capture),
833 timeout_secs,
834 )?;
835 emit_ok(data, json, |_| {
836 println!("ok written={target} len={}", value.len());
837 })
838}
839
840#[allow(clippy::too_many_arguments)]
841fn handle_click_at(
842 life: &Lifecycle,
843 x: f64,
844 y: f64,
845 dblclick: bool,
846 include_snapshot: bool,
847 capture: CaptureOpts,
848 timeout_secs: u64,
849 json: bool,
850) -> Result<(), CliError> {
851 let data = block_on_browser_timeout(
852 async move {
853 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
854 if let Ok(mut ledger) = life.ledger.lock() {
855 ledger.chrome_launched = true;
856 ledger.chrome_pid = session.chrome_pid();
857 }
858 let _ = session
859 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
860 .await?;
861 let r = session.click_at(x, y, dblclick, include_snapshot).await;
862 let close = session.shutdown().await;
863 if let Ok(mut ledger) = life.ledger.lock() {
864 ledger.chrome_launched = false;
865 ledger.chrome_pid = None;
866 }
867 close?;
868 r
869 },
870 timeout_secs,
871 )?;
872 emit_ok(data, json, |_| {
873 println!("ok click-at x={x} y={y} dbl={dblclick}")
874 })
875}
876
877fn handle_keys(
878 life: &Lifecycle,
879 key: &str,
880 include_snapshot: bool,
881 capture: CaptureOpts,
882 timeout_secs: u64,
883 json: bool,
884) -> Result<(), CliError> {
885 let data =
886 block_on_browser_timeout(run_keys(life, key, include_snapshot, capture), timeout_secs)?;
887 emit_ok(data, json, |_| println!("ok key={key}"))
888}
889
890#[allow(clippy::too_many_arguments)]
891fn handle_type(
892 life: &Lifecycle,
893 target: Option<&str>,
894 text: &str,
895 clear: bool,
896 submit: Option<&str>,
897 focus_only: bool,
898 capture: CaptureOpts,
899 timeout_secs: u64,
900 json: bool,
901) -> Result<(), CliError> {
902 if target.is_none() && !focus_only {
903 return Err(CliError::with_suggestion(
904 ErrorKind::Usage,
905 "type requires a target or --focus-only",
906 "Pass TARGET or --focus-only for the focused element",
907 ));
908 }
909 let data = block_on_browser_timeout(
910 run_type(life, target, text, clear, submit, focus_only, capture),
911 timeout_secs,
912 )?;
913 let label = target.unwrap_or("(focused)");
914 emit_ok(data, json, |_| {
915 println!(
916 "ok typed={label} len={} clear={clear} submit={submit:?} focus_only={focus_only}",
917 text.len()
918 );
919 })
920}
921
922#[allow(clippy::too_many_arguments)]
923fn handle_wait(
924 life: &Lifecycle,
925 ms: u64,
926 texts: &[String],
927 selector: Option<&str>,
928 state: Option<&str>,
929 wait_timeout_ms: Option<u64>,
930 include_snapshot: bool,
931 capture: CaptureOpts,
932 timeout_secs: u64,
933 json: bool,
934) -> Result<(), CliError> {
935 let texts_owned = texts.to_vec();
936 let selector_owned = selector.map(|s| s.to_string());
937 let state_owned = state.map(|s| s.to_string());
938 let wait_ms = wait_timeout_ms.or(if ms == 0 { None } else { Some(ms) });
940 let data = block_on_browser_timeout(
941 async move {
942 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
943 if let Ok(mut ledger) = life.ledger.lock() {
944 ledger.chrome_launched = true;
945 ledger.chrome_pid = session.chrome_pid();
946 }
947 let r = if texts_owned.is_empty() {
949 session
950 .wait_for(
951 wait_ms,
952 None,
953 selector_owned.as_deref(),
954 state_owned.as_deref(),
955 include_snapshot,
956 )
957 .await
958 } else {
959 let mut last_err = None;
960 let mut ok = None;
961 for t in &texts_owned {
962 match session
963 .wait_for(
964 wait_ms,
965 Some(t.as_str()),
966 selector_owned.as_deref(),
967 state_owned.as_deref(),
968 false,
969 )
970 .await
971 {
972 Ok(v) => {
973 ok = Some(v);
974 break;
975 }
976 Err(e) => last_err = Some(e),
977 }
978 }
979 match ok {
980 Some(v) => {
981 if include_snapshot {
982 Ok(session
983 .attach_snapshot_if(true, v)
984 .await
985 .unwrap_or_else(|_| serde_json::json!({"waited": true})))
986 } else {
987 Ok(v)
988 }
989 }
990 None => Err(last_err.unwrap_or_else(|| {
991 CliError::new(ErrorKind::Browser, "wait: no text matched")
992 })),
993 }
994 };
995 let close = session.shutdown().await;
996 if let Ok(mut ledger) = life.ledger.lock() {
997 ledger.chrome_launched = false;
998 ledger.chrome_pid = None;
999 }
1000 close?;
1001 r
1002 },
1003 timeout_secs,
1004 )?;
1005 emit_ok(data, json, |d| {
1006 println!("ok wait {}", d);
1007 })
1008}
1009
1010fn with_session_blank<F, Fut>(
1011 life: &Lifecycle,
1012 capture: CaptureOpts,
1013 timeout_secs: u64,
1014 f: F,
1015) -> Result<serde_json::Value, CliError>
1016where
1017 F: FnOnce(OneShotSession) -> Fut + Send + 'static,
1018 Fut: std::future::Future<Output = Result<(OneShotSession, serde_json::Value), CliError>> + Send,
1019{
1020 block_on_browser_timeout(
1021 async move {
1022 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1023 if let Ok(mut ledger) = life.ledger.lock() {
1024 ledger.chrome_launched = true;
1025 ledger.chrome_pid = session.chrome_pid();
1026 }
1027 let _ = session
1028 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1029 .await?;
1030 let (session, value) = f(session).await?;
1031 let close = session.shutdown().await;
1032 if let Ok(mut ledger) = life.ledger.lock() {
1033 ledger.chrome_launched = false;
1034 ledger.chrome_pid = None;
1035 }
1036 close?;
1037 Ok(value)
1038 },
1039 timeout_secs,
1040 )
1041}
1042
1043fn handle_hover(
1044 life: &Lifecycle,
1045 target: &str,
1046 include_snapshot: bool,
1047 capture: CaptureOpts,
1048 timeout_secs: u64,
1049 json: bool,
1050) -> Result<(), CliError> {
1051 let target = target.to_string();
1052 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1053 let v = session.hover(&target, include_snapshot).await?;
1054 Ok((session, v))
1055 })?;
1056 emit_ok(data, json, |_| println!("ok hover"))
1057}
1058
1059fn handle_drag(
1060 life: &Lifecycle,
1061 from: &str,
1062 to: &str,
1063 include_snapshot: bool,
1064 capture: CaptureOpts,
1065 timeout_secs: u64,
1066 json: bool,
1067) -> Result<(), CliError> {
1068 let from = from.to_string();
1069 let to = to.to_string();
1070 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1071 let v = session.drag(&from, &to, include_snapshot).await?;
1072 Ok((session, v))
1073 })?;
1074 emit_ok(data, json, |_| println!("ok drag"))
1075}
1076
1077fn handle_fill_form(
1078 life: &Lifecycle,
1079 fields_json: &str,
1080 include_snapshot: bool,
1081 capture: CaptureOpts,
1082 timeout_secs: u64,
1083 json: bool,
1084) -> Result<(), CliError> {
1085 let parsed: serde_json::Value = serde_json::from_str(fields_json).map_err(|e| {
1086 CliError::with_suggestion(
1087 ErrorKind::Usage,
1088 format!("fill-form --json parse error: {e}"),
1089 r##"Pass JSON array: [{"target":"input","value":"x"}] or [{"uid":"@e1","value":"x"}]"##,
1090 )
1091 })?;
1092 let arr = parsed.as_array().ok_or_else(|| {
1093 CliError::with_suggestion(
1094 ErrorKind::Usage,
1095 "fill-form --json must be a JSON array",
1096 r##"[{"target":"input","value":"x"}]"##,
1097 )
1098 })?;
1099 let mut fields = Vec::new();
1100 for item in arr {
1101 let target = item
1102 .get("target")
1103 .or_else(|| item.get("uid"))
1104 .or_else(|| item.get("selector"))
1105 .or_else(|| item.get("ref"))
1106 .and_then(|v| v.as_str())
1107 .ok_or_else(|| {
1108 CliError::new(
1109 ErrorKind::Usage,
1110 "fill-form field missing target/uid/selector/ref",
1111 )
1112 })?
1113 .to_string();
1114 let target = if target.starts_with('e')
1116 && target.len() > 1
1117 && target[1..].chars().all(|c| c.is_ascii_digit())
1118 {
1119 format!("@{target}")
1120 } else {
1121 target
1122 };
1123 let value = item
1124 .get("value")
1125 .or_else(|| item.get("text"))
1126 .and_then(|v| v.as_str())
1127 .ok_or_else(|| CliError::new(ErrorKind::Usage, "fill-form field missing value"))?
1128 .to_string();
1129 fields.push((target, value));
1130 }
1131 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1132 let v = session.fill_form(&fields, include_snapshot).await?;
1133 Ok((session, v))
1134 })?;
1135 emit_ok(data, json, |d| {
1136 let n = d.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
1137 println!("ok fill-form count={n}");
1138 })
1139}
1140
1141fn handle_upload(
1142 life: &Lifecycle,
1143 target: &str,
1144 path: &Path,
1145 include_snapshot: bool,
1146 capture: CaptureOpts,
1147 timeout_secs: u64,
1148 json: bool,
1149) -> Result<(), CliError> {
1150 let target = target.to_string();
1151 let path = path.to_path_buf();
1152 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1153 let v = session.upload(&target, &path, include_snapshot).await?;
1154 Ok((session, v))
1155 })?;
1156 emit_ok(data, json, |d| {
1157 let p = d.get("path").and_then(|v| v.as_str()).unwrap_or("");
1158 println!("ok upload path={p}");
1159 })
1160}
1161
1162fn handle_history(
1163 life: &Lifecycle,
1164 direction: &str,
1165 capture: CaptureOpts,
1166 timeout_secs: u64,
1167 json: bool,
1168) -> Result<(), CliError> {
1169 let direction = direction.to_string();
1170 let direction_label = direction.clone();
1171 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1172 let v = match direction.as_str() {
1173 "back" => session.back().await?,
1174 "forward" => session.forward().await?,
1175 other => {
1176 return Err(CliError::new(
1177 ErrorKind::Usage,
1178 format!("unknown history direction: {other}"),
1179 ))
1180 }
1181 };
1182 Ok((session, v))
1183 })?;
1184 emit_ok(data, json, |_| println!("ok {direction_label}"))
1185}
1186
1187fn handle_reload(
1188 life: &Lifecycle,
1189 ignore_cache: bool,
1190 init_script: Option<&str>,
1191 handle_before_unload: bool,
1192 capture: CaptureOpts,
1193 timeout_secs: u64,
1194 json: bool,
1195) -> Result<(), CliError> {
1196 let init = init_script.map(|s| s.to_string());
1197 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1198 if let Some(ref js) = init {
1199 let _ = session.eval(js, None, Some("accept"), None).await;
1200 }
1201 if handle_before_unload {
1202 let _ = session
1204 .eval(
1205 "window.addEventListener('beforeunload', e => { e.returnValue=''; });",
1206 None,
1207 Some("accept"),
1208 None,
1209 )
1210 .await;
1211 }
1212 let v = session.reload(ignore_cache).await?;
1213 Ok((session, v))
1214 })?;
1215 emit_ok(data, json, |_| {
1216 println!("ok reload ignore_cache={ignore_cache}")
1217 })
1218}
1219
1220#[allow(clippy::too_many_arguments)]
1221fn handle_eval(
1222 life: &Lifecycle,
1223 expression: &str,
1224 args: Option<&str>,
1225 dialog_action: Option<&str>,
1226 file_path: Option<&Path>,
1227 capture: CaptureOpts,
1228 timeout_secs: u64,
1229 json: bool,
1230) -> Result<(), CliError> {
1231 let expr = expression.to_string();
1232 let args_owned = args.map(|s| s.to_string());
1233 let dialog_owned = dialog_action.map(|s| s.to_string());
1234 let path_owned = file_path.map(|p| p.to_path_buf());
1235 let data = block_on_browser_timeout(
1236 async move {
1237 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1238 if let Ok(mut ledger) = life.ledger.lock() {
1239 ledger.chrome_launched = true;
1240 ledger.chrome_pid = session.chrome_pid();
1241 }
1242 let _ = session
1243 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1244 .await?;
1245 let r = session
1246 .eval(
1247 &expr,
1248 args_owned.as_deref(),
1249 dialog_owned.as_deref(),
1250 path_owned.as_deref(),
1251 )
1252 .await;
1253 let close = session.shutdown().await;
1254 if let Ok(mut ledger) = life.ledger.lock() {
1255 ledger.chrome_launched = false;
1256 ledger.chrome_pid = None;
1257 }
1258 close?;
1259 r
1260 },
1261 timeout_secs,
1262 )?;
1263 emit_ok(data, json, |d| {
1264 println!(
1265 "ok eval={}",
1266 d.get("result").unwrap_or(&serde_json::Value::Null)
1267 );
1268 })
1269}
1270
1271#[allow(clippy::too_many_arguments)]
1272fn handle_grab(
1273 life: &Lifecycle,
1274 path: Option<&Path>,
1275 format: GrabFormat,
1276 full_page: bool,
1277 quality: Option<i32>,
1278 element: Option<&str>,
1279 artifacts: Option<&Path>,
1280 capture: CaptureOpts,
1281 timeout_secs: u64,
1282 json: bool,
1283) -> Result<(), CliError> {
1284 let fmt = match format {
1285 GrabFormat::Png => "png",
1286 GrabFormat::Jpeg => "jpeg",
1287 GrabFormat::Webp => "webp",
1288 };
1289 if let Some(a) = artifacts {
1290 std::fs::create_dir_all(a)
1291 .map_err(|e| CliError::new(ErrorKind::Io, format!("artifacts-dir mkdir: {e}")))?;
1292 }
1293 let path_owned = path.map(|p| p.to_path_buf()).or_else(|| {
1294 artifacts.map(|a| {
1295 a.join(format!(
1296 "grab-{}.{}",
1297 std::time::SystemTime::now()
1298 .duration_since(std::time::UNIX_EPOCH)
1299 .map(|d| d.as_millis())
1300 .unwrap_or(0),
1301 fmt
1302 ))
1303 })
1304 });
1305 if let Some(ref p) = path_owned {
1306 if let Some(parent) = p.parent() {
1307 if !parent.as_os_str().is_empty() {
1308 std::fs::create_dir_all(parent)
1309 .map_err(|e| CliError::new(ErrorKind::Io, format!("grab path mkdir: {e}")))?;
1310 }
1311 }
1312 }
1313 let element_owned = element.map(|s| s.to_string());
1314 let data = block_on_browser_timeout(
1315 async move {
1316 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1317 if let Ok(mut ledger) = life.ledger.lock() {
1318 ledger.chrome_launched = true;
1319 ledger.chrome_pid = session.chrome_pid();
1320 }
1321 let _ = session
1322 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1323 .await?;
1324 let r = session
1325 .grab(
1326 path_owned.as_deref(),
1327 fmt,
1328 full_page,
1329 quality,
1330 element_owned.as_deref(),
1331 )
1332 .await;
1333 let close = session.shutdown().await;
1334 if let Ok(mut ledger) = life.ledger.lock() {
1335 ledger.chrome_launched = false;
1336 ledger.chrome_pid = None;
1337 }
1338 close?;
1339 r
1340 },
1341 timeout_secs,
1342 )?;
1343 emit_ok(data, json, |d| {
1344 let p = d.get("path").and_then(|v| v.as_str()).unwrap_or("");
1345 println!("ok grab path={p}");
1346 })
1347}
1348
1349fn handle_run(
1350 life: &Lifecycle,
1351 script: &Path,
1352 robots: RobotsPolicy,
1353 capture: CaptureOpts,
1354 timeout_secs: u64,
1355 json: bool,
1356 flags: run::RunFlags,
1357) -> Result<(), CliError> {
1358 let script = script.to_path_buf();
1359 let data = block_on_browser_timeout(
1360 run::run_script_with_flags(life, &script, robots, capture, flags),
1361 timeout_secs,
1362 )?;
1363 emit_ok(data, json, |d| {
1364 let total = d.get("total").and_then(|v| v.as_u64()).unwrap_or(0);
1365 println!("ok run steps={total}");
1366 })
1367}
1368
1369fn handle_exec(
1370 life: &Lifecycle,
1371 args: &[String],
1372 robots: RobotsPolicy,
1373 capture: CaptureOpts,
1374 timeout_secs: u64,
1375 json: bool,
1376 flags: run::RunFlags,
1377) -> Result<(), CliError> {
1378 if args.is_empty() {
1379 return Err(CliError::with_suggestion(
1380 ErrorKind::Usage,
1381 "exec requires a subcommand (e.g. goto)",
1382 "browser-automation-cli exec goto about:blank",
1383 ));
1384 }
1385 match args[0].as_str() {
1387 "goto" => {
1388 let url = args.get(1).ok_or_else(|| {
1389 CliError::with_suggestion(
1390 ErrorKind::Usage,
1391 "exec goto requires a URL",
1392 "browser-automation-cli exec goto about:blank",
1393 )
1394 })?;
1395 handle_goto(
1396 life,
1397 url,
1398 robots,
1399 capture,
1400 timeout_secs,
1401 json,
1402 None,
1403 false,
1404 None,
1405 )
1406 }
1407 "wait" | "view" | "press" | "write" | "keys" | "type" | "hover" | "back" | "forward"
1408 | "reload" | "eval" | "grab" | "page" | "console" | "net" | "dialog" | "emulate"
1409 | "resize" | "extract" | "text" | "scroll" | "cookie" | "attr" | "assert" | "click-at"
1410 | "drag" | "fill-form" | "upload" | "devtools3p" | "webmcp" | "heap" | "perf"
1411 | "lighthouse" | "screencast" | "extension" => {
1412 let step = run::argv_to_step(args)?;
1413 let data = block_on_browser_timeout(
1414 run::run_one_step(life, step, robots, capture, flags),
1415 timeout_secs,
1416 )?;
1417 emit_ok(data, json, |d| println!("ok exec {d}"))
1418 }
1419 other => Err(CliError::with_suggestion(
1420 ErrorKind::Usage,
1421 format!("unknown exec subcommand: {other}"),
1422 "Use browser-automation-cli exec <cmd> ... or run --script for multi-step NDJSON",
1423 )),
1424 }
1425}
1426
1427fn handle_extract(
1428 life: &Lifecycle,
1429 target: &str,
1430 attr: Option<&str>,
1431 capture: CaptureOpts,
1432 timeout_secs: u64,
1433 json: bool,
1434) -> Result<(), CliError> {
1435 let target = target.to_string();
1436 let attr = attr.map(|s| s.to_string());
1437 let data = block_on_browser_timeout(
1438 async move {
1439 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1440 if let Ok(mut ledger) = life.ledger.lock() {
1441 ledger.chrome_launched = true;
1442 ledger.chrome_pid = session.chrome_pid();
1443 }
1444 let _ = session
1445 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1446 .await?;
1447 let r = session.extract(&target, attr.as_deref()).await;
1448 let close = session.shutdown().await;
1449 if let Ok(mut ledger) = life.ledger.lock() {
1450 ledger.chrome_launched = false;
1451 ledger.chrome_pid = None;
1452 }
1453 close?;
1454 r
1455 },
1456 timeout_secs,
1457 )?;
1458 emit_ok(data, json, |d| println!("ok extract {d}"))
1459}
1460
1461fn handle_attr(
1462 life: &Lifecycle,
1463 target: &str,
1464 name: &str,
1465 capture: CaptureOpts,
1466 timeout_secs: u64,
1467 json: bool,
1468) -> Result<(), CliError> {
1469 handle_extract(life, target, Some(name), capture, timeout_secs, json)
1470}
1471
1472fn handle_assert(
1473 life: &Lifecycle,
1474 kind: AssertKind,
1475 capture: CaptureOpts,
1476 timeout_secs: u64,
1477 json: bool,
1478) -> Result<(), CliError> {
1479 let data = block_on_browser_timeout(
1480 async move {
1481 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1482 if let Ok(mut ledger) = life.ledger.lock() {
1483 ledger.chrome_launched = true;
1484 ledger.chrome_pid = session.chrome_pid();
1485 }
1486 let _ = session
1487 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1488 .await?;
1489 let r = match kind {
1490 AssertKind::Url { value, contains } => session.assert_url(&value, contains).await,
1491 AssertKind::Text { value, target } => {
1492 session.assert_text(&value, target.as_deref()).await
1493 }
1494 AssertKind::Console { level, max } => session.assert_console(&level, max).await,
1495 };
1496 let close = session.shutdown().await;
1497 if let Ok(mut ledger) = life.ledger.lock() {
1498 ledger.chrome_launched = false;
1499 ledger.chrome_pid = None;
1500 }
1501 close?;
1502 r
1503 },
1504 timeout_secs,
1505 )?;
1506 emit_ok(data, json, |_| println!("ok assert"))
1507}
1508
1509fn handle_console(
1510 life: &Lifecycle,
1511 action: ConsoleAction,
1512 capture: CaptureOpts,
1513 timeout_secs: u64,
1514 json: bool,
1515) -> Result<(), CliError> {
1516 let data = block_on_browser_timeout(
1517 async move {
1518 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1519 if let Ok(mut ledger) = life.ledger.lock() {
1520 ledger.chrome_launched = true;
1521 ledger.chrome_pid = session.chrome_pid();
1522 }
1523 let _ = session
1524 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1525 .await?;
1526 let r = match action {
1527 ConsoleAction::List {
1528 page_idx,
1529 page_size,
1530 types,
1531 include_preserved,
1532 service_worker_id,
1533 } => session.console_list(
1534 page_idx,
1535 page_size,
1536 types.as_deref(),
1537 include_preserved,
1538 service_worker_id.as_deref(),
1539 ),
1540 ConsoleAction::Get { id } => session.console_get(id),
1541 ConsoleAction::Clear => session.console_clear(),
1542 ConsoleAction::Dump { path } => session.console_dump(&path),
1543 };
1544 let close = session.shutdown().await;
1545 if let Ok(mut ledger) = life.ledger.lock() {
1546 ledger.chrome_launched = false;
1547 ledger.chrome_pid = None;
1548 }
1549 close?;
1550 r
1551 },
1552 timeout_secs,
1553 )?;
1554 emit_ok(data, json, |d| println!("ok console {d}"))
1555}
1556
1557fn handle_net(
1558 life: &Lifecycle,
1559 action: NetAction,
1560 capture: CaptureOpts,
1561 timeout_secs: u64,
1562 json: bool,
1563) -> Result<(), CliError> {
1564 let data = block_on_browser_timeout(
1565 async move {
1566 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1567 if let Ok(mut ledger) = life.ledger.lock() {
1568 ledger.chrome_launched = true;
1569 ledger.chrome_pid = session.chrome_pid();
1570 }
1571 let _ = session
1572 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1573 .await?;
1574 let r = match action {
1575 NetAction::List {
1576 page_idx,
1577 page_size,
1578 resource_types,
1579 include_preserved,
1580 } => session.net_list(
1581 page_idx,
1582 page_size,
1583 resource_types.as_deref(),
1584 include_preserved,
1585 ),
1586 NetAction::Get {
1587 id,
1588 request_path,
1589 response_path,
1590 } => session.net_get(&id, request_path.as_deref(), response_path.as_deref()),
1591 };
1592 let close = session.shutdown().await;
1593 if let Ok(mut ledger) = life.ledger.lock() {
1594 ledger.chrome_launched = false;
1595 ledger.chrome_pid = None;
1596 }
1597 close?;
1598 r
1599 },
1600 timeout_secs,
1601 )?;
1602 emit_ok(data, json, |d| println!("ok net {d}"))
1603}
1604
1605fn handle_page(
1606 life: &Lifecycle,
1607 action: Option<PageAction>,
1608 capture: CaptureOpts,
1609 timeout_secs: u64,
1610 json: bool,
1611) -> Result<(), CliError> {
1612 let action = action.unwrap_or(PageAction::Info);
1613 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1614 let v = match action {
1615 PageAction::Info => session.page_info().await?,
1616 PageAction::List => session.page_list().await?,
1617 PageAction::New {
1618 url,
1619 background,
1620 isolated_context,
1621 } => {
1622 session
1623 .page_new(url.as_deref(), background, isolated_context)
1624 .await?
1625 }
1626 PageAction::Select {
1627 index,
1628 page_id,
1629 bring_to_front,
1630 } => {
1631 let idx = index.or(page_id).ok_or_else(|| {
1632 CliError::with_suggestion(
1633 ErrorKind::Usage,
1634 "page select requires INDEX or --page-id",
1635 "browser-automation-cli page select 0 --json",
1636 )
1637 })?;
1638 session.page_select(idx, bring_to_front).await?
1639 }
1640 PageAction::Close { index, page_id } => session.page_close(index.or(page_id)).await?,
1641 };
1642 Ok((session, v))
1643 })?;
1644 emit_ok(data, json, |d| {
1645 if let (Some(u), Some(t)) = (
1646 d.get("url").and_then(|v| v.as_str()),
1647 d.get("title").and_then(|v| v.as_str()),
1648 ) {
1649 println!("ok page url={u} title={t}");
1650 } else {
1651 println!("ok page {d}");
1652 }
1653 })
1654}
1655
1656fn handle_text(
1657 life: &Lifecycle,
1658 target: &str,
1659 capture: CaptureOpts,
1660 timeout_secs: u64,
1661 json: bool,
1662) -> Result<(), CliError> {
1663 handle_extract(life, target, None, capture, timeout_secs, json)
1664}
1665
1666fn handle_scroll(
1667 life: &Lifecycle,
1668 target: Option<&str>,
1669 delta_x: f64,
1670 delta_y: f64,
1671 capture: CaptureOpts,
1672 timeout_secs: u64,
1673 json: bool,
1674) -> Result<(), CliError> {
1675 let target_owned = target.map(|s| s.to_string());
1676 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1677 let v = session
1678 .scroll(target_owned.as_deref(), delta_x, delta_y)
1679 .await?;
1680 Ok((session, v))
1681 })?;
1682 emit_ok(data, json, |d| println!("ok scroll {d}"))
1683}
1684
1685fn handle_cookie(
1686 life: &Lifecycle,
1687 action: CookieAction,
1688 capture: CaptureOpts,
1689 timeout_secs: u64,
1690 json: bool,
1691) -> Result<(), CliError> {
1692 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1693 let v = match action {
1694 CookieAction::List { url } => session.cookie_list(url.as_deref()).await?,
1695 CookieAction::Set { json: body } => session.cookie_set(&body).await?,
1696 CookieAction::Clear => session.cookie_clear().await?,
1697 };
1698 Ok((session, v))
1699 })?;
1700 emit_ok(data, json, |d| println!("ok cookie {d}"))
1701}
1702
1703fn handle_dialog(
1704 life: &Lifecycle,
1705 action: DialogAction,
1706 capture: CaptureOpts,
1707 timeout_secs: u64,
1708 json: bool,
1709) -> Result<(), CliError> {
1710 let data = block_on_browser_timeout(
1711 async move {
1712 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1713 if let Ok(mut ledger) = life.ledger.lock() {
1714 ledger.chrome_launched = true;
1715 ledger.chrome_pid = session.chrome_pid();
1716 }
1717 let _ = session
1718 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1719 .await?;
1720 let r = match action {
1721 DialogAction::Accept { text } => session.dialog(true, text.as_deref()).await,
1722 DialogAction::Dismiss => session.dialog(false, None).await,
1723 };
1724 let close = session.shutdown().await;
1725 if let Ok(mut ledger) = life.ledger.lock() {
1726 ledger.chrome_launched = false;
1727 ledger.chrome_pid = None;
1728 }
1729 close?;
1730 r
1731 },
1732 timeout_secs,
1733 )?;
1734 emit_ok(data, json, |_| println!("ok dialog"))
1735}
1736
1737#[allow(clippy::too_many_arguments)]
1738fn handle_scrape(
1739 life: &Lifecycle,
1740 url: &str,
1741 robots: RobotsPolicy,
1742 capture: CaptureOpts,
1743 timeout_secs: u64,
1744 json: bool,
1745 format: &str,
1746 engine: &str,
1747 only_main_content: bool,
1748) -> Result<(), CliError> {
1749 let fmt = crate::scrape_local::ScrapeFormat::parse(format)?;
1750 let engine_l = engine.to_ascii_lowercase();
1751 if engine_l == "http" {
1752 let opts = crate::scrape_local::ScrapeOpts {
1753 format: fmt,
1754 only_main_content,
1755 engine: "http".into(),
1756 ..Default::default()
1757 };
1758 let data = block_on_browser_timeout(
1759 crate::scrape_local::scrape_http(url, robots, &opts),
1760 timeout_secs,
1761 )?;
1762 return emit_ok(data, json, |d| {
1763 let u = d.get("source_url").and_then(|v| v.as_str()).unwrap_or(url);
1764 println!("ok scrape engine=http source_url={u}");
1765 });
1766 }
1767 let data = block_on_browser_timeout(run_scrape(life, url, robots, capture), timeout_secs)?;
1769 let data = if matches!(
1770 fmt,
1771 crate::scrape_local::ScrapeFormat::Text
1772 ) && !only_main_content
1773 {
1774 data
1775 } else {
1776 let html = data
1778 .get("html")
1779 .and_then(|v| v.as_str())
1780 .unwrap_or("")
1781 .to_string();
1782 if html.is_empty() {
1783 data
1784 } else {
1785 let opts = crate::scrape_local::ScrapeOpts {
1786 format: fmt,
1787 only_main_content,
1788 engine: "browser".into(),
1789 ..Default::default()
1790 };
1791 crate::scrape_local::build_scrape_payload(url, 200, &html, &opts, robots)
1792 }
1793 };
1794 emit_ok(data, json, |d| {
1795 let policy = d
1796 .get("robots_policy")
1797 .and_then(|v| v.as_str())
1798 .unwrap_or("honor");
1799 let u = d.get("source_url").and_then(|v| v.as_str()).unwrap_or(url);
1800 println!("ok scrape source_url={u} robots_policy={policy}");
1801 })
1802}
1803
1804fn handle_batch_scrape(
1805 urls_file: &Path,
1806 robots: RobotsPolicy,
1807 format: &str,
1808 concurrency: usize,
1809 json: bool,
1810) -> Result<(), CliError> {
1811 let urls = crate::scrape_local::read_urls_file(urls_file)?;
1812 let opts = crate::scrape_local::ScrapeOpts {
1813 format: crate::scrape_local::ScrapeFormat::parse(format)?,
1814 engine: "http".into(),
1815 ..Default::default()
1816 };
1817 let data = block_on_browser_timeout(
1818 crate::scrape_local::batch_scrape_http(&urls, robots, &opts, concurrency),
1819 0,
1820 )?;
1821 emit_ok(data, json, |d| {
1822 println!(
1823 "ok batch-scrape count={}",
1824 d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)
1825 );
1826 })
1827}
1828
1829fn handle_crawl(
1830 url: &str,
1831 robots: RobotsPolicy,
1832 limit: usize,
1833 max_depth: usize,
1834 format: &str,
1835 same_host: bool,
1836 json: bool,
1837) -> Result<(), CliError> {
1838 let opts = crate::scrape_local::ScrapeOpts {
1839 format: crate::scrape_local::ScrapeFormat::parse(format)?,
1840 engine: "http".into(),
1841 ..Default::default()
1842 };
1843 let data = block_on_browser_timeout(
1844 crate::scrape_local::crawl_http(url, robots, &opts, limit, max_depth, same_host),
1845 0,
1846 )?;
1847 emit_ok(data, json, |d| {
1848 println!(
1849 "ok crawl count={}",
1850 d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)
1851 );
1852 })
1853}
1854
1855fn handle_map(
1856 url: &str,
1857 robots: RobotsPolicy,
1858 limit: usize,
1859 max_depth: usize,
1860 json: bool,
1861) -> Result<(), CliError> {
1862 let data = block_on_browser_timeout(
1863 crate::scrape_local::map_http(url, robots, limit, max_depth),
1864 0,
1865 )?;
1866 emit_ok(data, json, |d| {
1867 println!(
1868 "ok map count={}",
1869 d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)
1870 );
1871 })
1872}
1873
1874fn handle_search(
1875 query: &str,
1876 robots: RobotsPolicy,
1877 limit: usize,
1878 json: bool,
1879) -> Result<(), CliError> {
1880 let data =
1881 block_on_browser_timeout(crate::scrape_local::search_http(query, robots, limit), 0)?;
1882 emit_ok(data, json, |d| {
1883 println!(
1884 "ok search count={}",
1885 d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)
1886 );
1887 })
1888}
1889
1890fn handle_parse(path: &Path, json: bool) -> Result<(), CliError> {
1891 let data = crate::scrape_local::parse_file(path)?;
1892 emit_ok(data, json, |d| {
1893 println!(
1894 "ok parse path={}",
1895 d.get("path").and_then(|v| v.as_str()).unwrap_or("")
1896 );
1897 })
1898}
1899
1900fn handle_mitm(action: MitmAction, json: bool) -> Result<(), CliError> {
1901 let data = match action {
1902 MitmAction::Status => crate::mitm_local::status()?,
1903 MitmAction::List { host, limit } => crate::mitm_local::list(host.as_deref(), limit)?,
1904 MitmAction::Get { id } => crate::mitm_local::get(id)?,
1905 MitmAction::Har { out } => crate::mitm_local::export_har(&out)?,
1906 MitmAction::Export { format, out } => {
1907 if format.eq_ignore_ascii_case("har") {
1908 crate::mitm_local::export_har(&out)?
1909 } else {
1910 let path = crate::mitm_local::default_capture_path()?;
1911 let cap = crate::mitm_local::MitmCapture::load(&path, true)?;
1912 let body = serde_json::to_vec_pretty(&serde_json::json!({
1913 "count": cap.items.len(),
1914 "items": cap.items,
1915 }))
1916 .map_err(|e| CliError::new(ErrorKind::Data, format!("export: {e}")))?;
1917 std::fs::write(&out, body)
1918 .map_err(|e| CliError::new(ErrorKind::Io, format!("export write: {e}")))?;
1919 serde_json::json!({
1920 "path": out.display().to_string(),
1921 "format": format,
1922 "count": cap.items.len(),
1923 })
1924 }
1925 }
1926 MitmAction::Domains => crate::mitm_local::domains()?,
1927 MitmAction::Apis { kind } => crate::mitm_local::apis(kind.as_deref())?,
1928 MitmAction::InitCa => crate::mitm_local::ensure_ca()?,
1929 MitmAction::Start { seconds } => {
1930 crate::browser::block_on_browser(crate::mitm_local::start_proxy_oneshot(seconds))?
1931 }
1932 };
1933 emit_ok(data, json, |d| println!("ok mitm {d}"))
1934}
1935
1936fn handle_workflow(action: WorkflowAction, json: bool) -> Result<(), CliError> {
1937 let data = match action {
1938 WorkflowAction::Run { manifest, journal } => {
1939 crate::workflow_local::workflow_run(&manifest, journal.as_deref())?
1940 }
1941 WorkflowAction::Resume { manifest, journal } => {
1942 crate::workflow_local::workflow_resume(&manifest, journal.as_deref())?
1943 }
1944 WorkflowAction::Status { journal, name } => {
1945 crate::workflow_local::workflow_status(journal.as_deref(), name.as_deref())?
1946 }
1947 };
1948 emit_ok(data, json, |d| println!("ok workflow {d}"))
1949}
1950
1951fn handle_config(action: ConfigAction, json: bool) -> Result<(), CliError> {
1952 let data = match action {
1953 ConfigAction::Path => crate::xdg::paths_snapshot()?,
1954 ConfigAction::Init => crate::xdg::init_layout()?,
1955 ConfigAction::Show => crate::xdg::config_get(None)?,
1956 ConfigAction::Set { key, value } => crate::xdg::config_set(&key, &value)?,
1957 ConfigAction::Get { key } => crate::xdg::config_get(key.as_deref())?,
1958 };
1959 emit_ok(data, json, |d| println!("ok config {d}"))
1960}
1961
1962#[allow(clippy::too_many_arguments)]
1963fn handle_emulate(
1964 life: &Lifecycle,
1965 user_agent: Option<&str>,
1966 locale: Option<&str>,
1967 timezone: Option<&str>,
1968 offline: bool,
1969 latitude: Option<f64>,
1970 longitude: Option<f64>,
1971 media: Option<&str>,
1972 network_conditions: Option<&str>,
1973 cpu_throttling_rate: Option<f64>,
1974 color_scheme: Option<&str>,
1975 extra_headers: Option<&str>,
1976 viewport: Option<&str>,
1977 capture: CaptureOpts,
1978 timeout_secs: u64,
1979 json: bool,
1980) -> Result<(), CliError> {
1981 let ua = user_agent.map(|s| s.to_string());
1982 let loc = locale.map(|s| s.to_string());
1983 let tz = timezone.map(|s| s.to_string());
1984 let media = media.map(|s| s.to_string());
1985 let net = network_conditions.map(|s| s.to_string());
1986 let scheme = color_scheme.map(|s| s.to_string());
1987 let headers = extra_headers.map(|s| s.to_string());
1988 let vp = viewport.map(|s| s.to_string());
1989 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1990 let v = session
1991 .emulate(
1992 ua.as_deref(),
1993 loc.as_deref(),
1994 tz.as_deref(),
1995 offline,
1996 latitude,
1997 longitude,
1998 media.as_deref(),
1999 net.as_deref(),
2000 cpu_throttling_rate,
2001 scheme.as_deref(),
2002 headers.as_deref(),
2003 vp.as_deref(),
2004 )
2005 .await?;
2006 Ok((session, v))
2007 })?;
2008 emit_ok(data, json, |_| println!("ok emulate"))
2009}
2010
2011#[allow(clippy::too_many_arguments)]
2012fn handle_resize(
2013 life: &Lifecycle,
2014 width: i32,
2015 height: i32,
2016 scale: f64,
2017 mobile: bool,
2018 capture: CaptureOpts,
2019 timeout_secs: u64,
2020 json: bool,
2021) -> Result<(), CliError> {
2022 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2023 let v = session.resize(width, height, scale, mobile).await?;
2024 Ok((session, v))
2025 })?;
2026 emit_ok(data, json, |_| println!("ok resize {width}x{height}"))
2027}
2028
2029fn handle_perf(
2030 life: &Lifecycle,
2031 action: PerfAction,
2032 capture: CaptureOpts,
2033 timeout_secs: u64,
2034 json: bool,
2035) -> Result<(), CliError> {
2036 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2037 let v = match action {
2038 PerfAction::Start {
2039 path,
2040 reload,
2041 auto_stop,
2042 } => {
2043 session
2044 .perf_start(path.as_deref(), reload, auto_stop)
2045 .await?
2046 }
2047 PerfAction::Stop { path } => session.perf_stop(path.as_deref()).await?,
2048 PerfAction::Insight {
2049 name,
2050 insight_set_id,
2051 insight_name,
2052 } => {
2053 let resolved = insight_name.or(name);
2054 session
2055 .perf_insight(resolved.as_deref(), insight_set_id.as_deref())
2056 .await?
2057 }
2058 };
2059 Ok((session, v))
2060 })?;
2061 emit_ok(data, json, |d| println!("ok perf {d}"))
2062}
2063
2064pub(crate) fn lighthouse_to_value(
2066 url: &str,
2067 out_dir: Option<&Path>,
2068 device: &str,
2069 mode: &str,
2070 lighthouse_path: Option<&Path>,
2071) -> Result<serde_json::Value, CliError> {
2072 let bin = lighthouse_path
2073 .map(|p| p.display().to_string())
2074 .unwrap_or_else(|| "lighthouse".to_string());
2075 let out = out_dir.map(|p| p.to_path_buf()).unwrap_or_else(|| {
2076 crate::xdg::cache_dir()
2077 .unwrap_or_else(|_| std::env::temp_dir().join("browser-automation-cli"))
2078 .join("lighthouse")
2079 });
2080 std::fs::create_dir_all(&out)
2081 .map_err(|e| CliError::new(ErrorKind::Io, format!("lighthouse out-dir: {e}")))?;
2082 let form_factor = if device.eq_ignore_ascii_case("mobile") {
2083 "mobile"
2084 } else {
2085 "desktop"
2086 };
2087 let mode_norm = if mode.eq_ignore_ascii_case("snapshot") {
2088 "snapshot"
2089 } else {
2090 "navigation"
2091 };
2092 let html_path = out.join("report.html");
2094 let json_path = out.join("report.json");
2095 let output = std::process::Command::new(&bin)
2096 .arg(url)
2097 .arg("--quiet")
2098 .arg("--output=html")
2099 .arg("--output=json")
2100 .arg(format!("--output-path={}", out.join("report").display()))
2101 .arg(format!("--form-factor={form_factor}"))
2102 .arg("--chrome-flags=--headless=new")
2103 .arg("--only-categories=accessibility,seo,best-practices")
2104 .output()
2105 .map_err(|e| {
2106 CliError::with_suggestion(
2107 ErrorKind::Unavailable,
2108 format!("lighthouse spawn failed: {e}"),
2109 "Install lighthouse (npm i -g lighthouse) or pass --lighthouse-path; doctor reports binary presence",
2110 )
2111 })?;
2112 if !output.status.success() {
2113 let stderr = String::from_utf8_lossy(&output.stderr);
2114 return Err(CliError::with_suggestion(
2115 ErrorKind::Software,
2116 format!("lighthouse exited non-zero: {stderr}"),
2117 "Check URL and lighthouse install",
2118 ));
2119 }
2120 let report_html = if html_path.exists() {
2122 html_path.clone()
2123 } else if out.join("report.report.html").exists() {
2124 out.join("report.report.html")
2125 } else {
2126 html_path.clone()
2127 };
2128 let report_json = if json_path.exists() {
2129 json_path.clone()
2130 } else if out.join("report.report.json").exists() {
2131 out.join("report.report.json")
2132 } else {
2133 out.join("report.json")
2135 };
2136
2137 let mut scores = Vec::new();
2138 let mut passed_audits = 0u64;
2139 let mut failed_audits = 0u64;
2140 if report_json.exists() {
2141 if let Ok(raw) = std::fs::read_to_string(&report_json) {
2142 if let Ok(lhr) = serde_json::from_str::<serde_json::Value>(&raw) {
2143 if let Some(cats) = lhr.get("categories").and_then(|c| c.as_object()) {
2144 for (id, cat) in cats {
2145 scores.push(serde_json::json!({
2146 "id": id,
2147 "title": cat.get("title").and_then(|t| t.as_str()).unwrap_or(id),
2148 "score": cat.get("score"),
2149 }));
2150 }
2151 }
2152 if let Some(audits) = lhr.get("audits").and_then(|a| a.as_object()) {
2153 for a in audits.values() {
2154 if let Some(sc) = a.get("score").and_then(|s| s.as_f64()) {
2155 if sc < 1.0 {
2156 failed_audits += 1;
2157 } else {
2158 passed_audits += 1;
2159 }
2160 }
2161 }
2162 }
2163 }
2164 }
2165 }
2166
2167 Ok(serde_json::json!({
2168 "lighthouse": true,
2169 "url": url,
2170 "device": form_factor,
2171 "mode": mode_norm,
2172 "binary": bin,
2173 "out_dir": out.to_string_lossy(),
2174 "reports": {
2175 "html": report_html.to_string_lossy(),
2176 "json": report_json.to_string_lossy(),
2177 },
2178 "scores": scores,
2179 "passed_audits": passed_audits,
2180 "failed_audits": failed_audits,
2181 }))
2182}
2183
2184fn handle_lighthouse(
2185 url: &str,
2186 out_dir: Option<&Path>,
2187 device: &str,
2188 mode: &str,
2189 lighthouse_path: Option<&Path>,
2190 json: bool,
2191) -> Result<(), CliError> {
2192 let data = lighthouse_to_value(url, out_dir, device, mode, lighthouse_path)?;
2193 emit_ok(data, json, |d| {
2194 println!(
2195 "ok lighthouse report={}",
2196 d.pointer("/reports/html")
2197 .and_then(|v| v.as_str())
2198 .unwrap_or("")
2199 );
2200 })
2201}
2202
2203fn handle_screencast(
2204 life: &Lifecycle,
2205 action: ScreencastAction,
2206 capture: CaptureOpts,
2207 timeout_secs: u64,
2208 json: bool,
2209) -> Result<(), CliError> {
2210 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2211 let v = match action {
2212 ScreencastAction::Start { path } => session.screencast_start(path.as_deref()).await?,
2213 ScreencastAction::Stop { path } => session.screencast_stop(path.as_deref()).await?,
2214 };
2215 Ok((session, v))
2216 })?;
2217 emit_ok(data, json, |d| println!("ok screencast {d}"))
2218}
2219
2220fn handle_heap(
2221 life: &Lifecycle,
2222 action: HeapAction,
2223 capture: CaptureOpts,
2224 timeout_secs: u64,
2225 json: bool,
2226) -> Result<(), CliError> {
2227 match action {
2228 HeapAction::Take { path } => {
2229 let data =
2230 with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2231 let v = session.heap_take(&path).await?;
2232 Ok((session, v))
2233 })?;
2234 emit_ok(data, json, |d| {
2235 println!(
2236 "ok heap take path={}",
2237 d.get("path").and_then(|v| v.as_str()).unwrap_or("")
2238 );
2239 })
2240 }
2241 HeapAction::Close { path } => {
2242 let data = OneShotSession::heap_close(&path)?;
2243 emit_ok(data, json, |_| {
2244 println!("ok heap close path={}", path.display())
2245 })
2246 }
2247 HeapAction::Compare {
2248 base,
2249 current,
2250 class_index,
2251 } => {
2252 let mut data = OneShotSession::heap_compare(&base, ¤t)?;
2253 if let Some(ci) = class_index {
2254 if let Some(obj) = data.as_object_mut() {
2255 obj.insert("class_index".into(), serde_json::json!(ci));
2256 }
2257 }
2258 emit_ok(data, json, |d| println!("ok heap compare {d}"))
2259 }
2260 HeapAction::Summary { path } => {
2261 let data = OneShotSession::heap_file_summary(&path)?;
2262 emit_ok(data, json, |d| println!("ok heap summary {d}"))
2263 }
2264 HeapAction::Details {
2265 path,
2266 filter_name,
2267 page_idx,
2268 page_size,
2269 } => {
2270 validate_heap_filter_name(filter_name.as_deref())?;
2271 let mut data = OneShotSession::heap_details(&path)?;
2272 paginate_filter_json(
2273 &mut data,
2274 "classes",
2275 filter_name.as_deref(),
2276 page_idx,
2277 page_size,
2278 );
2279 emit_ok(data, json, |d| println!("ok heap details {d}"))
2280 }
2281 HeapAction::DupStrings {
2282 path,
2283 page_idx,
2284 page_size,
2285 } => {
2286 let mut data = OneShotSession::heap_dup_strings(&path)?;
2287 paginate_filter_json(&mut data, "strings", None, page_idx, page_size);
2288 emit_ok(data, json, |d| println!("ok heap dup-strings {d}"))
2289 }
2290 HeapAction::ClassNodes {
2291 path,
2292 id,
2293 filter_name,
2294 page_idx,
2295 page_size,
2296 } => {
2297 validate_heap_filter_name(filter_name.as_deref())?;
2298 let mut data = OneShotSession::heap_class_nodes(&path, id)?;
2299 paginate_filter_json(
2300 &mut data,
2301 "nodes",
2302 filter_name.as_deref(),
2303 page_idx,
2304 page_size,
2305 );
2306 emit_ok(data, json, |d| println!("ok heap class-nodes {d}"))
2307 }
2308 HeapAction::Dominators { path, node } => {
2309 let data = OneShotSession::heap_node_op(&path, node, "dominators")?;
2310 emit_ok(data, json, |d| println!("ok heap dominators {d}"))
2311 }
2312 HeapAction::Edges {
2313 path,
2314 node,
2315 page_idx,
2316 page_size,
2317 } => {
2318 let mut data = OneShotSession::heap_node_op(&path, node, "edges")?;
2319 paginate_filter_json(&mut data, "edges", None, page_idx, page_size);
2320 emit_ok(data, json, |d| println!("ok heap edges {d}"))
2321 }
2322 HeapAction::Retainers {
2323 path,
2324 node,
2325 page_idx,
2326 page_size,
2327 } => {
2328 let mut data = OneShotSession::heap_node_op(&path, node, "retainers")?;
2329 paginate_filter_json(&mut data, "retainers", None, page_idx, page_size);
2330 emit_ok(data, json, |d| println!("ok heap retainers {d}"))
2331 }
2332 HeapAction::Paths {
2333 path,
2334 node,
2335 max_depth,
2336 max_nodes,
2337 max_siblings,
2338 } => {
2339 let data = crate::native::heap_snapshot::node_op_with_limits(
2340 &path,
2341 node,
2342 "paths",
2343 max_depth as usize,
2344 max_siblings.unwrap_or(32) as usize,
2345 max_nodes.unwrap_or(200) as usize,
2346 200,
2347 )
2348 .map_err(|e| {
2349 CliError::with_suggestion(
2350 ErrorKind::Io,
2351 e,
2352 "Pass a valid .heapsnapshot path and node id",
2353 )
2354 })?;
2355 emit_ok(data, json, |d| println!("ok heap paths {d}"))
2356 }
2357 HeapAction::ObjectDetails { path, node } => {
2358 let data = OneShotSession::heap_object_details(&path, node)?;
2359 emit_ok(data, json, |d| println!("ok heap object-details {d}"))
2360 }
2361 }
2362}
2363
2364const HEAP_FILTER_NAME_ENUM: &[&str] = &[
2366 "objectsRetainedByDetachedDomNodes",
2367 "objectsRetainedByConsole",
2368 "objectsRetainedByEventHandlers",
2369 "objectsRetainedByContexts",
2370];
2371
2372fn validate_heap_filter_name(filter_name: Option<&str>) -> Result<(), CliError> {
2373 let Some(f) = filter_name else {
2374 return Ok(());
2375 };
2376 if f.starts_with("objectsRetained")
2378 && !HEAP_FILTER_NAME_ENUM
2379 .iter()
2380 .any(|e| e.eq_ignore_ascii_case(f))
2381 {
2382 return Err(CliError::with_suggestion(
2383 ErrorKind::Usage,
2384 format!("invalid heap --filter-name enum: {f}"),
2385 "Use objectsRetainedByDetachedDomNodes|objectsRetainedByConsole|objectsRetainedByEventHandlers|objectsRetainedByContexts or free-text substring",
2386 ));
2387 }
2388 Ok(())
2389}
2390
2391fn paginate_filter_json(
2393 data: &mut serde_json::Value,
2394 array_key: &str,
2395 filter_name: Option<&str>,
2396 page_idx: Option<usize>,
2397 page_size: Option<usize>,
2398) {
2399 let key = {
2400 if data.get(array_key).and_then(|v| v.as_array()).is_some() {
2401 array_key.to_string()
2402 } else {
2403 let mut found = None;
2404 for alt in ["items", "results", "list"] {
2405 if data.get(alt).and_then(|v| v.as_array()).is_some() {
2406 found = Some(alt.to_string());
2407 break;
2408 }
2409 }
2410 match found {
2411 Some(k) => k,
2412 None => return,
2413 }
2414 }
2415 };
2416
2417 let is_enum_filter = filter_name
2418 .map(|f| {
2419 HEAP_FILTER_NAME_ENUM
2420 .iter()
2421 .any(|e| e.eq_ignore_ascii_case(f))
2422 })
2423 .unwrap_or(false);
2424
2425 if is_enum_filter {
2426 if let Some(obj) = data.as_object_mut() {
2429 obj.insert(
2430 "filter_name".into(),
2431 serde_json::json!(filter_name.unwrap()),
2432 );
2433 obj.insert(
2434 "filter_applied".into(),
2435 serde_json::json!("enum_recorded_offline_not_recomputed"),
2436 );
2437 }
2438 }
2439
2440 let Some(arr) = data.get_mut(&key).and_then(|v| v.as_array_mut()) else {
2441 return;
2442 };
2443 if let Some(f) = filter_name {
2444 if !is_enum_filter {
2445 let f_low = f.to_ascii_lowercase();
2446 arr.retain(|item| {
2447 item.get("name")
2448 .or_else(|| item.get("class_name"))
2449 .or_else(|| item.get("string"))
2450 .and_then(|v| v.as_str())
2451 .map(|s| s.to_ascii_lowercase().contains(&f_low))
2452 .unwrap_or(true)
2453 });
2454 }
2455 }
2456 let total = arr.len();
2457 let page = page_idx.unwrap_or(0);
2458 let size = page_size.unwrap_or(total.max(1));
2459 let start = page.saturating_mul(size).min(total);
2460 let end = (start + size).min(total);
2461 let page_items: Vec<serde_json::Value> = arr[start..end].to_vec();
2462 *arr = page_items;
2463 if let Some(obj) = data.as_object_mut() {
2464 obj.insert("total".into(), serde_json::json!(total));
2465 obj.insert("page_idx".into(), serde_json::json!(page));
2466 obj.insert("page_size".into(), serde_json::json!(size));
2467 }
2468}
2469
2470fn handle_extension(
2471 life: &Lifecycle,
2472 action: ExtensionAction,
2473 capture: CaptureOpts,
2474 timeout_secs: u64,
2475 json: bool,
2476) -> Result<(), CliError> {
2477 match action {
2478 ExtensionAction::List => {
2479 let data =
2480 with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2481 let v = session.extension_list().await?;
2482 Ok((session, v))
2483 })?;
2484 emit_ok(data, json, |d| println!("ok extension list {d}"))
2485 }
2486 ExtensionAction::Install { path } => {
2487 let path_s = path.display().to_string();
2488 let data = block_on_browser_timeout(
2489 async move {
2490 let mut session =
2491 OneShotSession::launch_with_extensions(capture, vec![path_s.clone()])
2492 .await?;
2493 if let Ok(mut ledger) = life.ledger.lock() {
2494 ledger.chrome_launched = true;
2495 ledger.chrome_pid = session.chrome_pid();
2496 }
2497 let mut listed = session.extension_list().await?;
2499 for _ in 0..20 {
2500 let count = listed.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
2501 if count > 0 {
2502 break;
2503 }
2504 tokio::time::sleep(std::time::Duration::from_millis(150)).await;
2505 listed = session.extension_list().await?;
2506 }
2507 let close = session.shutdown().await;
2508 if let Ok(mut ledger) = life.ledger.lock() {
2509 ledger.chrome_launched = false;
2510 ledger.chrome_pid = None;
2511 }
2512 close?;
2513 Ok(serde_json::json!({
2514 "installed_path": path_s,
2515 "load_extension": true,
2516 "targets": listed,
2517 "note": "one-shot: Chrome launched with --load-extension for this process only",
2518 }))
2519 },
2520 timeout_secs,
2521 )?;
2522 emit_ok(data, json, |d| println!("ok extension install {d}"))
2523 }
2524 ExtensionAction::Reload { id, path } => {
2525 let id = id.clone();
2526 let path_s = path.as_ref().map(|p| p.display().to_string());
2527 let data = block_on_browser_timeout(
2528 async move {
2529 let mut session = if let Some(p) = path_s {
2530 OneShotSession::launch_with_extensions(capture, vec![p]).await?
2531 } else {
2532 OneShotSession::launch_headless_with_capture(capture).await?
2533 };
2534 if let Ok(mut ledger) = life.ledger.lock() {
2535 ledger.chrome_launched = true;
2536 ledger.chrome_pid = session.chrome_pid();
2537 }
2538 let v = session.extension_reload(&id).await;
2539 let close = session.shutdown().await;
2540 if let Ok(mut ledger) = life.ledger.lock() {
2541 ledger.chrome_launched = false;
2542 ledger.chrome_pid = None;
2543 }
2544 close?;
2545 v
2546 },
2547 timeout_secs,
2548 )?;
2549 emit_ok(data, json, |d| println!("ok extension reload {d}"))
2550 }
2551 ExtensionAction::Trigger { id, path } => {
2552 let id = id.clone();
2553 let path_s = path.as_ref().map(|p| p.display().to_string());
2554 let data = block_on_browser_timeout(
2555 async move {
2556 let mut session = if let Some(p) = path_s {
2557 OneShotSession::launch_with_extensions(capture, vec![p]).await?
2558 } else {
2559 OneShotSession::launch_headless_with_capture(capture).await?
2560 };
2561 if let Ok(mut ledger) = life.ledger.lock() {
2562 ledger.chrome_launched = true;
2563 ledger.chrome_pid = session.chrome_pid();
2564 }
2565 let v = session.extension_trigger(&id).await;
2566 let close = session.shutdown().await;
2567 if let Ok(mut ledger) = life.ledger.lock() {
2568 ledger.chrome_launched = false;
2569 ledger.chrome_pid = None;
2570 }
2571 close?;
2572 v
2573 },
2574 timeout_secs,
2575 )?;
2576 emit_ok(data, json, |d| println!("ok extension trigger {d}"))
2577 }
2578 ExtensionAction::Uninstall { id } => {
2579 let data = serde_json::json!({
2581 "uninstalled": id,
2582 "persistent": false,
2583 "note": "one-shot CLI does not keep extensions across processes; omit path on next install",
2584 });
2585 emit_ok(data, json, |_| println!("ok extension uninstall id={id}"))
2586 }
2587 }
2588}
2589
2590fn handle_devtools3p(
2591 life: &Lifecycle,
2592 action: Devtools3pAction,
2593 capture: CaptureOpts,
2594 timeout_secs: u64,
2595 json: bool,
2596) -> Result<(), CliError> {
2597 match action {
2598 Devtools3pAction::List { url } => {
2599 let url = url.unwrap_or_else(|| "about:blank".into());
2600 let data =
2601 with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2602 if url != "about:blank" {
2603 let _ = session
2604 .goto(&url, crate::robots::RobotsPolicy::Ignore)
2605 .await?;
2606 }
2607 let v = session.devtools3p_list().await?;
2608 Ok((session, v))
2609 })?;
2610 emit_ok(data, json, |d| println!("ok devtools3p list {d}"))
2611 }
2612 Devtools3pAction::Exec { name, params, url } => {
2613 let url = url.unwrap_or_else(|| "about:blank".into());
2614 let params = params.clone();
2615 let name = name.clone();
2616 let data =
2617 with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2618 if url != "about:blank" {
2619 let _ = session
2620 .goto(&url, crate::robots::RobotsPolicy::Ignore)
2621 .await?;
2622 }
2623 let v = session.devtools3p_exec(&name, params.as_deref()).await?;
2624 Ok((session, v))
2625 })?;
2626 emit_ok(data, json, |d| println!("ok devtools3p exec {d}"))
2627 }
2628 }
2629}
2630
2631fn handle_webmcp(
2632 life: &Lifecycle,
2633 action: WebmcpAction,
2634 capture: CaptureOpts,
2635 timeout_secs: u64,
2636 json: bool,
2637) -> Result<(), CliError> {
2638 match action {
2639 WebmcpAction::List { url } => {
2640 let url = url.unwrap_or_else(|| "about:blank".into());
2641 let data =
2642 with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2643 if url != "about:blank" {
2644 let _ = session
2645 .goto(&url, crate::robots::RobotsPolicy::Ignore)
2646 .await?;
2647 }
2648 let v = session.webmcp_list().await?;
2649 Ok((session, v))
2650 })?;
2651 emit_ok(data, json, |d| println!("ok webmcp list {d}"))
2652 }
2653 WebmcpAction::Exec { name, input, url } => {
2654 let url = url.unwrap_or_else(|| "about:blank".into());
2655 let name = name.clone();
2656 let input = input.clone();
2657 let data =
2658 with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2659 if url != "about:blank" {
2660 let _ = session
2661 .goto(&url, crate::robots::RobotsPolicy::Ignore)
2662 .await?;
2663 }
2664 let v = session.webmcp_exec(&name, input.as_deref()).await?;
2665 Ok((session, v))
2666 })?;
2667 emit_ok(data, json, |d| println!("ok webmcp exec {d}"))
2668 }
2669 }
2670}
2671
2672fn handle_completions(shell: CompletionShell) -> Result<(), CliError> {
2673 use clap::CommandFactory;
2674 use clap_complete::{generate, shells};
2675 use std::io::Write;
2676
2677 let mut cmd = crate::cli::Cli::command();
2678 let bin = "browser-automation-cli";
2679 let mut out = std::io::stdout();
2680 match shell {
2681 CompletionShell::Bash => generate(shells::Bash, &mut cmd, bin, &mut out),
2682 CompletionShell::Zsh => generate(shells::Zsh, &mut cmd, bin, &mut out),
2683 CompletionShell::Fish => generate(shells::Fish, &mut cmd, bin, &mut out),
2684 CompletionShell::Elvish => generate(shells::Elvish, &mut cmd, bin, &mut out),
2685 CompletionShell::Powershell => generate(shells::PowerShell, &mut cmd, bin, &mut out),
2686 }
2687 let _ = out.flush();
2688 Ok(())
2689}