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