1#![allow(missing_docs)]
3
4mod meta;
5pub mod run;
6
7use std::path::Path;
8
9use serde_json::json;
10
11use crate::browser::{
12 block_on_browser_timeout, run_keys, run_press, run_scrape, run_type, run_view, run_write,
13 CaptureOpts, OneShotSession,
14};
15use crate::cli::{
16 AssertKind, BeforeUnloadAction, Cli, Commands, CompletionShell, ConfigAction, ConsoleAction,
17 CookieAction, Devtools3pAction, DialogAction, ExtensionAction, GrabFormat, HeapAction,
18 MitmAction, NetAction, PageAction, PerfAction, ScreencastAction, WebmcpAction, WorkflowAction,
19};
20use crate::envelope::{print_error_json, print_success_json};
21use crate::error::{CliError, ErrorKind};
22use crate::lifecycle::Lifecycle;
23use crate::robots::RobotsPolicy;
24
25pub fn dispatch(cli: Cli, life: &Lifecycle) -> i32 {
27 let json = cli.globals.json
28 || matches!(
29 &cli.command,
30 Commands::Doctor { json: true, .. } | Commands::Commands { json: true }
31 );
32 let capture = CaptureOpts {
33 console: cli.globals.capture_console,
34 network: cli.globals.capture_network,
35 };
36 let timeout_secs = cli.globals.timeout;
37 let artifacts = cli.globals.artifacts_dir.clone();
38 let category_memory = cli.globals.category_memory;
39 let category_extensions = cli.globals.category_extensions;
40 let category_third_party = cli.globals.category_third_party;
41 let category_webmcp = cli.globals.category_webmcp;
42 let experimental_screencast = cli.globals.experimental_screencast;
43 let experimental_vision = cli.globals.experimental_vision;
44 let json_steps = cli.globals.json_steps;
45 let robots =
46 match RobotsPolicy::from_flags(cli.globals.ignore_robots, cli.globals.i_accept_robots_risk)
47 {
48 Ok(p) => p,
49 Err(e) => {
50 life.finalize();
51 return emit_err(&e, json);
52 }
53 };
54
55 let code = match cli.command {
56 Commands::Doctor {
57 offline,
58 quick,
59 fix,
60 json: doc_json,
61 } => crate::doctor::run_doctor(crate::doctor::DoctorOptions {
62 offline,
63 quick,
64 fix,
65 json: json || doc_json,
66 }),
67 Commands::Commands { json: cmd_json } => match meta::list_commands(json || cmd_json) {
68 Ok(()) => 0,
69 Err(e) => emit_err(&e, json || cmd_json),
70 },
71 Commands::Schema { cmd, cmd_positional } => {
72 let resolved = cmd_positional.or(cmd).filter(|s| !s.trim().is_empty());
74 match resolved {
75 Some(c) => match meta::schema_for_cmd(&c, json) {
76 Ok(()) => 0,
77 Err(e) => emit_err(&e, json),
78 },
79 None => emit_err(
80 &CliError::with_suggestion(
81 ErrorKind::Usage,
82 "schema requires a command name",
83 "Use: browser-automation-cli schema run or schema --cmd run",
84 ),
85 json,
86 ),
87 }
88 },
89 Commands::Version => match handle_version(json) {
90 Ok(()) => 0,
91 Err(e) => emit_err(&e, json),
92 },
93 Commands::Goto {
94 url,
95 init_script,
96 handle_before_unload,
97 navigation_timeout_ms,
98 } => {
99 match handle_goto(
100 life,
101 &url,
102 robots,
103 capture,
104 timeout_secs,
105 json,
106 init_script.as_deref(),
107 handle_before_unload,
108 navigation_timeout_ms,
109 ) {
110 Ok(()) => 0,
111 Err(e) => emit_err(&e, json),
112 }
113 }
114 Commands::View {
115 verbose,
116 path,
117 allow_empty,
118 } => {
119 match handle_view(
120 life,
121 verbose,
122 path.as_deref(),
123 allow_empty,
124 capture,
125 timeout_secs,
126 json,
127 ) {
128 Ok(()) => 0,
129 Err(e) => emit_err(&e, json),
130 }
131 }
132 Commands::Press {
133 target,
134 dblclick,
135 include_snapshot,
136 } => {
137 match handle_press(
138 life,
139 &target,
140 dblclick,
141 include_snapshot,
142 capture,
143 timeout_secs,
144 json,
145 ) {
146 Ok(()) => 0,
147 Err(e) => emit_err(&e, json),
148 }
149 }
150 Commands::ClickAt {
151 x,
152 y,
153 dblclick,
154 include_snapshot,
155 } => {
156 if !experimental_vision {
157 let e = CliError::with_suggestion(
158 ErrorKind::Usage,
159 "click-at requires --experimental-vision",
160 crate::i18n::suggestion_key("vision_required", None),
161 );
162 emit_err(&e, json)
163 } else {
164 match handle_click_at(
165 life,
166 x,
167 y,
168 dblclick,
169 include_snapshot,
170 capture,
171 timeout_secs,
172 json,
173 ) {
174 Ok(()) => 0,
175 Err(e) => emit_err(&e, json),
176 }
177 }
178 }
179 Commands::Write {
180 target,
181 value,
182 include_snapshot,
183 } => {
184 match handle_write(
185 life,
186 &target,
187 &value,
188 include_snapshot,
189 capture,
190 timeout_secs,
191 json,
192 ) {
193 Ok(()) => 0,
194 Err(e) => emit_err(&e, json),
195 }
196 }
197 Commands::Keys {
198 key,
199 include_snapshot,
200 } => match handle_keys(life, &key, include_snapshot, capture, timeout_secs, json) {
201 Ok(()) => 0,
202 Err(e) => emit_err(&e, json),
203 },
204 Commands::Type {
205 target,
206 text,
207 clear,
208 submit,
209 focus_only,
210 } => match handle_type(
211 life,
212 target.as_deref(),
213 &text,
214 clear,
215 submit.as_deref(),
216 focus_only,
217 capture,
218 timeout_secs,
219 json,
220 ) {
221 Ok(()) => 0,
222 Err(e) => emit_err(&e, json),
223 },
224 Commands::Wait {
225 ms,
226 text,
227 selector,
228 state,
229 wait_timeout_ms,
230 include_snapshot,
231 } => {
232 match handle_wait(
233 life,
234 ms,
235 &text,
236 selector.as_deref(),
237 state.as_deref(),
238 wait_timeout_ms,
239 include_snapshot,
240 capture,
241 timeout_secs,
242 json,
243 ) {
244 Ok(()) => 0,
245 Err(e) => emit_err(&e, json),
246 }
247 }
248 Commands::Hover {
249 target,
250 include_snapshot,
251 } => match handle_hover(life, &target, include_snapshot, capture, timeout_secs, json) {
252 Ok(()) => 0,
253 Err(e) => emit_err(&e, json),
254 },
255 Commands::Drag {
256 from,
257 to,
258 include_snapshot,
259 } => {
260 match handle_drag(
261 life,
262 &from,
263 &to,
264 include_snapshot,
265 capture,
266 timeout_secs,
267 json,
268 ) {
269 Ok(()) => 0,
270 Err(e) => emit_err(&e, json),
271 }
272 }
273 Commands::FillForm {
274 json: fields_json,
275 include_snapshot,
276 } => {
277 match handle_fill_form(
278 life,
279 &fields_json,
280 include_snapshot,
281 capture,
282 timeout_secs,
283 json,
284 ) {
285 Ok(()) => 0,
286 Err(e) => emit_err(&e, json),
287 }
288 }
289 Commands::Upload {
290 target,
291 path,
292 include_snapshot,
293 } => {
294 match handle_upload(
295 life,
296 &target,
297 &path,
298 include_snapshot,
299 capture,
300 timeout_secs,
301 json,
302 ) {
303 Ok(()) => 0,
304 Err(e) => emit_err(&e, json),
305 }
306 }
307 Commands::Back => match handle_history(life, "back", capture, timeout_secs, json) {
308 Ok(()) => 0,
309 Err(e) => emit_err(&e, json),
310 },
311 Commands::Forward => match handle_history(life, "forward", capture, timeout_secs, json) {
312 Ok(()) => 0,
313 Err(e) => emit_err(&e, json),
314 },
315 Commands::Reload {
316 ignore_cache,
317 init_script,
318 handle_before_unload,
319 } => {
320 match handle_reload(
321 life,
322 ignore_cache,
323 init_script.as_deref(),
324 handle_before_unload,
325 capture,
326 timeout_secs,
327 json,
328 ) {
329 Ok(()) => 0,
330 Err(e) => emit_err(&e, json),
331 }
332 }
333 Commands::Eval {
334 expression,
335 args,
336 dialog_action,
337 file_path,
338 service_worker_id,
339 } => {
340 match handle_eval(
341 life,
342 &expression,
343 args.as_deref(),
344 dialog_action.as_deref(),
345 file_path.as_deref(),
346 service_worker_id.as_deref(),
347 capture,
348 timeout_secs,
349 json,
350 ) {
351 Ok(()) => 0,
352 Err(e) => emit_err(&e, json),
353 }
354 }
355 Commands::Grab {
356 path,
357 format,
358 full_page,
359 quality,
360 element,
361 } => match handle_grab(
362 life,
363 path.as_deref(),
364 format,
365 full_page,
366 quality,
367 element.as_deref(),
368 artifacts.as_deref(),
369 capture,
370 timeout_secs,
371 json,
372 ) {
373 Ok(()) => 0,
374 Err(e) => emit_err(&e, json),
375 },
376 Commands::PrintPdf { path, url } => {
377 match handle_print_pdf(
378 life,
379 path.as_deref(),
380 url.as_deref(),
381 robots,
382 capture,
383 timeout_secs,
384 json,
385 ) {
386 Ok(()) => 0,
387 Err(e) => emit_err(&e, json),
388 }
389 }
390 Commands::Monitor { action } => match handle_monitor(action, robots, timeout_secs, json) {
391 Ok(()) => 0,
392 Err(e) => emit_err(&e, json),
393 },
394 Commands::Run { script } => {
395 let flags = run::RunFlags::from_globals(
396 experimental_vision,
397 experimental_screencast,
398 category_memory,
399 category_extensions,
400 category_third_party,
401 category_webmcp,
402 json_steps,
403 );
404 match handle_run(life, &script, robots, capture, timeout_secs, json, flags) {
405 Ok(()) => 0,
406 Err(e) => emit_err(&e, json),
407 }
408 }
409 Commands::Exec { args } => {
410 let (args, json_from_trail) = peel_trailing_globals(&args);
413 let json = json || json_from_trail;
414 let flags = run::RunFlags::from_globals(
415 experimental_vision,
416 experimental_screencast,
417 category_memory,
418 category_extensions,
419 category_third_party,
420 category_webmcp,
421 json_steps,
422 );
423 match handle_exec(life, &args, robots, capture, timeout_secs, json, flags) {
424 Ok(()) => 0,
425 Err(e) => emit_err(&e, json),
426 }
427 }
428 Commands::Extract {
429 target,
430 attr,
431 llm,
432 question,
433 schema_json,
434 } => {
435 match handle_extract(
436 life,
437 &target,
438 attr.as_deref(),
439 llm,
440 question.as_deref(),
441 schema_json.as_deref(),
442 capture,
443 timeout_secs,
444 json,
445 ) {
446 Ok(()) => 0,
447 Err(e) => emit_err(&e, json),
448 }
449 }
450 Commands::Text { target } => {
451 match handle_text(life, &target, capture, timeout_secs, json) {
452 Ok(()) => 0,
453 Err(e) => emit_err(&e, json),
454 }
455 }
456 Commands::Scroll {
457 target,
458 delta_x,
459 delta_y,
460 } => match handle_scroll(
461 life,
462 target.as_deref(),
463 delta_x,
464 delta_y,
465 capture,
466 timeout_secs,
467 json,
468 ) {
469 Ok(()) => 0,
470 Err(e) => emit_err(&e, json),
471 },
472 Commands::Cookie { action } => {
473 match handle_cookie(life, action, capture, timeout_secs, json) {
474 Ok(()) => 0,
475 Err(e) => emit_err(&e, json),
476 }
477 }
478 Commands::Attr { target, name } => {
479 match handle_attr(life, &target, &name, capture, timeout_secs, json) {
480 Ok(()) => 0,
481 Err(e) => emit_err(&e, json),
482 }
483 }
484 Commands::Assert { kind } => match handle_assert(life, kind, capture, timeout_secs, json) {
485 Ok(()) => 0,
486 Err(e) => emit_err(&e, json),
487 },
488 Commands::Console { action } => {
489 match handle_console(life, action, capture, timeout_secs, json) {
490 Ok(()) => 0,
491 Err(e) => emit_err(&e, json),
492 }
493 }
494 Commands::Net { action } => match handle_net(life, action, capture, timeout_secs, json) {
495 Ok(()) => 0,
496 Err(e) => emit_err(&e, json),
497 },
498 Commands::Page { action } => match handle_page(life, action, capture, timeout_secs, json) {
499 Ok(()) => 0,
500 Err(e) => emit_err(&e, json),
501 },
502 Commands::Dialog { action } => {
503 match handle_dialog(life, action, capture, timeout_secs, json) {
504 Ok(()) => 0,
505 Err(e) => emit_err(&e, json),
506 }
507 }
508 Commands::Scrape {
509 url,
510 format,
511 engine,
512 only_main_content,
513 webhook_url,
514 } => {
515 match handle_scrape(
516 life,
517 &url,
518 robots,
519 capture,
520 timeout_secs,
521 json,
522 &format,
523 &engine,
524 only_main_content,
525 webhook_url.as_deref(),
526 ) {
527 Ok(()) => 0,
528 Err(e) => emit_err(&e, json),
529 }
530 }
531 Commands::BatchScrape {
532 urls_file,
533 format,
534 concurrency,
535 engine,
536 } => match handle_batch_scrape(
537 life,
538 &urls_file,
539 robots,
540 capture,
541 timeout_secs,
542 &format,
543 concurrency,
544 &engine,
545 json,
546 ) {
547 Ok(()) => 0,
548 Err(e) => emit_err(&e, json),
549 },
550 Commands::Crawl {
551 url,
552 limit,
553 max_depth,
554 format,
555 same_host,
556 engine,
557 } => match handle_crawl(
558 life,
559 &url,
560 robots,
561 capture,
562 timeout_secs,
563 limit,
564 max_depth,
565 &format,
566 same_host,
567 &engine,
568 json,
569 ) {
570 Ok(()) => 0,
571 Err(e) => emit_err(&e, json),
572 },
573 Commands::Map {
574 url,
575 limit,
576 max_depth,
577 } => match handle_map(&url, robots, limit, max_depth, json) {
578 Ok(()) => 0,
579 Err(e) => emit_err(&e, json),
580 },
581 Commands::Search { query, limit } => match handle_search(&query, robots, limit, json) {
582 Ok(()) => 0,
583 Err(e) => emit_err(&e, json),
584 },
585 Commands::Parse { path, redact_pii } => match handle_parse(&path, redact_pii, json) {
586 Ok(()) => 0,
587 Err(e) => emit_err(&e, json),
588 },
589 Commands::Qr { action } => match handle_qr(action, json) {
590 Ok(()) => 0,
591 Err(e) => emit_err(&e, json),
592 },
593 Commands::FindPaths {
594 pattern,
595 paths,
596 extension,
597 hidden,
598 no_ignore,
599 max_depth,
600 entry_type,
601 limit,
602 glob,
603 } => match handle_find_paths(
604 pattern.as_deref(),
605 &paths,
606 extension.as_deref(),
607 hidden,
608 no_ignore,
609 max_depth,
610 entry_type.as_deref(),
611 limit,
612 glob.as_deref(),
613 json,
614 ) {
615 Ok(()) => 0,
616 Err(e) => emit_err(&e, json),
617 },
618 Commands::SgScan { paths, limit } => match handle_sg_scan(&paths, limit, json) {
619 Ok(()) => 0,
620 Err(e) => emit_err(&e, json),
621 },
622 Commands::SgRewrite { paths, apply } => match handle_sg_rewrite(&paths, apply, json) {
623 Ok(()) => 0,
624 Err(e) => emit_err(&e, json),
625 },
626 Commands::SheetWrite { input, out, sheet } => {
627 match handle_sheet_write(&input, &out, &sheet, json) {
628 Ok(()) => 0,
629 Err(e) => emit_err(&e, json),
630 }
631 }
632 Commands::Mitm { action } => match handle_mitm(action, json) {
633 Ok(()) => 0,
634 Err(e) => emit_err(&e, json),
635 },
636 Commands::Workflow { action } => match handle_workflow(action, json) {
637 Ok(()) => 0,
638 Err(e) => emit_err(&e, json),
639 },
640 Commands::Config { action } => match handle_config(action, json) {
641 Ok(()) => 0,
642 Err(e) => emit_err(&e, json),
643 },
644 Commands::Emulate {
645 user_agent,
646 locale,
647 timezone,
648 offline,
649 latitude,
650 longitude,
651 media,
652 network_conditions,
653 cpu_throttling_rate,
654 color_scheme,
655 extra_headers,
656 viewport,
657 } => match handle_emulate(
658 life,
659 user_agent.as_deref(),
660 locale.as_deref(),
661 timezone.as_deref(),
662 offline,
663 latitude,
664 longitude,
665 media.as_deref(),
666 network_conditions.as_deref(),
667 cpu_throttling_rate,
668 color_scheme.as_deref(),
669 extra_headers.as_deref(),
670 viewport.as_deref(),
671 capture,
672 timeout_secs,
673 json,
674 ) {
675 Ok(()) => 0,
676 Err(e) => emit_err(&e, json),
677 },
678 Commands::Resize {
679 width,
680 height,
681 scale,
682 mobile,
683 } => match handle_resize(
684 life,
685 width,
686 height,
687 scale,
688 mobile,
689 capture,
690 timeout_secs,
691 json,
692 ) {
693 Ok(()) => 0,
694 Err(e) => emit_err(&e, json),
695 },
696 Commands::Perf { action } => match handle_perf(life, action, capture, timeout_secs, json) {
697 Ok(()) => 0,
698 Err(e) => emit_err(&e, json),
699 },
700 Commands::Lighthouse {
701 url,
702 out_dir,
703 device,
704 mode,
705 lighthouse_path,
706 } => match handle_lighthouse(
707 &url,
708 out_dir.as_deref(),
709 &device,
710 &mode,
711 lighthouse_path.as_deref(),
712 json,
713 ) {
714 Ok(()) => 0,
715 Err(e) => emit_err(&e, json),
716 },
717 Commands::Screencast { action } => {
718 if !experimental_screencast {
719 emit_err(
720 &CliError::with_suggestion(
721 ErrorKind::Usage,
722 "screencast requires --experimental-screencast",
723 "Pass --experimental-screencast on the same invocation",
724 ),
725 json,
726 )
727 } else {
728 match handle_screencast(life, action, capture, timeout_secs, json) {
729 Ok(()) => 0,
730 Err(e) => emit_err(&e, json),
731 }
732 }
733 }
734 Commands::Heap { action } => {
735 let deep = !matches!(
736 &action,
737 HeapAction::Take { .. } | HeapAction::Summary { .. } | HeapAction::Close { .. }
738 );
739 if deep && !category_memory {
740 emit_err(
741 &CliError::with_suggestion(
742 ErrorKind::Usage,
743 "deep heap tools require --category-memory",
744 "Pass --category-memory (heap take/summary/close work without deep graph ops)",
745 ),
746 json,
747 )
748 } else {
749 match handle_heap(life, action, capture, timeout_secs, json) {
750 Ok(()) => 0,
751 Err(e) => emit_err(&e, json),
752 }
753 }
754 }
755 Commands::Extension { action } => {
756 if !category_extensions {
757 emit_err(
758 &CliError::with_suggestion(
759 ErrorKind::Usage,
760 "extension tools require --category-extensions",
761 "Pass --category-extensions on the same invocation",
762 ),
763 json,
764 )
765 } else {
766 match handle_extension(life, action, capture, timeout_secs, json) {
767 Ok(()) => 0,
768 Err(e) => emit_err(&e, json),
769 }
770 }
771 }
772 Commands::Devtools3p { action } => {
773 if !category_third_party {
774 emit_err(
775 &CliError::with_suggestion(
776 ErrorKind::Usage,
777 "devtools3p requires --category-third-party",
778 "Pass --category-third-party on the same invocation",
779 ),
780 json,
781 )
782 } else {
783 match handle_devtools3p(life, action, capture, timeout_secs, json) {
784 Ok(()) => 0,
785 Err(e) => emit_err(&e, json),
786 }
787 }
788 }
789 Commands::Webmcp { action } => {
790 if !category_webmcp {
791 emit_err(
792 &CliError::with_suggestion(
793 ErrorKind::Usage,
794 "webmcp requires --category-webmcp",
795 "Pass --category-webmcp on the same invocation",
796 ),
797 json,
798 )
799 } else {
800 match handle_webmcp(life, action, capture, timeout_secs, json) {
801 Ok(()) => 0,
802 Err(e) => emit_err(&e, json),
803 }
804 }
805 }
806 Commands::Completions { shell } => match handle_completions(shell) {
807 Ok(()) => 0,
808 Err(e) => emit_err(&e, json),
809 },
810 };
811
812 life.finalize();
813 code
814}
815
816fn handle_version(json: bool) -> Result<(), CliError> {
817 let data = serde_json::json!({
818 "name": "browser-automation-cli",
819 "version": env!("CARGO_PKG_VERSION"),
820 });
821 emit_ok(data, json, |d| {
822 println!(
823 "{}",
824 d.get("version").and_then(|v| v.as_str()).unwrap_or("")
825 );
826 })
827}
828
829fn emit_ok<F>(data: serde_json::Value, json: bool, text: F) -> Result<(), CliError>
830where
831 F: FnOnce(&serde_json::Value),
832{
833 if json {
834 print_success_json(data)?;
835 } else {
836 text(&data);
837 }
838 Ok(())
839}
840
841fn emit_err(err: &CliError, json: bool) -> i32 {
842 let localized = crate::i18n::localize_error_suggestion(err);
843 if json {
844 let _ = print_error_json(&localized);
845 } else {
846 eprintln!("error: {localized}");
847 if let Some(s) = localized.suggestion() {
848 eprintln!("suggestion: {s}");
849 }
850 }
851 localized.exit_code() as i32
852}
853
854fn peel_trailing_globals(args: &[String]) -> (Vec<String>, bool) {
856 let mut json = false;
857 let mut out = Vec::with_capacity(args.len());
858 for a in args {
859 match a.as_str() {
860 "--json" => json = true,
861 other => out.push(other.to_string()),
862 }
863 }
864 (out, json)
865}
866
867#[allow(clippy::too_many_arguments)]
868fn handle_goto(
869 life: &Lifecycle,
870 url: &str,
871 robots: RobotsPolicy,
872 capture: CaptureOpts,
873 timeout_secs: u64,
874 json: bool,
875 init_script: Option<&str>,
876 handle_before_unload: Option<BeforeUnloadAction>,
877 navigation_timeout_ms: Option<u64>,
878) -> Result<(), CliError> {
879 let init = init_script.map(|s| s.to_string());
880 let url_owned = url.to_string();
881 let beforeunload = handle_before_unload.map(|a| a.as_str());
882 let data = block_on_browser_timeout(
883 crate::browser::run_goto_with_options(
884 life,
885 &url_owned,
886 capture,
887 robots,
888 init.as_deref(),
889 beforeunload,
890 navigation_timeout_ms,
891 ),
892 timeout_secs,
893 )?;
894 emit_ok(data, json, |d| {
895 let u = d.get("url").and_then(|v| v.as_str()).unwrap_or(url);
896 let t = d.get("title").and_then(|v| v.as_str()).unwrap_or("");
897 println!("ok url={u} title={t}");
898 })
899}
900
901fn handle_view(
902 life: &Lifecycle,
903 verbose: bool,
904 path: Option<&Path>,
905 allow_empty: bool,
906 capture: CaptureOpts,
907 timeout_secs: u64,
908 json: bool,
909) -> Result<(), CliError> {
910 let path_owned = path.map(|p| p.to_path_buf());
911 let data = block_on_browser_timeout(
912 async move {
913 let mut data = run_view(life, verbose, capture).await?;
914 let tree = data.get("tree").and_then(|v| v.as_str()).unwrap_or("");
915 let empty = tree.contains("empty page")
916 || data
917 .get("url")
918 .and_then(|v| v.as_str())
919 .is_some_and(|u| u == "about:blank");
920 if empty && !allow_empty {
921 return Err(CliError::with_suggestion(
922 ErrorKind::Usage,
923 "view returned empty page (no content); refuse silent success",
924 "Navigate with goto first, or pass --allow-empty for blank snapshots",
925 ));
926 }
927 if let Some(obj) = data.as_object_mut() {
928 obj.insert("empty".into(), serde_json::json!(empty));
929 }
930 if let Some(p) = path_owned.as_ref() {
931 if let Some(parent) = p.parent() {
932 if !parent.as_os_str().is_empty() {
933 std::fs::create_dir_all(parent).map_err(|e| {
934 CliError::new(ErrorKind::Io, format!("view --path mkdir: {e}"))
935 })?;
936 }
937 }
938 let tree = data.get("tree").and_then(|v| v.as_str()).unwrap_or("");
939 std::fs::write(p, tree.as_bytes())
940 .map_err(|e| CliError::new(ErrorKind::Io, format!("view --path write: {e}")))?;
941 if let Some(obj) = data.as_object_mut() {
942 obj.insert(
943 "path".to_string(),
944 serde_json::Value::String(p.display().to_string()),
945 );
946 }
947 }
948 Ok(data)
949 },
950 timeout_secs,
951 )?;
952 emit_ok(data, json, |d| {
953 if let Some(p) = d.get("path").and_then(|v| v.as_str()) {
954 println!("ok view path={p}");
955 } else if let Some(tree) = d.get("tree").and_then(|v| v.as_str()) {
956 print!("{tree}");
957 if !tree.ends_with('\n') {
958 println!();
959 }
960 } else {
961 println!("ok view");
962 }
963 })
964}
965
966fn handle_press(
967 life: &Lifecycle,
968 target: &str,
969 dblclick: bool,
970 include_snapshot: bool,
971 capture: CaptureOpts,
972 timeout_secs: u64,
973 json: bool,
974) -> Result<(), CliError> {
975 let data = block_on_browser_timeout(
976 run_press(life, target, dblclick, include_snapshot, capture),
977 timeout_secs,
978 )?;
979 emit_ok(data, json, |_| {
980 println!("ok pressed={target} dblclick={dblclick}");
981 })
982}
983
984fn handle_write(
985 life: &Lifecycle,
986 target: &str,
987 value: &str,
988 include_snapshot: bool,
989 capture: CaptureOpts,
990 timeout_secs: u64,
991 json: bool,
992) -> Result<(), CliError> {
993 let data = block_on_browser_timeout(
994 run_write(life, target, value, include_snapshot, capture),
995 timeout_secs,
996 )?;
997 emit_ok(data, json, |_| {
998 println!("ok written={target} len={}", value.len());
999 })
1000}
1001
1002#[allow(clippy::too_many_arguments)]
1003fn handle_click_at(
1004 life: &Lifecycle,
1005 x: f64,
1006 y: f64,
1007 dblclick: bool,
1008 include_snapshot: bool,
1009 capture: CaptureOpts,
1010 timeout_secs: u64,
1011 json: bool,
1012) -> Result<(), CliError> {
1013 let data = block_on_browser_timeout(
1014 async move {
1015 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1016 if let Ok(mut ledger) = life.ledger.lock() {
1017 ledger.chrome_launched = true;
1018 ledger.chrome_pid = session.chrome_pid();
1019 }
1020 let _ = session
1021 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1022 .await?;
1023 let r = session.click_at(x, y, dblclick, include_snapshot).await;
1024 let close = session.shutdown().await;
1025 if let Ok(mut ledger) = life.ledger.lock() {
1026 ledger.chrome_launched = false;
1027 ledger.chrome_pid = None;
1028 }
1029 close?;
1030 r
1031 },
1032 timeout_secs,
1033 )?;
1034 emit_ok(data, json, |_| {
1035 println!("ok click-at x={x} y={y} dbl={dblclick}")
1036 })
1037}
1038
1039fn handle_keys(
1040 life: &Lifecycle,
1041 key: &str,
1042 include_snapshot: bool,
1043 capture: CaptureOpts,
1044 timeout_secs: u64,
1045 json: bool,
1046) -> Result<(), CliError> {
1047 let data =
1048 block_on_browser_timeout(run_keys(life, key, include_snapshot, capture), timeout_secs)?;
1049 emit_ok(data, json, |_| println!("ok key={key}"))
1050}
1051
1052#[allow(clippy::too_many_arguments)]
1053fn handle_type(
1054 life: &Lifecycle,
1055 target: Option<&str>,
1056 text: &str,
1057 clear: bool,
1058 submit: Option<&str>,
1059 focus_only: bool,
1060 capture: CaptureOpts,
1061 timeout_secs: u64,
1062 json: bool,
1063) -> Result<(), CliError> {
1064 if target.is_none() && !focus_only {
1065 return Err(CliError::with_suggestion(
1066 ErrorKind::Usage,
1067 "type requires a target or --focus-only",
1068 "Pass TARGET or --focus-only for the focused element",
1069 ));
1070 }
1071 let data = block_on_browser_timeout(
1072 run_type(life, target, text, clear, submit, focus_only, capture),
1073 timeout_secs,
1074 )?;
1075 let label = target.unwrap_or("(focused)");
1076 emit_ok(data, json, |_| {
1077 println!(
1078 "ok typed={label} len={} clear={clear} submit={submit:?} focus_only={focus_only}",
1079 text.len()
1080 );
1081 })
1082}
1083
1084#[allow(clippy::too_many_arguments)]
1085fn handle_wait(
1086 life: &Lifecycle,
1087 ms: u64,
1088 texts: &[String],
1089 selector: Option<&str>,
1090 state: Option<&str>,
1091 wait_timeout_ms: Option<u64>,
1092 include_snapshot: bool,
1093 capture: CaptureOpts,
1094 timeout_secs: u64,
1095 json: bool,
1096) -> Result<(), CliError> {
1097 let texts_owned = texts.to_vec();
1098 let selector_owned = selector.map(|s| s.to_string());
1099 let state_owned = state.map(|s| s.to_string());
1100 let wait_ms = wait_timeout_ms.or(if ms == 0 { None } else { Some(ms) });
1102 let data = block_on_browser_timeout(
1103 async move {
1104 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1105 if let Ok(mut ledger) = life.ledger.lock() {
1106 ledger.chrome_launched = true;
1107 ledger.chrome_pid = session.chrome_pid();
1108 }
1109 let r = if texts_owned.is_empty() {
1111 session
1112 .wait_for(
1113 wait_ms,
1114 None,
1115 selector_owned.as_deref(),
1116 state_owned.as_deref(),
1117 include_snapshot,
1118 )
1119 .await
1120 } else {
1121 let mut last_err = None;
1122 let mut ok = None;
1123 for t in &texts_owned {
1124 match session
1125 .wait_for(
1126 wait_ms,
1127 Some(t.as_str()),
1128 selector_owned.as_deref(),
1129 state_owned.as_deref(),
1130 false,
1131 )
1132 .await
1133 {
1134 Ok(v) => {
1135 ok = Some(v);
1136 break;
1137 }
1138 Err(e) => last_err = Some(e),
1139 }
1140 }
1141 match ok {
1142 Some(v) => {
1143 if include_snapshot {
1144 Ok(session
1145 .attach_snapshot_if(true, v)
1146 .await
1147 .unwrap_or_else(|_| serde_json::json!({"waited": true})))
1148 } else {
1149 Ok(v)
1150 }
1151 }
1152 None => Err(last_err.unwrap_or_else(|| {
1153 CliError::new(ErrorKind::Browser, "wait: no text matched")
1154 })),
1155 }
1156 };
1157 let close = session.shutdown().await;
1158 if let Ok(mut ledger) = life.ledger.lock() {
1159 ledger.chrome_launched = false;
1160 ledger.chrome_pid = None;
1161 }
1162 close?;
1163 r
1164 },
1165 timeout_secs,
1166 )?;
1167 emit_ok(data, json, |d| {
1168 println!("ok wait {}", d);
1169 })
1170}
1171
1172fn with_session_blank<F, Fut>(
1173 life: &Lifecycle,
1174 capture: CaptureOpts,
1175 timeout_secs: u64,
1176 f: F,
1177) -> Result<serde_json::Value, CliError>
1178where
1179 F: FnOnce(OneShotSession) -> Fut + Send + 'static,
1180 Fut: std::future::Future<Output = Result<(OneShotSession, serde_json::Value), CliError>> + Send,
1181{
1182 block_on_browser_timeout(
1183 async move {
1184 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1185 if let Ok(mut ledger) = life.ledger.lock() {
1186 ledger.chrome_launched = true;
1187 ledger.chrome_pid = session.chrome_pid();
1188 }
1189 let _ = session
1190 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1191 .await?;
1192 let (session, value) = f(session).await?;
1193 let close = session.shutdown().await;
1194 if let Ok(mut ledger) = life.ledger.lock() {
1195 ledger.chrome_launched = false;
1196 ledger.chrome_pid = None;
1197 }
1198 close?;
1199 Ok(value)
1200 },
1201 timeout_secs,
1202 )
1203}
1204
1205fn handle_hover(
1206 life: &Lifecycle,
1207 target: &str,
1208 include_snapshot: bool,
1209 capture: CaptureOpts,
1210 timeout_secs: u64,
1211 json: bool,
1212) -> Result<(), CliError> {
1213 let target = target.to_string();
1214 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1215 let v = session.hover(&target, include_snapshot).await?;
1216 Ok((session, v))
1217 })?;
1218 emit_ok(data, json, |_| println!("ok hover"))
1219}
1220
1221fn handle_drag(
1222 life: &Lifecycle,
1223 from: &str,
1224 to: &str,
1225 include_snapshot: bool,
1226 capture: CaptureOpts,
1227 timeout_secs: u64,
1228 json: bool,
1229) -> Result<(), CliError> {
1230 let from = from.to_string();
1231 let to = to.to_string();
1232 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1233 let v = session.drag(&from, &to, include_snapshot).await?;
1234 Ok((session, v))
1235 })?;
1236 emit_ok(data, json, |_| println!("ok drag"))
1237}
1238
1239fn handle_fill_form(
1240 life: &Lifecycle,
1241 fields_json: &str,
1242 include_snapshot: bool,
1243 capture: CaptureOpts,
1244 timeout_secs: u64,
1245 json: bool,
1246) -> Result<(), CliError> {
1247 let parsed: serde_json::Value = serde_json::from_str(fields_json).map_err(|e| {
1248 CliError::with_suggestion(
1249 ErrorKind::Usage,
1250 format!("fill-form --json parse error: {e}"),
1251 r##"Pass JSON array: [{"target":"input","value":"x"}] or [{"uid":"@e1","value":"x"}]"##,
1252 )
1253 })?;
1254 let arr = parsed.as_array().ok_or_else(|| {
1255 CliError::with_suggestion(
1256 ErrorKind::Usage,
1257 "fill-form --json must be a JSON array",
1258 r##"[{"target":"input","value":"x"}]"##,
1259 )
1260 })?;
1261 let mut fields = Vec::new();
1262 for item in arr {
1263 let target = item
1264 .get("target")
1265 .or_else(|| item.get("uid"))
1266 .or_else(|| item.get("selector"))
1267 .or_else(|| item.get("ref"))
1268 .and_then(|v| v.as_str())
1269 .ok_or_else(|| {
1270 CliError::new(
1271 ErrorKind::Usage,
1272 "fill-form field missing target/uid/selector/ref",
1273 )
1274 })?
1275 .to_string();
1276 let target = if target.starts_with('e')
1278 && target.len() > 1
1279 && target[1..].chars().all(|c| c.is_ascii_digit())
1280 {
1281 format!("@{target}")
1282 } else {
1283 target
1284 };
1285 let value = item
1286 .get("value")
1287 .or_else(|| item.get("text"))
1288 .and_then(|v| v.as_str())
1289 .ok_or_else(|| CliError::new(ErrorKind::Usage, "fill-form field missing value"))?
1290 .to_string();
1291 fields.push((target, value));
1292 }
1293 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1294 let v = session.fill_form(&fields, include_snapshot).await?;
1295 Ok((session, v))
1296 })?;
1297 emit_ok(data, json, |d| {
1298 let n = d.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
1299 println!("ok fill-form count={n}");
1300 })
1301}
1302
1303fn handle_upload(
1304 life: &Lifecycle,
1305 target: &str,
1306 path: &Path,
1307 include_snapshot: bool,
1308 capture: CaptureOpts,
1309 timeout_secs: u64,
1310 json: bool,
1311) -> Result<(), CliError> {
1312 let target = target.to_string();
1313 let path = path.to_path_buf();
1314 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1315 let v = session.upload(&target, &path, include_snapshot).await?;
1316 Ok((session, v))
1317 })?;
1318 emit_ok(data, json, |d| {
1319 let p = d.get("path").and_then(|v| v.as_str()).unwrap_or("");
1320 println!("ok upload path={p}");
1321 })
1322}
1323
1324fn handle_history(
1325 life: &Lifecycle,
1326 direction: &str,
1327 capture: CaptureOpts,
1328 timeout_secs: u64,
1329 json: bool,
1330) -> Result<(), CliError> {
1331 let direction = direction.to_string();
1332 let direction_label = direction.clone();
1333 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1334 let v = match direction.as_str() {
1335 "back" => session.back().await?,
1336 "forward" => session.forward().await?,
1337 other => {
1338 return Err(CliError::new(
1339 ErrorKind::Usage,
1340 format!("unknown history direction: {other}"),
1341 ))
1342 }
1343 };
1344 Ok((session, v))
1345 })?;
1346 emit_ok(data, json, |_| println!("ok {direction_label}"))
1347}
1348
1349fn handle_reload(
1350 life: &Lifecycle,
1351 ignore_cache: bool,
1352 init_script: Option<&str>,
1353 handle_before_unload: Option<BeforeUnloadAction>,
1354 capture: CaptureOpts,
1355 timeout_secs: u64,
1356 json: bool,
1357) -> Result<(), CliError> {
1358 if init_script.is_some() {
1361 return Err(CliError::with_suggestion(
1362 ErrorKind::Usage,
1363 "reload --init-script requires multi-step `run` with a prior goto in the same process",
1364 "Use: browser-automation-cli run --script steps.jsonl (goto then reload --init-script …)",
1365 ));
1366 }
1367 let beforeunload = handle_before_unload.map(|a| a.as_str().to_string());
1368 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1369 let v = session
1371 .reload_with_options(ignore_cache, None, beforeunload.as_deref())
1372 .await?;
1373 Ok((session, v))
1374 })?;
1375 emit_ok(data, json, |_| {
1376 println!("ok reload ignore_cache={ignore_cache}")
1377 })
1378}
1379
1380#[allow(clippy::too_many_arguments)]
1381fn handle_eval(
1382 life: &Lifecycle,
1383 expression: &str,
1384 args: Option<&str>,
1385 dialog_action: Option<&str>,
1386 file_path: Option<&Path>,
1387 service_worker_id: Option<&str>,
1388 capture: CaptureOpts,
1389 timeout_secs: u64,
1390 json: bool,
1391) -> Result<(), CliError> {
1392 let expr = expression.to_string();
1393 let args_owned = args.map(|s| s.to_string());
1394 let dialog_owned = dialog_action.map(|s| s.to_string());
1395 let path_owned = file_path.map(|p| p.to_path_buf());
1396 let sw_owned = service_worker_id.map(|s| s.to_string());
1397 let data = block_on_browser_timeout(
1398 async move {
1399 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1400 if let Ok(mut ledger) = life.ledger.lock() {
1401 ledger.chrome_launched = true;
1402 ledger.chrome_pid = session.chrome_pid();
1403 if let Some(dir) = session.temp_user_data_dir() {
1404 ledger.profile_dir = Some(dir);
1405 }
1406 }
1407 let r = if let Some(ref sw) = sw_owned {
1408 session.eval_service_worker(sw, &expr).await
1409 } else {
1410 let _ = session
1411 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1412 .await?;
1413 session
1414 .eval(
1415 &expr,
1416 args_owned.as_deref(),
1417 dialog_owned.as_deref(),
1418 path_owned.as_deref(),
1419 )
1420 .await
1421 };
1422 let close = session.shutdown().await;
1423 if let Ok(mut ledger) = life.ledger.lock() {
1424 ledger.chrome_launched = false;
1425 ledger.chrome_pid = None;
1426 }
1427 close?;
1428 r
1429 },
1430 timeout_secs,
1431 )?;
1432 emit_ok(data, json, |d| {
1433 println!(
1434 "ok eval={}",
1435 d.get("result").unwrap_or(&serde_json::Value::Null)
1436 );
1437 })
1438}
1439
1440#[allow(clippy::too_many_arguments)]
1441fn handle_grab(
1442 life: &Lifecycle,
1443 path: Option<&Path>,
1444 format: GrabFormat,
1445 full_page: bool,
1446 quality: Option<i32>,
1447 element: Option<&str>,
1448 artifacts: Option<&Path>,
1449 capture: CaptureOpts,
1450 timeout_secs: u64,
1451 json: bool,
1452) -> Result<(), CliError> {
1453 let fmt = match format {
1454 GrabFormat::Png => "png",
1455 GrabFormat::Jpeg => "jpeg",
1456 GrabFormat::Webp => "webp",
1457 };
1458 if let Some(a) = artifacts {
1459 std::fs::create_dir_all(a)
1460 .map_err(|e| CliError::new(ErrorKind::Io, format!("artifacts-dir mkdir: {e}")))?;
1461 }
1462 let path_owned = path.map(|p| p.to_path_buf()).or_else(|| {
1463 artifacts.map(|a| {
1464 a.join(format!(
1465 "grab-{}.{}",
1466 std::time::SystemTime::now()
1467 .duration_since(std::time::UNIX_EPOCH)
1468 .map(|d| d.as_millis())
1469 .unwrap_or(0),
1470 fmt
1471 ))
1472 })
1473 });
1474 if let Some(ref p) = path_owned {
1475 if let Some(parent) = p.parent() {
1476 if !parent.as_os_str().is_empty() {
1477 std::fs::create_dir_all(parent)
1478 .map_err(|e| CliError::new(ErrorKind::Io, format!("grab path mkdir: {e}")))?;
1479 }
1480 }
1481 }
1482 let element_owned = element.map(|s| s.to_string());
1483 let data = block_on_browser_timeout(
1484 async move {
1485 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1486 if let Ok(mut ledger) = life.ledger.lock() {
1487 ledger.chrome_launched = true;
1488 ledger.chrome_pid = session.chrome_pid();
1489 }
1490 let _ = session
1491 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1492 .await?;
1493 let r = session
1494 .grab(
1495 path_owned.as_deref(),
1496 fmt,
1497 full_page,
1498 quality,
1499 element_owned.as_deref(),
1500 )
1501 .await;
1502 let close = session.shutdown().await;
1503 if let Ok(mut ledger) = life.ledger.lock() {
1504 ledger.chrome_launched = false;
1505 ledger.chrome_pid = None;
1506 }
1507 close?;
1508 r
1509 },
1510 timeout_secs,
1511 )?;
1512 emit_ok(data, json, |d| {
1513 let p = d.get("path").and_then(|v| v.as_str()).unwrap_or("");
1514 println!("ok grab path={p}");
1515 })
1516}
1517
1518fn handle_print_pdf(
1519 life: &Lifecycle,
1520 path: Option<&Path>,
1521 url: Option<&str>,
1522 robots: RobotsPolicy,
1523 capture: CaptureOpts,
1524 timeout_secs: u64,
1525 json: bool,
1526) -> Result<(), CliError> {
1527 if url.map(str::trim).filter(|u| !u.is_empty()).is_none() {
1529 return Err(CliError::with_suggestion(
1530 ErrorKind::Usage,
1531 "print-pdf requires --url (blank about:blank PDF refused)",
1532 "Pass --url <page> or use multi-step run with goto then print-pdf",
1533 ));
1534 }
1535 let path = path.map(|p| p.to_path_buf());
1536 let url = url.map(|s| s.to_string());
1537 let data = block_on_browser_timeout(
1538 async {
1539 let mut session =
1540 crate::browser::OneShotSession::launch_headless_with_capture(capture).await?;
1541 if let Ok(mut ledger) = life.ledger.lock() {
1542 ledger.chrome_launched = true;
1543 ledger.chrome_pid = session.chrome_pid();
1544 }
1545 if let Some(u) = url.as_deref() {
1546 let _ = session.goto(u, robots).await?;
1547 }
1548 let out = session.print_pdf(path.as_deref()).await;
1549 let _ = session.shutdown().await;
1550 if let Ok(mut ledger) = life.ledger.lock() {
1551 ledger.chrome_launched = false;
1552 ledger.chrome_pid = None;
1553 }
1554 out
1555 },
1556 timeout_secs,
1557 )?;
1558 emit_ok(data, json, |d| {
1559 let p = d.get("path").and_then(|v| v.as_str()).unwrap_or("");
1560 println!("ok print-pdf path={p}");
1561 })
1562}
1563
1564fn handle_monitor(
1565 action: crate::cli::MonitorAction,
1566 robots: RobotsPolicy,
1567 timeout_secs: u64,
1568 json: bool,
1569) -> Result<(), CliError> {
1570 use sha2::{Digest, Sha256};
1571 match action {
1572 crate::cli::MonitorAction::Check {
1573 url,
1574 baseline,
1575 write_baseline,
1576 engine,
1577 } => {
1578 let engine_l = engine.to_ascii_lowercase();
1579 let text = if engine_l == "browser" {
1580 return Err(CliError::with_suggestion(
1581 ErrorKind::Usage,
1582 "monitor check --engine browser is reserved; use http for baseline hash",
1583 "Pass --engine http (default) for one-shot baseline compare",
1584 ));
1585 } else {
1586 let opts = crate::scrape_local::ScrapeOpts {
1587 format: crate::scrape_local::ScrapeFormat::Text,
1588 engine: "http".into(),
1589 ..Default::default()
1590 };
1591 let page = block_on_browser_timeout(
1592 crate::scrape_local::scrape_http(&url, robots, &opts),
1593 timeout_secs,
1594 )?;
1595 page.get("text")
1596 .and_then(|v| v.as_str())
1597 .unwrap_or("")
1598 .to_string()
1599 };
1600 let mut hasher = Sha256::new();
1601 hasher.update(text.as_bytes());
1602 let hash = hex::encode(hasher.finalize());
1603 let baseline_exists = baseline.exists();
1604 let (changed, previous_hash) = if baseline_exists {
1605 let prev = std::fs::read_to_string(&baseline).map_err(|e| {
1606 CliError::new(
1607 ErrorKind::Io,
1608 format!("read baseline {}: {e}", baseline.display()),
1609 )
1610 })?;
1611 let prev = prev.trim().to_string();
1612 (prev != hash, Some(prev))
1613 } else {
1614 (true, None)
1615 };
1616 if write_baseline || !baseline_exists {
1617 if let Some(parent) = baseline.parent() {
1618 if !parent.as_os_str().is_empty() {
1619 let _ = std::fs::create_dir_all(parent);
1620 }
1621 }
1622 std::fs::write(&baseline, format!("{hash}\n")).map_err(|e| {
1623 CliError::new(
1624 ErrorKind::Io,
1625 format!("write baseline {}: {e}", baseline.display()),
1626 )
1627 })?;
1628 }
1629 let data = serde_json::json!({
1630 "url": url,
1631 "baseline": baseline.display().to_string(),
1632 "hash": hash,
1633 "previous_hash": previous_hash,
1634 "changed": changed,
1635 "baseline_written": write_baseline || !baseline_exists,
1636 "engine": "http",
1637 });
1638 emit_ok(data, json, |d| {
1639 let ch = d.get("changed").and_then(|v| v.as_bool()).unwrap_or(false);
1640 println!("ok monitor check changed={ch}");
1641 })
1642 }
1643 }
1644}
1645
1646fn handle_run(
1647 life: &Lifecycle,
1648 script: &Path,
1649 robots: RobotsPolicy,
1650 capture: CaptureOpts,
1651 timeout_secs: u64,
1652 json: bool,
1653 flags: run::RunFlags,
1654) -> Result<(), CliError> {
1655 let script = script.to_path_buf();
1656 let data = block_on_browser_timeout(
1657 run::run_script_with_flags(life, &script, robots, capture, flags),
1658 timeout_secs,
1659 )?;
1660 if data.get("ok") == Some(&serde_json::json!(false)) {
1662 let kind = data
1663 .pointer("/error/kind")
1664 .and_then(|v| v.as_str())
1665 .unwrap_or("data");
1666 let message = data
1667 .pointer("/error/message")
1668 .and_then(|v| v.as_str())
1669 .unwrap_or("run fail-fast")
1670 .to_string();
1671 let suggestion = data
1672 .pointer("/error/suggestion")
1673 .and_then(|v| v.as_str())
1674 .unwrap_or_else(|| crate::i18n::suggestion_key("run_fail_fast", None))
1675 .to_string();
1676 let err_kind = match kind {
1677 "usage" => ErrorKind::Usage,
1678 "unavailable" => ErrorKind::Unavailable,
1679 "browser" => ErrorKind::Browser,
1680 "timeout" => ErrorKind::Timeout,
1681 "data" => ErrorKind::Data,
1682 _ => ErrorKind::Software,
1683 };
1684 let partial = serde_json::json!({
1685 "total": data.get("total"),
1686 "failed_index": data.get("failed_index"),
1687 "failed_cmd": data.get("failed_cmd"),
1688 "steps": data.get("steps"),
1689 });
1690 return Err(CliError::with_suggestion(err_kind, message, suggestion).with_data(partial));
1691 }
1692 emit_ok(data, json, |d| {
1693 let total = d.get("total").and_then(|v| v.as_u64()).unwrap_or(0);
1694 println!("ok run steps={total}");
1695 })
1696}
1697
1698fn handle_exec(
1699 life: &Lifecycle,
1700 args: &[String],
1701 robots: RobotsPolicy,
1702 capture: CaptureOpts,
1703 timeout_secs: u64,
1704 json: bool,
1705 flags: run::RunFlags,
1706) -> Result<(), CliError> {
1707 if args.is_empty() {
1708 return Err(CliError::with_suggestion(
1709 ErrorKind::Usage,
1710 "exec requires a subcommand (e.g. goto)",
1711 "browser-automation-cli exec goto about:blank",
1712 ));
1713 }
1714 match args[0].as_str() {
1716 "goto" => {
1717 let url = args.get(1).ok_or_else(|| {
1718 CliError::with_suggestion(
1719 ErrorKind::Usage,
1720 "exec goto requires a URL",
1721 "browser-automation-cli exec goto about:blank",
1722 )
1723 })?;
1724 handle_goto(
1725 life,
1726 url,
1727 robots,
1728 capture,
1729 timeout_secs,
1730 json,
1731 None,
1732 None,
1733 None,
1734 )
1735 }
1736 "wait" | "view" | "press" | "write" | "keys" | "type" | "hover" | "back" | "forward"
1737 | "reload" | "eval" | "grab" | "page" | "console" | "net" | "dialog" | "emulate"
1738 | "resize" | "extract" | "text" | "scroll" | "cookie" | "attr" | "assert" | "click-at"
1739 | "drag" | "fill-form" | "upload" | "devtools3p" | "webmcp" | "heap" | "perf"
1740 | "lighthouse" | "screencast" | "extension" => {
1741 let step = run::argv_to_step(args)?;
1742 let data = block_on_browser_timeout(
1743 run::run_one_step(life, step, robots, capture, flags),
1744 timeout_secs,
1745 )?;
1746 emit_ok(data, json, |d| println!("ok exec {d}"))
1747 }
1748 other => Err(CliError::with_suggestion(
1749 ErrorKind::Usage,
1750 format!("unknown exec subcommand: {other}"),
1751 "Use browser-automation-cli exec <cmd> ... or run --script for multi-step NDJSON",
1752 )),
1753 }
1754}
1755
1756#[allow(clippy::too_many_arguments)]
1757fn handle_extract(
1758 life: &Lifecycle,
1759 target: &str,
1760 attr: Option<&str>,
1761 llm: bool,
1762 question: Option<&str>,
1763 schema_json: Option<&std::path::Path>,
1764 capture: CaptureOpts,
1765 timeout_secs: u64,
1766 json: bool,
1767) -> Result<(), CliError> {
1768 if llm {
1769 if !(target.starts_with("http://")
1771 || target.starts_with("https://")
1772 || Path::new(target).is_file())
1773 {
1774 let target_owned = target.to_string();
1775 let attr_owned = attr.map(|s| s.to_string());
1776 let dom = block_on_browser_timeout(
1777 async move {
1778 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1779 if let Ok(mut ledger) = life.ledger.lock() {
1780 ledger.chrome_launched = true;
1781 ledger.chrome_pid = session.chrome_pid();
1782 }
1783 let r = session.extract(&target_owned, attr_owned.as_deref()).await;
1786 let close = session.shutdown().await;
1787 if let Ok(mut ledger) = life.ledger.lock() {
1788 ledger.chrome_launched = false;
1789 ledger.chrome_pid = None;
1790 }
1791 close?;
1792 r
1793 },
1794 timeout_secs,
1795 );
1796 match dom {
1797 Ok(v) => {
1798 let text = v
1799 .get("text")
1800 .or_else(|| v.get("value"))
1801 .and_then(|t| t.as_str())
1802 .unwrap_or("")
1803 .to_string();
1804 if text.trim().is_empty() {
1805 return Err(CliError::with_suggestion(
1806 ErrorKind::Usage,
1807 "extract --llm with selector produced empty text (need navigated page)",
1808 "Use multi-step run: goto then extract --llm, or pass http(s)/file target",
1809 ));
1810 }
1811 return handle_extract_llm_text(&text, question, schema_json, json);
1812 }
1813 Err(e) => {
1814 return Err(CliError::with_suggestion(
1815 ErrorKind::Usage,
1816 format!("extract --llm selector path failed: {}", e.message()),
1817 "Pass http(s) URL, local file, or use run with goto then extract",
1818 ));
1819 }
1820 }
1821 }
1822 return handle_extract_llm(target, question, schema_json, json);
1823 }
1824 let target = target.to_string();
1825 let attr = attr.map(|s| s.to_string());
1826 let data = block_on_browser_timeout(
1827 async move {
1828 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1829 if let Ok(mut ledger) = life.ledger.lock() {
1830 ledger.chrome_launched = true;
1831 ledger.chrome_pid = session.chrome_pid();
1832 }
1833 let _ = session
1834 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1835 .await?;
1836 let r = session.extract(&target, attr.as_deref()).await;
1837 let close = session.shutdown().await;
1838 if let Ok(mut ledger) = life.ledger.lock() {
1839 ledger.chrome_launched = false;
1840 ledger.chrome_pid = None;
1841 }
1842 close?;
1843 r
1844 },
1845 timeout_secs,
1846 )?;
1847 emit_ok(data, json, |d| println!("ok extract {d}"))
1848}
1849
1850fn handle_extract_llm(
1851 target: &str,
1852 question: Option<&str>,
1853 schema_json: Option<&std::path::Path>,
1854 json: bool,
1855) -> Result<(), CliError> {
1856 let source_text = if target.starts_with("http://") || target.starts_with("https://") {
1857 let opts = crate::scrape_local::ScrapeOpts {
1858 format: crate::scrape_local::ScrapeFormat::Text,
1859 only_main_content: true,
1860 engine: "http".into(),
1861 max_body_bytes: 2_000_000,
1862 };
1863 let rt = tokio::runtime::Builder::new_multi_thread()
1864 .enable_all()
1865 .build()
1866 .map_err(|e| CliError::new(ErrorKind::Software, format!("runtime: {e}")))?;
1867 let data = rt.block_on(crate::scrape_local::scrape_http(
1868 target,
1869 crate::robots::RobotsPolicy::Honor,
1870 &opts,
1871 ))?;
1872 data.get("text")
1873 .and_then(|t| t.as_str())
1874 .unwrap_or("")
1875 .to_string()
1876 } else if Path::new(target).is_file() {
1877 let parsed = crate::scrape_local::parse_file(Path::new(target))?;
1878 parsed
1879 .get("text")
1880 .and_then(|t| t.as_str())
1881 .unwrap_or("")
1882 .to_string()
1883 } else {
1884 return Err(CliError::with_suggestion(
1885 ErrorKind::Usage,
1886 "extract --llm target must be http(s) URL or local file path",
1887 "Example: browser-automation-cli --json extract --llm --question 'sum' https://example.com",
1888 ));
1889 };
1890 if source_text.trim().is_empty() {
1891 return Err(CliError::new(
1892 ErrorKind::Data,
1893 "extract --llm: empty source text",
1894 ));
1895 }
1896 handle_extract_llm_text(&source_text, question, schema_json, json)
1897}
1898
1899fn handle_extract_llm_text(
1901 source_text: &str,
1902 question: Option<&str>,
1903 schema_json: Option<&std::path::Path>,
1904 json: bool,
1905) -> Result<(), CliError> {
1906 let schema_body = match schema_json {
1907 Some(p) => Some(std::fs::read_to_string(p).map_err(|e| {
1908 CliError::new(
1909 ErrorKind::Io,
1910 format!("read schema-json {}: {e}", p.display()),
1911 )
1912 })?),
1913 None => None,
1914 };
1915 if source_text.trim().is_empty() {
1916 return Err(CliError::new(
1917 ErrorKind::Data,
1918 "extract --llm: empty source text",
1919 ));
1920 }
1921 let data = crate::llm_local::extract_with_llm(source_text, question, schema_body.as_deref())?;
1922 emit_ok(data, json, |d| println!("ok extract-llm {d}"))
1923}
1924
1925fn handle_attr(
1926 life: &Lifecycle,
1927 target: &str,
1928 name: &str,
1929 capture: CaptureOpts,
1930 timeout_secs: u64,
1931 json: bool,
1932) -> Result<(), CliError> {
1933 handle_extract(
1934 life,
1935 target,
1936 Some(name),
1937 false,
1938 None,
1939 None,
1940 capture,
1941 timeout_secs,
1942 json,
1943 )
1944}
1945
1946fn handle_assert(
1947 life: &Lifecycle,
1948 kind: AssertKind,
1949 capture: CaptureOpts,
1950 timeout_secs: u64,
1951 json: bool,
1952) -> Result<(), CliError> {
1953 let data = block_on_browser_timeout(
1954 async move {
1955 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1956 if let Ok(mut ledger) = life.ledger.lock() {
1957 ledger.chrome_launched = true;
1958 ledger.chrome_pid = session.chrome_pid();
1959 }
1960 let _ = session
1961 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1962 .await?;
1963 let r = match kind {
1964 AssertKind::Url { value, contains } => session.assert_url(&value, contains).await,
1965 AssertKind::Text { value, target } => {
1966 session.assert_text(&value, target.as_deref()).await
1967 }
1968 AssertKind::Console { level, max } => session.assert_console(&level, max).await,
1969 AssertKind::ConsoleEmpty => session.assert_console_empty().await,
1970 AssertKind::ConsoleNoMatch { pattern } => {
1971 session.assert_console_no_match(&pattern).await
1972 },
1973 };
1974 let close = session.shutdown().await;
1975 if let Ok(mut ledger) = life.ledger.lock() {
1976 ledger.chrome_launched = false;
1977 ledger.chrome_pid = None;
1978 }
1979 close?;
1980 r
1981 },
1982 timeout_secs,
1983 )?;
1984 emit_ok(data, json, |_| println!("ok assert"))
1985}
1986
1987fn handle_console(
1988 life: &Lifecycle,
1989 action: ConsoleAction,
1990 capture: CaptureOpts,
1991 timeout_secs: u64,
1992 json: bool,
1993) -> Result<(), CliError> {
1994 let data = block_on_browser_timeout(
1995 async move {
1996 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1997 if let Ok(mut ledger) = life.ledger.lock() {
1998 ledger.chrome_launched = true;
1999 ledger.chrome_pid = session.chrome_pid();
2000 }
2001 let _ = session
2002 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
2003 .await?;
2004 let r = match action {
2005 ConsoleAction::List {
2006 page_idx,
2007 page_size,
2008 types,
2009 include_preserved,
2010 service_worker_id,
2011 } => session.console_list(
2012 page_idx,
2013 page_size,
2014 types.as_deref(),
2015 include_preserved,
2016 service_worker_id.as_deref(),
2017 ),
2018 ConsoleAction::Get { id } => session.console_get(id),
2019 ConsoleAction::Clear => session.console_clear(),
2020 ConsoleAction::Dump { path } => session.console_dump(&path),
2021 };
2022 let close = session.shutdown().await;
2023 if let Ok(mut ledger) = life.ledger.lock() {
2024 ledger.chrome_launched = false;
2025 ledger.chrome_pid = None;
2026 }
2027 close?;
2028 r
2029 },
2030 timeout_secs,
2031 )?;
2032 emit_ok(data, json, |d| println!("ok console {d}"))
2033}
2034
2035fn handle_net(
2036 life: &Lifecycle,
2037 action: NetAction,
2038 capture: CaptureOpts,
2039 timeout_secs: u64,
2040 json: bool,
2041) -> Result<(), CliError> {
2042 let data = block_on_browser_timeout(
2043 async move {
2044 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
2045 if let Ok(mut ledger) = life.ledger.lock() {
2046 ledger.chrome_launched = true;
2047 ledger.chrome_pid = session.chrome_pid();
2048 }
2049 let _ = session
2050 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
2051 .await?;
2052 let r = match action {
2053 NetAction::List {
2054 page_idx,
2055 page_size,
2056 resource_types,
2057 include_preserved,
2058 } => session.net_list(
2059 page_idx,
2060 page_size,
2061 resource_types.as_deref(),
2062 include_preserved,
2063 ),
2064 NetAction::Get {
2065 id,
2066 request_path,
2067 response_path,
2068 } => session.net_get(&id, request_path.as_deref(), response_path.as_deref()),
2069 };
2070 let close = session.shutdown().await;
2071 if let Ok(mut ledger) = life.ledger.lock() {
2072 ledger.chrome_launched = false;
2073 ledger.chrome_pid = None;
2074 }
2075 close?;
2076 r
2077 },
2078 timeout_secs,
2079 )?;
2080 emit_ok(data, json, |d| println!("ok net {d}"))
2081}
2082
2083fn handle_page(
2084 life: &Lifecycle,
2085 action: Option<PageAction>,
2086 capture: CaptureOpts,
2087 timeout_secs: u64,
2088 json: bool,
2089) -> Result<(), CliError> {
2090 let action = action.unwrap_or(PageAction::Info);
2091 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2092 let v = match action {
2093 PageAction::Info => session.page_info().await?,
2094 PageAction::List => session.page_list().await?,
2095 PageAction::New {
2096 url,
2097 background,
2098 isolated_context,
2099 } => {
2100 session
2101 .page_new(url.as_deref(), background, isolated_context.as_deref())
2102 .await?
2103 }
2104 PageAction::Select {
2105 index,
2106 page_id,
2107 bring_to_front,
2108 } => {
2109 let idx = index.or(page_id).ok_or_else(|| {
2110 CliError::with_suggestion(
2111 ErrorKind::Usage,
2112 "page select requires INDEX or --page-id",
2113 "browser-automation-cli page select 0 --json",
2114 )
2115 })?;
2116 session.page_select(idx, bring_to_front).await?
2117 }
2118 PageAction::Close { index, page_id } => session.page_close(index.or(page_id)).await?,
2119 PageAction::TabId => {
2120 let tab = session.active_tab_id_string().ok_or_else(|| {
2121 CliError::with_suggestion(
2122 ErrorKind::Browser,
2123 "no active tab id",
2124 "Open a page first (goto / page new)",
2125 )
2126 })?;
2127 serde_json::json!({
2128 "tab_id": tab,
2129 "tool": "get_tab_id",
2130 })
2131 }
2132 };
2133 Ok((session, v))
2134 })?;
2135 emit_ok(data, json, |d| {
2136 if let Some(tab) = d.get("tab_id").and_then(|v| v.as_str()) {
2137 println!("ok page tab-id={tab}");
2138 } else if let (Some(u), Some(t)) = (
2139 d.get("url").and_then(|v| v.as_str()),
2140 d.get("title").and_then(|v| v.as_str()),
2141 ) {
2142 println!("ok page url={u} title={t}");
2143 } else {
2144 println!("ok page {d}");
2145 }
2146 })
2147}
2148
2149fn handle_text(
2150 life: &Lifecycle,
2151 target: &str,
2152 capture: CaptureOpts,
2153 timeout_secs: u64,
2154 json: bool,
2155) -> Result<(), CliError> {
2156 handle_extract(
2157 life,
2158 target,
2159 None,
2160 false,
2161 None,
2162 None,
2163 capture,
2164 timeout_secs,
2165 json,
2166 )
2167}
2168
2169fn handle_scroll(
2170 life: &Lifecycle,
2171 target: Option<&str>,
2172 delta_x: f64,
2173 delta_y: f64,
2174 capture: CaptureOpts,
2175 timeout_secs: u64,
2176 json: bool,
2177) -> Result<(), CliError> {
2178 let target_owned = target.map(|s| s.to_string());
2179 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2180 let v = session
2181 .scroll(target_owned.as_deref(), delta_x, delta_y)
2182 .await?;
2183 Ok((session, v))
2184 })?;
2185 emit_ok(data, json, |d| println!("ok scroll {d}"))
2186}
2187
2188fn handle_cookie(
2189 life: &Lifecycle,
2190 action: CookieAction,
2191 capture: CaptureOpts,
2192 timeout_secs: u64,
2193 json: bool,
2194) -> Result<(), CliError> {
2195 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2196 let v = match action {
2197 CookieAction::List { url } => session.cookie_list(url.as_deref()).await?,
2198 CookieAction::Set { json: body } => session.cookie_set(&body).await?,
2199 CookieAction::Clear => session.cookie_clear().await?,
2200 };
2201 Ok((session, v))
2202 })?;
2203 emit_ok(data, json, |d| println!("ok cookie {d}"))
2204}
2205
2206fn handle_dialog(
2207 life: &Lifecycle,
2208 action: DialogAction,
2209 capture: CaptureOpts,
2210 timeout_secs: u64,
2211 json: bool,
2212) -> Result<(), CliError> {
2213 let data = block_on_browser_timeout(
2214 async move {
2215 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
2216 if let Ok(mut ledger) = life.ledger.lock() {
2217 ledger.chrome_launched = true;
2218 ledger.chrome_pid = session.chrome_pid();
2219 }
2220 let _ = session
2221 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
2222 .await?;
2223 let (r, if_present) = match action {
2224 DialogAction::Accept { text, if_present } => {
2225 (session.dialog(true, text.as_deref()).await, if_present)
2226 }
2227 DialogAction::Dismiss { if_present } => {
2228 (session.dialog(false, None).await, if_present)
2229 }
2230 };
2231 let r = match r {
2232 Ok(v) => Ok(v),
2233 Err(e) if if_present => {
2234 let msg = e.message().to_ascii_lowercase();
2235 if msg.contains("no dialog")
2236 || msg.contains("not showing")
2237 || msg.contains("-32602")
2238 || msg.contains("dialog failed")
2239 {
2240 Ok(serde_json::json!({
2241 "dialog_shown": false,
2242 "if_present": true,
2243 "ok": true,
2244 }))
2245 } else {
2246 Err(e)
2247 }
2248 }
2249 Err(e) => Err(e),
2250 };
2251 let close = session.shutdown().await;
2252 if let Ok(mut ledger) = life.ledger.lock() {
2253 ledger.chrome_launched = false;
2254 ledger.chrome_pid = None;
2255 }
2256 close?;
2257 r
2258 },
2259 timeout_secs,
2260 )?;
2261 emit_ok(data, json, |_| println!("ok dialog"))
2262}
2263
2264#[allow(clippy::too_many_arguments)]
2265fn post_webhook(webhook_url: &str, data: &serde_json::Value) -> Result<(), CliError> {
2266 let client = reqwest::blocking::Client::builder()
2268 .timeout(std::time::Duration::from_secs(15))
2269 .build()
2270 .map_err(|e| CliError::new(ErrorKind::Software, format!("webhook client: {e}")))?;
2271 let mut last_err = String::new();
2272 for attempt in 0..3u32 {
2273 match client.post(webhook_url).json(data).send() {
2274 Ok(resp) if resp.status().is_success() => return Ok(()),
2275 Ok(resp) => {
2276 last_err = format!("webhook HTTP {}", resp.status());
2277 }
2278 Err(e) => last_err = format!("webhook: {e}"),
2279 }
2280 if attempt < 2 {
2281 std::thread::sleep(std::time::Duration::from_millis(50 * (1 << attempt)));
2282 }
2283 }
2284 Err(CliError::with_suggestion(
2285 ErrorKind::Unavailable,
2286 last_err,
2287 "Check --webhook-url reachability; operator destination only",
2288 ))
2289}
2290
2291#[allow(clippy::too_many_arguments)]
2292fn handle_scrape(
2293 life: &Lifecycle,
2294 url: &str,
2295 robots: RobotsPolicy,
2296 capture: CaptureOpts,
2297 timeout_secs: u64,
2298 json: bool,
2299 formats: &[String],
2300 engine: &str,
2301 only_main_content: bool,
2302 webhook_url: Option<&str>,
2303) -> Result<(), CliError> {
2304 let formats: Vec<&str> = if formats.is_empty() {
2306 vec!["text"]
2307 } else {
2308 formats.iter().map(String::as_str).collect()
2309 };
2310 let engine_l = engine.to_ascii_lowercase();
2311
2312 if engine_l == "http" {
2313 if formats.len() == 1 {
2314 let fmt = crate::scrape_local::ScrapeFormat::parse(formats[0])?;
2315 let opts = crate::scrape_local::ScrapeOpts {
2316 format: fmt,
2317 only_main_content,
2318 engine: "http".into(),
2319 ..Default::default()
2320 };
2321 let data = block_on_browser_timeout(
2322 crate::scrape_local::scrape_http(url, robots, &opts),
2323 timeout_secs,
2324 )?;
2325 if let Some(wh) = webhook_url {
2326 post_webhook(wh, &data)?;
2327 }
2328 return emit_ok(data, json, |d| {
2329 let u = d.get("source_url").and_then(|v| v.as_str()).unwrap_or(url);
2330 println!("ok scrape engine=http source_url={u}");
2331 });
2332 }
2333 let opts_html = crate::scrape_local::ScrapeOpts {
2335 format: crate::scrape_local::ScrapeFormat::Html,
2336 only_main_content,
2337 engine: "http".into(),
2338 ..Default::default()
2339 };
2340 let base = block_on_browser_timeout(
2341 crate::scrape_local::scrape_http(url, robots, &opts_html),
2342 timeout_secs,
2343 )?;
2344 let html = base
2345 .get("html")
2346 .or_else(|| base.get("content"))
2347 .and_then(|v| v.as_str())
2348 .unwrap_or("")
2349 .to_string();
2350 let source = base
2351 .get("source_url")
2352 .and_then(|v| v.as_str())
2353 .unwrap_or(url)
2354 .to_string();
2355 let status = base.get("status").and_then(|v| v.as_u64()).unwrap_or(200) as u16;
2356 let mut formats_out = serde_json::Map::new();
2357 for f in &formats {
2358 let fmt = crate::scrape_local::ScrapeFormat::parse(f)?;
2359 let opts = crate::scrape_local::ScrapeOpts {
2360 format: fmt,
2361 only_main_content,
2362 engine: "http".into(),
2363 ..Default::default()
2364 };
2365 let part =
2366 crate::scrape_local::build_scrape_payload(&source, status, &html, &opts, robots);
2367 formats_out.insert(f.replace('-', "_"), part);
2368 }
2369 let data = json!({
2370 "source_url": source,
2371 "engine": "http",
2372 "formats": formats_out,
2373 "format_list": formats,
2374 });
2375 if let Some(wh) = webhook_url {
2376 post_webhook(wh, &data)?;
2377 }
2378 return emit_ok(data, json, |d| {
2379 let u = d.get("source_url").and_then(|v| v.as_str()).unwrap_or(url);
2380 println!("ok scrape engine=http multi-format source_url={u}");
2381 });
2382 }
2383
2384 let data = block_on_browser_timeout(run_scrape(life, url, robots, capture), timeout_secs)?;
2386 let html = data
2387 .get("html")
2388 .and_then(|v| v.as_str())
2389 .unwrap_or("")
2390 .to_string();
2391 let source = data
2392 .get("source_url")
2393 .and_then(|v| v.as_str())
2394 .unwrap_or(url)
2395 .to_string();
2396 let data = if formats.len() == 1 {
2397 let fmt = crate::scrape_local::ScrapeFormat::parse(formats[0])?;
2398 if html.is_empty() {
2399 let mut d = data;
2400 if let Some(obj) = d.as_object_mut() {
2401 obj.insert(
2402 "format".into(),
2403 serde_json::json!(format!("{:?}", fmt).to_ascii_lowercase()),
2404 );
2405 obj.insert("engine".into(), serde_json::json!("browser"));
2406 }
2407 d
2408 } else {
2409 let opts = crate::scrape_local::ScrapeOpts {
2410 format: fmt,
2411 only_main_content,
2412 engine: "browser".into(),
2413 ..Default::default()
2414 };
2415 crate::scrape_local::build_scrape_payload(&source, 200, &html, &opts, robots)
2416 }
2417 } else {
2418 let mut formats_out = serde_json::Map::new();
2419 for f in &formats {
2420 let fmt = crate::scrape_local::ScrapeFormat::parse(f)?;
2421 let opts = crate::scrape_local::ScrapeOpts {
2422 format: fmt,
2423 only_main_content,
2424 engine: "browser".into(),
2425 ..Default::default()
2426 };
2427 let part =
2428 crate::scrape_local::build_scrape_payload(&source, 200, &html, &opts, robots);
2429 formats_out.insert(f.replace('-', "_"), part);
2430 }
2431 json!({
2432 "source_url": source,
2433 "engine": "browser",
2434 "formats": formats_out,
2435 "format_list": formats,
2436 "robots_policy": robots.as_str(),
2437 })
2438 };
2439 if let Some(wh) = webhook_url {
2440 post_webhook(wh, &data)?;
2441 }
2442 emit_ok(data, json, |d| {
2443 let policy = d
2444 .get("robots_policy")
2445 .and_then(|v| v.as_str())
2446 .unwrap_or("honor");
2447 let u = d.get("source_url").and_then(|v| v.as_str()).unwrap_or(url);
2448 println!("ok scrape source_url={u} robots_policy={policy}");
2449 })
2450}
2451
2452#[allow(clippy::too_many_arguments)]
2453fn handle_batch_scrape(
2454 life: &Lifecycle,
2455 urls_file: &Path,
2456 robots: RobotsPolicy,
2457 capture: CaptureOpts,
2458 timeout_secs: u64,
2459 format: &str,
2460 concurrency: usize,
2461 engine: &str,
2462 json: bool,
2463) -> Result<(), CliError> {
2464 let urls = crate::scrape_local::read_urls_file(urls_file)?;
2465 let engine_l = engine.to_ascii_lowercase();
2466 if engine_l == "browser" {
2467 let _ = concurrency;
2469 let mut pages = Vec::new();
2470 let mut errors = Vec::new();
2471 for u in &urls {
2472 match block_on_browser_timeout(run_scrape(life, u, robots, capture), timeout_secs) {
2473 Ok(v) => pages.push(v),
2474 Err(e) => errors.push(json!({ "url": u, "error": e.message() })),
2475 }
2476 }
2477 let data = json!({
2478 "count": pages.len(),
2479 "pages": pages,
2480 "errors": errors,
2481 "engine": "browser",
2482 "format": format,
2483 });
2484 return emit_ok(data, json, |d| {
2485 println!(
2486 "ok batch-scrape engine=browser count={}",
2487 d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)
2488 );
2489 });
2490 }
2491 let opts = crate::scrape_local::ScrapeOpts {
2492 format: crate::scrape_local::ScrapeFormat::parse(format)?,
2493 engine: "http".into(),
2494 ..Default::default()
2495 };
2496 let data = block_on_browser_timeout(
2497 crate::scrape_local::batch_scrape_http(&urls, robots, &opts, concurrency),
2498 0,
2499 )?;
2500 emit_ok(data, json, |d| {
2501 println!(
2502 "ok batch-scrape count={}",
2503 d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)
2504 );
2505 })
2506}
2507
2508#[allow(clippy::too_many_arguments)]
2509fn handle_crawl(
2510 life: &Lifecycle,
2511 url: &str,
2512 robots: RobotsPolicy,
2513 capture: CaptureOpts,
2514 timeout_secs: u64,
2515 limit: usize,
2516 max_depth: usize,
2517 format: &str,
2518 same_host: bool,
2519 engine: &str,
2520 json: bool,
2521) -> Result<(), CliError> {
2522 let engine_l = engine.to_ascii_lowercase();
2523 if engine_l == "browser" {
2524 let seed = block_on_browser_timeout(run_scrape(life, url, robots, capture), timeout_secs)?;
2526 let mut pages = vec![seed.clone()];
2527 let mut seen = std::collections::BTreeSet::new();
2528 seen.insert(url.to_string());
2529 let links: Vec<String> = seed
2530 .get("links")
2531 .and_then(|v| v.as_array())
2532 .map(|a| {
2533 a.iter()
2534 .filter_map(|x| x.as_str().map(|s| s.to_string()))
2535 .collect()
2536 })
2537 .unwrap_or_default();
2538 for link in links.into_iter().take(limit.saturating_sub(1)) {
2539 if !seen.insert(link.clone()) {
2540 continue;
2541 }
2542 if same_host {
2543 if let (Ok(seed_u), Ok(link_u)) = (
2545 url::Url::parse(url),
2546 url::Url::parse(&link),
2547 ) {
2548 if seed_u.host_str() != link_u.host_str() {
2549 continue;
2550 }
2551 }
2552 }
2553 match block_on_browser_timeout(run_scrape(life, &link, robots, capture), timeout_secs) {
2554 Ok(v) => pages.push(v),
2555 Err(_) => continue,
2556 }
2557 if pages.len() >= limit {
2558 break;
2559 }
2560 }
2561 let _ = max_depth; let data = json!({
2563 "count": pages.len(),
2564 "pages": pages,
2565 "engine": "browser",
2566 "format": format,
2567 "seed": url,
2568 "max_depth_applied": 1,
2569 });
2570 return emit_ok(data, json, |d| {
2571 println!(
2572 "ok crawl engine=browser count={}",
2573 d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)
2574 );
2575 });
2576 }
2577 let opts = crate::scrape_local::ScrapeOpts {
2578 format: crate::scrape_local::ScrapeFormat::parse(format)?,
2579 engine: "http".into(),
2580 ..Default::default()
2581 };
2582 let data = block_on_browser_timeout(
2583 crate::scrape_local::crawl_http(url, robots, &opts, limit, max_depth, same_host),
2584 0,
2585 )?;
2586 emit_ok(data, json, |d| {
2587 println!(
2588 "ok crawl count={}",
2589 d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)
2590 );
2591 })
2592}
2593
2594fn handle_map(
2595 url: &str,
2596 robots: RobotsPolicy,
2597 limit: usize,
2598 max_depth: usize,
2599 json: bool,
2600) -> Result<(), CliError> {
2601 let data = block_on_browser_timeout(
2602 crate::scrape_local::map_http(url, robots, limit, max_depth),
2603 0,
2604 )?;
2605 emit_ok(data, json, |d| {
2606 println!(
2607 "ok map count={}",
2608 d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)
2609 );
2610 })
2611}
2612
2613fn handle_search(
2614 query: &str,
2615 robots: RobotsPolicy,
2616 limit: usize,
2617 json: bool,
2618) -> Result<(), CliError> {
2619 let data = block_on_browser_timeout(crate::scrape_local::search_http(query, robots, limit), 0)?;
2620 emit_ok(data, json, |d| {
2621 println!(
2622 "ok search count={}",
2623 d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)
2624 );
2625 })
2626}
2627
2628fn handle_parse(path: &Path, redact_pii: bool, json: bool) -> Result<(), CliError> {
2629 let data = crate::scrape_local::parse_file_opts(path, redact_pii)?;
2630 emit_ok(data, json, |d| {
2631 println!(
2632 "ok parse path={}",
2633 d.get("path").and_then(|v| v.as_str()).unwrap_or("")
2634 );
2635 })
2636}
2637
2638fn handle_qr(action: crate::cli::QrAction, json: bool) -> Result<(), CliError> {
2639 let data = match action {
2640 crate::cli::QrAction::Encode { text, format, path } => {
2641 crate::qr_local::encode(&text, &format, path.as_deref())?
2642 }
2643 crate::cli::QrAction::Decode { path } => crate::qr_local::decode(&path)?,
2644 };
2645 emit_ok(data, json, |d| println!("ok qr {d}"))
2646}
2647
2648#[allow(clippy::too_many_arguments)]
2649fn handle_find_paths(
2650 pattern: Option<&str>,
2651 paths: &[String],
2652 extension: Option<&str>,
2653 hidden: bool,
2654 no_ignore: bool,
2655 max_depth: Option<usize>,
2656 entry_type: Option<&str>,
2657 limit: usize,
2658 glob: Option<&str>,
2659 json: bool,
2660) -> Result<(), CliError> {
2661 let opts = crate::find_paths::FindPathsOpts {
2662 pattern: pattern.unwrap_or("").to_string(),
2663 roots: crate::find_paths::roots_from(paths),
2664 extension: extension.map(|s| s.to_string()),
2665 hidden,
2666 no_ignore,
2667 max_depth,
2668 entry_type: entry_type.map(|s| s.to_string()),
2669 limit,
2670 glob: glob.map(|s| s.to_string()),
2671 };
2672 let data = crate::find_paths::find_paths(&opts)?;
2673 emit_ok(data, json, |d| {
2674 println!(
2675 "ok find-paths count={}",
2676 d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)
2677 );
2678 })
2679}
2680
2681fn handle_sg_scan(paths: &[String], limit: usize, json: bool) -> Result<(), CliError> {
2682 let roots: Vec<std::path::PathBuf> = if paths.is_empty() {
2683 vec![std::path::PathBuf::from(".")]
2684 } else {
2685 paths.iter().map(std::path::PathBuf::from).collect()
2686 };
2687 let data = crate::sg_local::sg_scan(&roots, limit)?;
2688 emit_ok(data, json, |d| {
2689 println!(
2690 "ok sg-scan count={}",
2691 d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)
2692 );
2693 })
2694}
2695
2696fn handle_sg_rewrite(paths: &[String], apply: bool, json: bool) -> Result<(), CliError> {
2697 let roots: Vec<std::path::PathBuf> = if paths.is_empty() {
2698 vec![std::path::PathBuf::from(".")]
2699 } else {
2700 paths.iter().map(std::path::PathBuf::from).collect()
2701 };
2702 let data = crate::sg_local::sg_rewrite(&roots, apply)?;
2703 emit_ok(data, json, |d| {
2704 println!(
2705 "ok sg-rewrite apply={} planned={}",
2706 d.get("apply").and_then(|v| v.as_bool()).unwrap_or(false),
2707 d.get("planned").and_then(|v| v.as_u64()).unwrap_or(0)
2708 );
2709 })
2710}
2711
2712fn handle_sheet_write(
2713 input: &std::path::Path,
2714 out: &std::path::Path,
2715 sheet: &str,
2716 json: bool,
2717) -> Result<(), CliError> {
2718 let data = crate::sheet_local::sheet_write(input, out, sheet)?;
2719 emit_ok(data, json, |d| {
2720 println!(
2721 "ok sheet-write path={} rows={}",
2722 d.get("path").and_then(|v| v.as_str()).unwrap_or(""),
2723 d.get("rows").and_then(|v| v.as_u64()).unwrap_or(0)
2724 );
2725 })
2726}
2727
2728fn handle_mitm(action: MitmAction, json: bool) -> Result<(), CliError> {
2729 let data = match action {
2730 MitmAction::Status => crate::mitm_local::status()?,
2731 MitmAction::List { host, limit } => crate::mitm_local::list(host.as_deref(), limit)?,
2732 MitmAction::Get { id } => crate::mitm_local::get(id)?,
2733 MitmAction::Har { out } => crate::mitm_local::export_har(&out)?,
2734 MitmAction::Export { format, out } => {
2735 if format.eq_ignore_ascii_case("har") {
2736 crate::mitm_local::export_har(&out)?
2737 } else {
2738 let path = crate::mitm_local::default_capture_path()?;
2739 let cap = crate::mitm_local::MitmCapture::load(&path, true)?;
2740 let body = serde_json::to_vec_pretty(&serde_json::json!({
2741 "count": cap.items.len(),
2742 "items": cap.items,
2743 }))
2744 .map_err(|e| CliError::new(ErrorKind::Data, format!("export: {e}")))?;
2745 std::fs::write(&out, body)
2746 .map_err(|e| CliError::new(ErrorKind::Io, format!("export write: {e}")))?;
2747 serde_json::json!({
2748 "path": out.display().to_string(),
2749 "format": format,
2750 "count": cap.items.len(),
2751 })
2752 }
2753 }
2754 MitmAction::Domains => crate::mitm_local::domains()?,
2755 MitmAction::Apis { kind } => crate::mitm_local::apis(kind.as_deref())?,
2756 MitmAction::InitCa => crate::mitm_local::ensure_ca()?,
2757 MitmAction::Start { seconds } => {
2758 crate::browser::block_on_browser(crate::mitm_local::start_proxy_oneshot(seconds))?
2759 }
2760 MitmAction::CaptureUrl {
2761 url,
2762 seconds,
2763 har,
2764 hosts,
2765 } => crate::browser::block_on_browser(crate::mitm_local::capture_url_oneshot(
2766 &url,
2767 seconds,
2768 har.as_deref(),
2769 hosts.as_deref(),
2770 ))?,
2771 MitmAction::Graphql { limit } => crate::mitm_local::graphql(limit)?,
2772 MitmAction::Ws { action } => match action {
2773 crate::cli::MitmWsAction::List { limit } => crate::mitm_local::ws_list(limit)?,
2774 crate::cli::MitmWsAction::Get { id } => crate::mitm_local::ws_get(id)?,
2775 },
2776 MitmAction::Block { host, path } => {
2777 crate::mitm_local::block_rule(host.as_deref(), path.as_deref())?
2778 }
2779 MitmAction::Allow { host } => crate::mitm_local::allow_host(&host)?,
2780 MitmAction::Redact { secrets } => crate::mitm_local::redact_policy(secrets)?,
2781 };
2782 emit_ok(data, json, |d| println!("ok mitm {d}"))
2783}
2784
2785fn handle_workflow(action: WorkflowAction, json: bool) -> Result<(), CliError> {
2786 let data = match action {
2787 WorkflowAction::Run { manifest, journal } => {
2788 crate::workflow_local::workflow_run(&manifest, journal.as_deref())?
2789 }
2790 WorkflowAction::Resume { manifest, journal } => {
2791 crate::workflow_local::workflow_resume(&manifest, journal.as_deref())?
2792 }
2793 WorkflowAction::Status { journal, name } => {
2794 crate::workflow_local::workflow_status(journal.as_deref(), name.as_deref())?
2795 }
2796 };
2797 emit_ok(data, json, |d| println!("ok workflow {d}"))
2798}
2799
2800fn handle_config(action: ConfigAction, json: bool) -> Result<(), CliError> {
2801 let data = match action {
2802 ConfigAction::Path => crate::xdg::paths_snapshot()?,
2803 ConfigAction::Init => crate::xdg::init_layout()?,
2804 ConfigAction::Show => crate::xdg::config_get(None)?,
2805 ConfigAction::Set { key, value } => crate::xdg::config_set(&key, &value)?,
2806 ConfigAction::Get { key } => crate::xdg::config_get(key.as_deref())?,
2807 ConfigAction::ListKeys => crate::xdg::config_list_keys()?,
2808 };
2809 emit_ok(data, json, |d| println!("ok config {d}"))
2810}
2811
2812#[allow(clippy::too_many_arguments)]
2813fn handle_emulate(
2814 life: &Lifecycle,
2815 user_agent: Option<&str>,
2816 locale: Option<&str>,
2817 timezone: Option<&str>,
2818 offline: bool,
2819 latitude: Option<f64>,
2820 longitude: Option<f64>,
2821 media: Option<&str>,
2822 network_conditions: Option<&str>,
2823 cpu_throttling_rate: Option<f64>,
2824 color_scheme: Option<&str>,
2825 extra_headers: Option<&str>,
2826 viewport: Option<&str>,
2827 capture: CaptureOpts,
2828 timeout_secs: u64,
2829 json: bool,
2830) -> Result<(), CliError> {
2831 let ua = user_agent.map(|s| s.to_string());
2832 let loc = locale.map(|s| s.to_string());
2833 let tz = timezone.map(|s| s.to_string());
2834 let media = media.map(|s| s.to_string());
2835 let net = network_conditions.map(|s| s.to_string());
2836 let scheme = color_scheme.map(|s| s.to_string());
2837 let headers = extra_headers.map(|s| s.to_string());
2838 let vp = viewport.map(|s| s.to_string());
2839 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2840 let v = session
2841 .emulate(
2842 ua.as_deref(),
2843 loc.as_deref(),
2844 tz.as_deref(),
2845 offline,
2846 latitude,
2847 longitude,
2848 media.as_deref(),
2849 net.as_deref(),
2850 cpu_throttling_rate,
2851 scheme.as_deref(),
2852 headers.as_deref(),
2853 vp.as_deref(),
2854 )
2855 .await?;
2856 Ok((session, v))
2857 })?;
2858 emit_ok(data, json, |_| println!("ok emulate"))
2859}
2860
2861#[allow(clippy::too_many_arguments)]
2862fn handle_resize(
2863 life: &Lifecycle,
2864 width: i32,
2865 height: i32,
2866 scale: f64,
2867 mobile: bool,
2868 capture: CaptureOpts,
2869 timeout_secs: u64,
2870 json: bool,
2871) -> Result<(), CliError> {
2872 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2873 let v = session.resize(width, height, scale, mobile).await?;
2874 Ok((session, v))
2875 })?;
2876 emit_ok(data, json, |_| println!("ok resize {width}x{height}"))
2877}
2878
2879fn handle_perf(
2880 life: &Lifecycle,
2881 action: PerfAction,
2882 capture: CaptureOpts,
2883 timeout_secs: u64,
2884 json: bool,
2885) -> Result<(), CliError> {
2886 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2887 let v = match action {
2888 PerfAction::Start {
2889 path,
2890 reload,
2891 auto_stop,
2892 } => {
2893 session
2894 .perf_start(path.as_deref(), reload, auto_stop)
2895 .await?
2896 }
2897 PerfAction::Stop { path } => session.perf_stop(path.as_deref()).await?,
2898 PerfAction::Insight {
2899 name,
2900 insight_set_id,
2901 insight_name,
2902 } => {
2903 let resolved = insight_name.or(name);
2904 session
2905 .perf_insight(resolved.as_deref(), insight_set_id.as_deref())
2906 .await?
2907 }
2908 };
2909 Ok((session, v))
2910 })?;
2911 emit_ok(data, json, |d| println!("ok perf {d}"))
2912}
2913
2914#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2916pub(crate) enum LighthouseSource {
2917 Flag,
2919 Xdg,
2921 Path,
2923 Mock,
2925}
2926
2927impl LighthouseSource {
2928 fn as_str(self) -> &'static str {
2929 match self {
2930 Self::Flag => "flag",
2931 Self::Xdg => "xdg",
2932 Self::Path => "path",
2933 Self::Mock => "mock",
2934 }
2935 }
2936}
2937
2938pub(crate) fn resolve_lighthouse_binary(
2940 cli_path: Option<&Path>,
2941) -> Result<(std::path::PathBuf, LighthouseSource), CliError> {
2942 if let Some(p) = cli_path {
2943 if p.is_file() {
2944 let source = if p
2945 .file_name()
2946 .and_then(|n| n.to_str())
2947 .is_some_and(|n| n.contains("mock-lighthouse"))
2948 {
2949 LighthouseSource::Mock
2950 } else {
2951 LighthouseSource::Flag
2952 };
2953 return Ok((p.to_path_buf(), source));
2954 }
2955 return Err(CliError::with_suggestion(
2956 ErrorKind::Usage,
2957 format!("lighthouse path not found: {}", p.display()),
2958 "Pass an absolute executable path to --lighthouse-path",
2959 ));
2960 }
2961 if let Some(xdg) = crate::xdg::lighthouse_path_from_config().filter(|s| !s.is_empty()) {
2962 let p = Path::new(&xdg);
2963 if p.is_file() {
2964 let source = if xdg.contains("mock-lighthouse") {
2965 LighthouseSource::Mock
2966 } else {
2967 LighthouseSource::Xdg
2968 };
2969 return Ok((p.to_path_buf(), source));
2970 }
2971 }
2972 if let Some(p) = which_lighthouse() {
2973 return Ok((Path::new(&p).to_path_buf(), LighthouseSource::Path));
2974 }
2975 Err(CliError::with_suggestion(
2976 ErrorKind::Unavailable,
2977 "lighthouse binary not found on PATH or XDG lighthouse_path",
2978 crate::i18n::suggestion_key("lighthouse_missing", None),
2979 ))
2980}
2981
2982fn which_lighthouse() -> Option<String> {
2983 std::env::var_os("PATH").and_then(|paths| {
2984 for dir in std::env::split_paths(&paths) {
2985 let candidate = dir.join("lighthouse");
2986 if candidate.is_file() {
2987 return Some(candidate.display().to_string());
2988 }
2989 #[cfg(windows)]
2990 {
2991 let candidate = dir.join("lighthouse.cmd");
2992 if candidate.is_file() {
2993 return Some(candidate.display().to_string());
2994 }
2995 }
2996 }
2997 None
2998 })
2999}
3000
3001pub(crate) fn lighthouse_to_value(
3003 url: &str,
3004 out_dir: Option<&Path>,
3005 device: &str,
3006 mode: &str,
3007 lighthouse_path: Option<&Path>,
3008) -> Result<serde_json::Value, CliError> {
3009 let (bin_path, binary_source) = resolve_lighthouse_binary(lighthouse_path)?;
3010 let bin = bin_path.display().to_string();
3011 let out = out_dir.map(|p| p.to_path_buf()).unwrap_or_else(|| {
3012 crate::xdg::cache_dir()
3013 .unwrap_or_else(|_| std::env::temp_dir().join("browser-automation-cli"))
3014 .join("lighthouse")
3015 });
3016 std::fs::create_dir_all(&out)
3017 .map_err(|e| CliError::new(ErrorKind::Io, format!("lighthouse out-dir: {e}")))?;
3018 let form_factor = if device.eq_ignore_ascii_case("mobile") {
3019 "mobile"
3020 } else {
3021 "desktop"
3022 };
3023 let mode_norm = if mode.eq_ignore_ascii_case("snapshot") {
3024 "snapshot"
3025 } else if mode.eq_ignore_ascii_case("navigation") || mode.is_empty() {
3026 "navigation"
3027 } else {
3028 return Err(CliError::with_suggestion(
3029 ErrorKind::Usage,
3030 format!("unsupported lighthouse mode: {mode}"),
3031 "Use --mode navigation or --mode snapshot",
3032 ));
3033 };
3034 let html_path = out.join("report.html");
3036 let json_path = out.join("report.json");
3037 let mut cmd = std::process::Command::new(&bin);
3038 cmd.arg(url)
3039 .arg("--quiet")
3040 .arg("--output=html")
3041 .arg("--output=json")
3042 .arg(format!("--output-path={}", out.join("report").display()))
3043 .arg(format!("--form-factor={form_factor}"))
3044 .arg("--chrome-flags=--headless=new")
3045 .arg("--only-categories=accessibility,seo,best-practices");
3046 if mode_norm == "snapshot" {
3047 cmd.arg("--gather-mode=snapshot");
3049 }
3050 let output = cmd.output().map_err(|e| {
3051 CliError::with_suggestion(
3052 ErrorKind::Unavailable,
3053 format!("lighthouse spawn failed: {e}"),
3054 crate::i18n::suggestion_key("lighthouse_missing", None),
3055 )
3056 })?;
3057 if !output.status.success() {
3058 let stderr = String::from_utf8_lossy(&output.stderr);
3059 return Err(CliError::with_suggestion(
3060 ErrorKind::Software,
3061 format!("lighthouse exited non-zero: {stderr}"),
3062 "Check URL and lighthouse install",
3063 ));
3064 }
3065 let report_html = if html_path.exists() {
3067 html_path.clone()
3068 } else if out.join("report.report.html").exists() {
3069 out.join("report.report.html")
3070 } else {
3071 html_path.clone()
3072 };
3073 let report_json = if json_path.exists() {
3074 json_path.clone()
3075 } else if out.join("report.report.json").exists() {
3076 out.join("report.report.json")
3077 } else {
3078 out.join("report.json")
3080 };
3081
3082 let mut scores = Vec::new();
3083 let mut passed_audits = 0u64;
3084 let mut failed_audits = 0u64;
3085 if report_json.exists() {
3086 if let Ok(raw) = std::fs::read_to_string(&report_json) {
3087 if let Ok(lhr) = serde_json::from_str::<serde_json::Value>(&raw) {
3088 if let Some(cats) = lhr.get("categories").and_then(|c| c.as_object()) {
3089 for (id, cat) in cats {
3090 scores.push(serde_json::json!({
3091 "id": id,
3092 "title": cat.get("title").and_then(|t| t.as_str()).unwrap_or(id),
3093 "score": cat.get("score"),
3094 }));
3095 }
3096 }
3097 if let Some(audits) = lhr.get("audits").and_then(|a| a.as_object()) {
3098 for a in audits.values() {
3099 if let Some(sc) = a.get("score").and_then(|s| s.as_f64()) {
3100 if sc < 1.0 {
3101 failed_audits += 1;
3102 } else {
3103 passed_audits += 1;
3104 }
3105 }
3106 }
3107 }
3108 }
3109 }
3110 }
3111
3112 Ok(serde_json::json!({
3113 "lighthouse": true,
3114 "url": url,
3115 "device": form_factor,
3116 "mode": mode_norm,
3117 "binary": bin,
3118 "binary_source": binary_source.as_str(),
3119 "binary_present": true,
3120 "out_dir": out.to_string_lossy(),
3121 "reports": {
3122 "html": report_html.to_string_lossy(),
3123 "json": report_json.to_string_lossy(),
3124 },
3125 "scores": scores,
3126 "passed_audits": passed_audits,
3127 "failed_audits": failed_audits,
3128 }))
3129}
3130
3131fn handle_lighthouse(
3132 url: &str,
3133 out_dir: Option<&Path>,
3134 device: &str,
3135 mode: &str,
3136 lighthouse_path: Option<&Path>,
3137 json: bool,
3138) -> Result<(), CliError> {
3139 let data = lighthouse_to_value(url, out_dir, device, mode, lighthouse_path)?;
3140 emit_ok(data, json, |d| {
3141 println!(
3142 "ok lighthouse report={}",
3143 d.pointer("/reports/html")
3144 .and_then(|v| v.as_str())
3145 .unwrap_or("")
3146 );
3147 })
3148}
3149
3150fn handle_screencast(
3151 life: &Lifecycle,
3152 action: ScreencastAction,
3153 capture: CaptureOpts,
3154 timeout_secs: u64,
3155 json: bool,
3156) -> Result<(), CliError> {
3157 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
3158 let v = match action {
3159 ScreencastAction::Start { path } => session.screencast_start(path.as_deref()).await?,
3160 ScreencastAction::Stop { path } => session.screencast_stop(path.as_deref()).await?,
3161 };
3162 Ok((session, v))
3163 })?;
3164 emit_ok(data, json, |d| println!("ok screencast {d}"))
3165}
3166
3167fn handle_heap(
3168 life: &Lifecycle,
3169 action: HeapAction,
3170 capture: CaptureOpts,
3171 timeout_secs: u64,
3172 json: bool,
3173) -> Result<(), CliError> {
3174 match action {
3175 HeapAction::Take { path } => {
3176 let data =
3177 with_session_blank(life, capture, timeout_secs, move |mut session| async move {
3178 let v = session.heap_take(&path).await?;
3179 Ok((session, v))
3180 })?;
3181 emit_ok(data, json, |d| {
3182 println!(
3183 "ok heap take path={}",
3184 d.get("path").and_then(|v| v.as_str()).unwrap_or("")
3185 );
3186 })
3187 }
3188 HeapAction::Close { path } => {
3189 let data = OneShotSession::heap_close(&path)?;
3190 emit_ok(data, json, |_| {
3191 println!("ok heap close path={}", path.display())
3192 })
3193 }
3194 HeapAction::Compare {
3195 base,
3196 current,
3197 class_index,
3198 } => {
3199 let mut data = OneShotSession::heap_compare(&base, ¤t)?;
3200 if let Some(ci) = class_index {
3201 if let Some(obj) = data.as_object_mut() {
3202 obj.insert("class_index".into(), serde_json::json!(ci));
3203 }
3204 }
3205 emit_ok(data, json, |d| println!("ok heap compare {d}"))
3206 }
3207 HeapAction::Summary { path } => {
3208 let data = OneShotSession::heap_file_summary(&path)?;
3209 emit_ok(data, json, |d| println!("ok heap summary {d}"))
3210 }
3211 HeapAction::Details {
3212 path,
3213 filter_name,
3214 page_idx,
3215 page_size,
3216 } => {
3217 validate_heap_filter_name(filter_name.as_deref())?;
3218 let mut data = OneShotSession::heap_details(&path)?;
3219 paginate_filter_json(
3220 &mut data,
3221 "classes",
3222 filter_name.as_deref(),
3223 page_idx,
3224 page_size,
3225 );
3226 emit_ok(data, json, |d| println!("ok heap details {d}"))
3227 }
3228 HeapAction::DupStrings {
3229 path,
3230 page_idx,
3231 page_size,
3232 } => {
3233 let mut data = OneShotSession::heap_dup_strings(&path)?;
3234 paginate_filter_json(&mut data, "strings", None, page_idx, page_size);
3235 emit_ok(data, json, |d| println!("ok heap dup-strings {d}"))
3236 }
3237 HeapAction::ClassNodes {
3238 path,
3239 id,
3240 filter_name,
3241 page_idx,
3242 page_size,
3243 } => {
3244 validate_heap_filter_name(filter_name.as_deref())?;
3245 let mut data = OneShotSession::heap_class_nodes(&path, id)?;
3246 paginate_filter_json(
3247 &mut data,
3248 "nodes",
3249 filter_name.as_deref(),
3250 page_idx,
3251 page_size,
3252 );
3253 emit_ok(data, json, |d| println!("ok heap class-nodes {d}"))
3254 }
3255 HeapAction::Dominators { path, node } => {
3256 let data = OneShotSession::heap_node_op(&path, node, "dominators")?;
3257 emit_ok(data, json, |d| println!("ok heap dominators {d}"))
3258 }
3259 HeapAction::Edges {
3260 path,
3261 node,
3262 page_idx,
3263 page_size,
3264 } => {
3265 let mut data = OneShotSession::heap_node_op(&path, node, "edges")?;
3266 paginate_filter_json(&mut data, "edges", None, page_idx, page_size);
3267 emit_ok(data, json, |d| println!("ok heap edges {d}"))
3268 }
3269 HeapAction::Retainers {
3270 path,
3271 node,
3272 page_idx,
3273 page_size,
3274 } => {
3275 let mut data = OneShotSession::heap_node_op(&path, node, "retainers")?;
3276 paginate_filter_json(&mut data, "retainers", None, page_idx, page_size);
3277 emit_ok(data, json, |d| println!("ok heap retainers {d}"))
3278 }
3279 HeapAction::Paths {
3280 path,
3281 node,
3282 max_depth,
3283 max_nodes,
3284 max_siblings,
3285 } => {
3286 let data = crate::native::heap_snapshot::node_op_with_limits(
3287 &path,
3288 node,
3289 "paths",
3290 max_depth as usize,
3291 max_siblings.unwrap_or(32) as usize,
3292 max_nodes.unwrap_or(200) as usize,
3293 200,
3294 )
3295 .map_err(|e| {
3296 CliError::with_suggestion(
3297 ErrorKind::Io,
3298 e,
3299 "Pass a valid .heapsnapshot path and node id",
3300 )
3301 })?;
3302 emit_ok(data, json, |d| println!("ok heap paths {d}"))
3303 }
3304 HeapAction::ObjectDetails { path, node } => {
3305 let data = OneShotSession::heap_object_details(&path, node)?;
3306 emit_ok(data, json, |d| println!("ok heap object-details {d}"))
3307 }
3308 }
3309}
3310
3311const HEAP_FILTER_NAME_ENUM: &[&str] = &[
3313 "objectsRetainedByDetachedDomNodes",
3314 "objectsRetainedByConsole",
3315 "objectsRetainedByEventHandlers",
3316 "objectsRetainedByContexts",
3317];
3318
3319fn validate_heap_filter_name(filter_name: Option<&str>) -> Result<(), CliError> {
3320 let Some(f) = filter_name else {
3321 return Ok(());
3322 };
3323 if f.starts_with("objectsRetained")
3325 && !HEAP_FILTER_NAME_ENUM
3326 .iter()
3327 .any(|e| e.eq_ignore_ascii_case(f))
3328 {
3329 return Err(CliError::with_suggestion(
3330 ErrorKind::Usage,
3331 format!("invalid heap --filter-name enum: {f}"),
3332 "Use objectsRetainedByDetachedDomNodes|objectsRetainedByConsole|objectsRetainedByEventHandlers|objectsRetainedByContexts or free-text substring",
3333 ));
3334 }
3335 Ok(())
3336}
3337
3338fn paginate_filter_json(
3340 data: &mut serde_json::Value,
3341 array_key: &str,
3342 filter_name: Option<&str>,
3343 page_idx: Option<usize>,
3344 page_size: Option<usize>,
3345) {
3346 let key = {
3347 if data.get(array_key).and_then(|v| v.as_array()).is_some() {
3348 array_key.to_string()
3349 } else {
3350 let mut found = None;
3351 for alt in ["items", "results", "list"] {
3352 if data.get(alt).and_then(|v| v.as_array()).is_some() {
3353 found = Some(alt.to_string());
3354 break;
3355 }
3356 }
3357 match found {
3358 Some(k) => k,
3359 None => return,
3360 }
3361 }
3362 };
3363
3364 let is_enum_filter = filter_name
3365 .map(|f| {
3366 HEAP_FILTER_NAME_ENUM
3367 .iter()
3368 .any(|e| e.eq_ignore_ascii_case(f))
3369 })
3370 .unwrap_or(false);
3371
3372 if is_enum_filter {
3373 if let Some(obj) = data.as_object_mut() {
3376 obj.insert(
3377 "filter_name".into(),
3378 serde_json::json!(filter_name.unwrap()),
3379 );
3380 obj.insert(
3381 "filter_applied".into(),
3382 serde_json::json!("enum_recorded_offline_not_recomputed"),
3383 );
3384 }
3385 }
3386
3387 let Some(arr) = data.get_mut(&key).and_then(|v| v.as_array_mut()) else {
3388 return;
3389 };
3390 if let Some(f) = filter_name {
3391 if !is_enum_filter {
3392 let f_low = f.to_ascii_lowercase();
3393 arr.retain(|item| {
3394 item.get("name")
3395 .or_else(|| item.get("class_name"))
3396 .or_else(|| item.get("string"))
3397 .and_then(|v| v.as_str())
3398 .map(|s| s.to_ascii_lowercase().contains(&f_low))
3399 .unwrap_or(true)
3400 });
3401 }
3402 }
3403 let total = arr.len();
3404 let page = page_idx.unwrap_or(0);
3405 let size = page_size.unwrap_or(total.max(1));
3406 let start = page.saturating_mul(size).min(total);
3407 let end = (start + size).min(total);
3408 let page_items: Vec<serde_json::Value> = arr[start..end].to_vec();
3409 *arr = page_items;
3410 if let Some(obj) = data.as_object_mut() {
3411 obj.insert("total".into(), serde_json::json!(total));
3412 obj.insert("page_idx".into(), serde_json::json!(page));
3413 obj.insert("page_size".into(), serde_json::json!(size));
3414 }
3415}
3416
3417fn handle_extension(
3418 life: &Lifecycle,
3419 action: ExtensionAction,
3420 capture: CaptureOpts,
3421 timeout_secs: u64,
3422 json: bool,
3423) -> Result<(), CliError> {
3424 match action {
3425 ExtensionAction::List => {
3426 let data =
3427 with_session_blank(life, capture, timeout_secs, move |mut session| async move {
3428 let v = session.extension_list().await?;
3429 Ok((session, v))
3430 })?;
3431 emit_ok(data, json, |d| println!("ok extension list {d}"))
3432 }
3433 ExtensionAction::Install { path } => {
3434 let path_s = path.display().to_string();
3435 let data = block_on_browser_timeout(
3436 async move {
3437 let mut session =
3438 OneShotSession::launch_with_extensions(capture, vec![path_s.clone()])
3439 .await?;
3440 if let Ok(mut ledger) = life.ledger.lock() {
3441 ledger.chrome_launched = true;
3442 ledger.chrome_pid = session.chrome_pid();
3443 }
3444 let mut listed = session.extension_list().await?;
3446 for _ in 0..20 {
3447 let count = listed.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
3448 if count > 0 {
3449 break;
3450 }
3451 tokio::time::sleep(std::time::Duration::from_millis(150)).await;
3452 listed = session.extension_list().await?;
3453 }
3454 let close = session.shutdown().await;
3455 if let Ok(mut ledger) = life.ledger.lock() {
3456 ledger.chrome_launched = false;
3457 ledger.chrome_pid = None;
3458 }
3459 close?;
3460 Ok(serde_json::json!({
3461 "installed_path": path_s,
3462 "load_extension": true,
3463 "targets": listed,
3464 "note": "one-shot: Chrome launched with --load-extension for this process only",
3465 }))
3466 },
3467 timeout_secs,
3468 )?;
3469 emit_ok(data, json, |d| println!("ok extension install {d}"))
3470 }
3471 ExtensionAction::Reload { id, path } => {
3472 let id = id.clone();
3473 let path_s = path.as_ref().map(|p| p.display().to_string());
3474 let data = block_on_browser_timeout(
3475 async move {
3476 let mut session = if let Some(p) = path_s {
3477 OneShotSession::launch_with_extensions(capture, vec![p]).await?
3478 } else {
3479 OneShotSession::launch_headless_with_capture(capture).await?
3480 };
3481 if let Ok(mut ledger) = life.ledger.lock() {
3482 ledger.chrome_launched = true;
3483 ledger.chrome_pid = session.chrome_pid();
3484 }
3485 let v = session.extension_reload(&id).await;
3486 let close = session.shutdown().await;
3487 if let Ok(mut ledger) = life.ledger.lock() {
3488 ledger.chrome_launched = false;
3489 ledger.chrome_pid = None;
3490 }
3491 close?;
3492 v
3493 },
3494 timeout_secs,
3495 )?;
3496 emit_ok(data, json, |d| println!("ok extension reload {d}"))
3497 }
3498 ExtensionAction::Trigger { id, path } => {
3499 let id = id.clone();
3500 let path_s = path.as_ref().map(|p| p.display().to_string());
3501 let data = block_on_browser_timeout(
3502 async move {
3503 let mut session = if let Some(p) = path_s {
3504 OneShotSession::launch_with_extensions(capture, vec![p]).await?
3505 } else {
3506 OneShotSession::launch_headless_with_capture(capture).await?
3507 };
3508 if let Ok(mut ledger) = life.ledger.lock() {
3509 ledger.chrome_launched = true;
3510 ledger.chrome_pid = session.chrome_pid();
3511 }
3512 let v = session.extension_trigger(&id).await;
3513 let close = session.shutdown().await;
3514 if let Ok(mut ledger) = life.ledger.lock() {
3515 ledger.chrome_launched = false;
3516 ledger.chrome_pid = None;
3517 }
3518 close?;
3519 v
3520 },
3521 timeout_secs,
3522 )?;
3523 emit_ok(data, json, |d| println!("ok extension trigger {d}"))
3524 }
3525 ExtensionAction::Uninstall { id } => {
3526 let id = id.clone();
3527 let id_print = id.clone();
3528 let data = block_on_browser_timeout(
3530 async move {
3531 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
3532 if let Ok(mut ledger) = life.ledger.lock() {
3533 ledger.chrome_launched = true;
3534 ledger.chrome_pid = session.chrome_pid();
3535 if let Some(dir) = session.temp_user_data_dir() {
3536 ledger.profile_dir = Some(dir);
3537 }
3538 }
3539 let v = session.extension_uninstall(&id).await;
3540 let close = session.shutdown().await;
3541 if let Ok(mut ledger) = life.ledger.lock() {
3542 ledger.chrome_launched = false;
3543 ledger.chrome_pid = None;
3544 ledger.profile_dir = None;
3545 }
3546 close?;
3547 v
3548 },
3549 timeout_secs,
3550 )?;
3551 emit_ok(data, json, |d| {
3552 let effect = d.get("effect").and_then(|v| v.as_str()).unwrap_or("?");
3553 println!("ok extension uninstall id={id_print} effect={effect}")
3554 })
3555 }
3556 }
3557}
3558
3559fn handle_devtools3p(
3560 life: &Lifecycle,
3561 action: Devtools3pAction,
3562 capture: CaptureOpts,
3563 timeout_secs: u64,
3564 json: bool,
3565) -> Result<(), CliError> {
3566 match action {
3567 Devtools3pAction::List { url } => {
3568 let url = url.unwrap_or_else(|| "about:blank".into());
3569 let data =
3570 with_session_blank(life, capture, timeout_secs, move |mut session| async move {
3571 if url != "about:blank" {
3572 let _ = session
3573 .goto(&url, crate::robots::RobotsPolicy::Ignore)
3574 .await?;
3575 }
3576 let v = session.devtools3p_list().await?;
3577 Ok((session, v))
3578 })?;
3579 emit_ok(data, json, |d| println!("ok devtools3p list {d}"))
3580 }
3581 Devtools3pAction::Exec { name, params, url } => {
3582 let url = url.unwrap_or_else(|| "about:blank".into());
3583 let params = params.clone();
3584 let name = name.clone();
3585 let data =
3586 with_session_blank(life, capture, timeout_secs, move |mut session| async move {
3587 if url != "about:blank" {
3588 let _ = session
3589 .goto(&url, crate::robots::RobotsPolicy::Ignore)
3590 .await?;
3591 }
3592 let v = session.devtools3p_exec(&name, params.as_deref()).await?;
3593 Ok((session, v))
3594 })?;
3595 emit_ok(data, json, |d| println!("ok devtools3p exec {d}"))
3596 }
3597 }
3598}
3599
3600fn handle_webmcp(
3601 life: &Lifecycle,
3602 action: WebmcpAction,
3603 capture: CaptureOpts,
3604 timeout_secs: u64,
3605 json: bool,
3606) -> Result<(), CliError> {
3607 match action {
3608 WebmcpAction::List { url } => {
3609 let url = url.unwrap_or_else(|| "about:blank".into());
3610 let data =
3611 with_session_blank(life, capture, timeout_secs, move |mut session| async move {
3612 if url != "about:blank" {
3613 let _ = session
3614 .goto(&url, crate::robots::RobotsPolicy::Ignore)
3615 .await?;
3616 }
3617 let v = session.webmcp_list().await?;
3618 Ok((session, v))
3619 })?;
3620 emit_ok(data, json, |d| println!("ok webmcp list {d}"))
3621 }
3622 WebmcpAction::Exec { name, input, url } => {
3623 let url = url.unwrap_or_else(|| "about:blank".into());
3624 let name = name.clone();
3625 let input = input.clone();
3626 let data =
3627 with_session_blank(life, capture, timeout_secs, move |mut session| async move {
3628 if url != "about:blank" {
3629 let _ = session
3630 .goto(&url, crate::robots::RobotsPolicy::Ignore)
3631 .await?;
3632 }
3633 let v = session.webmcp_exec(&name, input.as_deref()).await?;
3634 Ok((session, v))
3635 })?;
3636 emit_ok(data, json, |d| println!("ok webmcp exec {d}"))
3637 }
3638 }
3639}
3640
3641fn handle_completions(shell: CompletionShell) -> Result<(), CliError> {
3642 use clap::CommandFactory;
3643 use clap_complete::{generate, shells};
3644 use std::io::Write;
3645
3646 let mut cmd = crate::cli::Cli::command();
3647 let bin = "browser-automation-cli";
3648 let mut out = std::io::stdout();
3649 match shell {
3650 CompletionShell::Bash => generate(shells::Bash, &mut cmd, bin, &mut out),
3651 CompletionShell::Zsh => generate(shells::Zsh, &mut cmd, bin, &mut out),
3652 CompletionShell::Fish => generate(shells::Fish, &mut cmd, bin, &mut out),
3653 CompletionShell::Elvish => generate(shells::Elvish, &mut cmd, bin, &mut out),
3654 CompletionShell::Powershell => generate(shells::PowerShell, &mut cmd, bin, &mut out),
3655 }
3656 let _ = out.flush();
3657 Ok(())
3658}
3659
3660#[cfg(test)]
3661mod lighthouse_tests {
3662 use super::*;
3663 use std::path::PathBuf;
3664
3665 #[test]
3666 fn mock_lighthouse_parses_scores() {
3667 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
3668 let mock = root.join("scripts/mock-lighthouse.sh");
3669 if !mock.is_file() {
3670 eprintln!("skip: mock-lighthouse.sh missing");
3671 return;
3672 }
3673 #[cfg(unix)]
3674 {
3675 use std::os::unix::fs::PermissionsExt;
3676 let _ = std::fs::set_permissions(&mock, std::fs::Permissions::from_mode(0o755));
3677 }
3678 let out = tempfile::tempdir().expect("tmp");
3679 let v = lighthouse_to_value(
3680 "https://example.com",
3681 Some(out.path()),
3682 "desktop",
3683 "navigation",
3684 Some(&mock),
3685 )
3686 .expect("mock lighthouse");
3687 assert_eq!(
3688 v.get("binary_source").and_then(|s| s.as_str()),
3689 Some("mock")
3690 );
3691 assert_eq!(
3692 v.get("binary_present").and_then(|b| b.as_bool()),
3693 Some(true)
3694 );
3695 let scores = v
3696 .get("scores")
3697 .and_then(|s| s.as_array())
3698 .cloned()
3699 .unwrap_or_default();
3700 assert!(!scores.is_empty(), "expected scores from mock LHR, got {v}");
3701 }
3702
3703 #[test]
3704 fn resolve_missing_is_unavailable() {
3705 let err =
3706 resolve_lighthouse_binary(Some(Path::new("/no/such/lighthouse-bin-xyz"))).unwrap_err();
3707 assert_eq!(err.kind(), ErrorKind::Usage);
3708 }
3709}