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