1use std::path::Path;
11
12use serde_json::{json, Value};
13
14use crate::browser::{CaptureOpts, OneShotSession};
15use crate::error::{CliError, ErrorKind};
16use crate::lifecycle::Lifecycle;
17use crate::robots::RobotsPolicy;
18
19pub const RUN_DISPATCHED_CMDS: &[&str] = &[
21 "goto",
22 "wait",
23 "hover",
24 "drag",
25 "fill-form",
26 "fill_form",
27 "select-option",
28 "select_option",
29 "pick",
30 "upload",
31 "back",
32 "forward",
33 "reload",
34 "view",
35 "press",
36 "write",
37 "keys",
38 "type",
39 "click-at",
40 "click_at",
41 "eval",
42 "grab",
43 "print-pdf",
44 "print_pdf",
45 "extract",
46 "text",
47 "scroll",
48 "cookie",
49 "attr",
50 "assert",
51 "console",
52 "net",
53 "page",
54 "dialog",
55 "scrape",
56 "emulate",
57 "resize",
58 "perf",
59 "lighthouse",
60 "screencast",
61 "heap",
62 "extension",
63 "devtools3p-list",
64 "devtools3p-exec",
65 "devtools3p_list",
66 "devtools3p_exec",
67 "webmcp-list",
68 "webmcp-exec",
69 "webmcp_list",
70 "webmcp_exec",
71];
72
73pub const INTENTIONAL_RUN_EXCLUDE: &[(&str, &str)] = &[
76 (
77 "extension-install",
78 "install requires Chrome relaunch with --load-extension; use top-level extension install",
79 ),
80 (
81 "extension-uninstall",
82 "uninstall is top-level one-shot; use extension uninstall outside run",
83 ),
84 (
85 "doctor",
86 "meta command; not a browser step",
87 ),
88 (
89 "commands",
90 "meta discovery; not a browser step",
91 ),
92 (
93 "schema",
94 "meta discovery; not a browser step",
95 ),
96 (
97 "version",
98 "meta; not a browser step",
99 ),
100 (
101 "config",
102 "XDG config; not a browser step",
103 ),
104 (
105 "completions",
106 "shell completions; not a browser step",
107 ),
108 (
109 "man",
110 "man page generation; not a browser step",
111 ),
112 (
113 "mitm",
114 "MITM is a separate one-shot surface; use mitm capture-url or --mitm with browser cmds",
115 ),
116 (
117 "workflow",
118 "workflow journal is top-level; not an in-session browser step",
119 ),
120 (
121 "batch-scrape",
122 "batch-scrape is top-level HTTP/browser pool; use scrape steps or top-level batch-scrape",
123 ),
124 (
125 "crawl",
126 "crawl is top-level; use top-level crawl or multi-step goto/scrape",
127 ),
128 (
129 "map",
130 "map is top-level discovery; not an in-session step",
131 ),
132 (
133 "search",
134 "search is top-level; not an in-session step",
135 ),
136 (
137 "parse",
138 "path-light parse; not a browser session step",
139 ),
140 (
141 "qr",
142 "path-light QR; not a browser session step",
143 ),
144 (
145 "find-paths",
146 "path-light discovery; not a browser session step",
147 ),
148 (
149 "sg-scan",
150 "path-light structural scan; not a browser session step",
151 ),
152 (
153 "sg-rewrite",
154 "path-light rewrite; not a browser session step",
155 ),
156 (
157 "sheet-write",
158 "path-light sheet; not a browser session step",
159 ),
160 (
161 "monitor",
162 "monitor check is top-level one-shot",
163 ),
164 (
165 "run",
166 "nested run is not supported",
167 ),
168 (
169 "exec",
170 "nested exec is not supported",
171 ),
172];
173
174pub fn run_supported_suggestion() -> String {
176 format!("Supported: {}", RUN_DISPATCHED_CMDS.join(" "))
177}
178
179pub fn parse_run_script(text: &str) -> Result<Vec<Value>, CliError> {
186 let text = crate::json_util::strip_utf8_bom(text);
187 let trimmed = text.trim();
188 if trimmed.is_empty() {
189 return Ok(Vec::new());
190 }
191
192 if trimmed.starts_with('[') {
194 match crate::json_util::value_from_str(trimmed) {
195 Ok(Value::Array(items)) => {
196 return normalize_step_values(items, "script array");
197 }
198 Ok(_) => {
199 return Err(CliError::with_suggestion(
200 ErrorKind::Data,
201 "script starts with '[' but is not a JSON array of step objects",
202 "Use [{\"cmd\":\"goto\",\"url\":\"…\"}, …] or NDJSON one object per line",
203 ));
204 }
205 Err(e) => {
206 if !trimmed.contains('\n') {
209 return Err(CliError::with_suggestion(
210 ErrorKind::Data,
211 format!("invalid JSON array script: {e}"),
212 "Each array element must be an object with \"cmd\" or \"action\"",
213 ));
214 }
215 }
216 }
217 }
218
219 let mut steps: Vec<Value> = Vec::new();
220 for (lineno, line) in text.lines().enumerate() {
221 let line = line.trim();
222 if line.is_empty() || line.starts_with('#') {
223 continue;
224 }
225 crate::json_util::check_ndjson_line_len(line, lineno + 1)?;
226 let v: Value = crate::json_util::value_from_str(line).map_err(|e| {
227 CliError::with_suggestion(
228 ErrorKind::Data,
229 format!("script line {}: invalid JSON: {e}", lineno + 1),
230 "Each non-empty line must be one JSON object with \"cmd\", or use a JSON array file",
231 )
232 })?;
233 match v {
234 Value::Array(items) if steps.is_empty() && lineno == 0 => {
235 return normalize_step_values(items, &format!("script line {}", lineno + 1));
237 }
238 Value::Array(_) => {
239 return Err(CliError::with_suggestion(
240 ErrorKind::Data,
241 format!(
242 "script line {}: nested JSON array not allowed in NDJSON mode",
243 lineno + 1
244 ),
245 "Use either one JSON array for the whole file, or one object per line",
246 ));
247 }
248 other => steps.push(other),
249 }
250 }
251 normalize_step_values(steps, "script")
252}
253
254fn normalize_step_values(items: Vec<Value>, ctx: &str) -> Result<Vec<Value>, CliError> {
255 let mut out = Vec::with_capacity(items.len());
256 for (i, v) in items.into_iter().enumerate() {
257 if !v.is_object() {
258 return Err(CliError::with_suggestion(
259 ErrorKind::Data,
260 format!("{ctx} step {i}: expected object with \"cmd\""),
261 "Example: {\"cmd\":\"goto\",\"url\":\"https://example.com\"}",
262 ));
263 }
264 out.push(v);
265 }
266 Ok(out)
267}
268
269fn step_beforeunload_action(step: &Value) -> Option<&'static str> {
273 let v = step
274 .get("handle_before_unload")
275 .or_else(|| step.get("handleBeforeUnload"));
276 match v {
277 Some(Value::Bool(true)) => Some("accept"),
278 Some(Value::Bool(false)) => None,
279 Some(Value::String(s)) => {
280 let s = s.trim().to_ascii_lowercase();
281 match s.as_str() {
282 "accept" | "true" | "1" | "yes" => Some("accept"),
283 "dismiss" | "cancel" => Some("dismiss"),
284 "off" | "false" | "0" | "no" | "none" => None,
285 _ => Some("accept"),
286 }
287 }
288 _ => None,
289 }
290}
291
292#[allow(dead_code)]
294fn step_wants_beforeunload_handle(step: &Value) -> bool {
295 step_beforeunload_action(step).is_some()
296}
297
298#[cfg(test)]
299mod parse_script_tests {
300 use super::*;
301
302 #[test]
303 fn parses_ndjson_objects() {
304 let text = r#"{"cmd":"goto","url":"https://example.com"}
305{"cmd":"eval","expression":"1+1"}
306"#;
307 let steps = parse_run_script(text).unwrap();
308 assert_eq!(steps.len(), 2);
309 assert_eq!(steps[0]["cmd"], "goto");
310 }
311
312 #[test]
313 fn parses_json_array() {
314 let text = r#"[
315 {"cmd":"goto","url":"https://example.com"},
316 {"cmd":"reload","ignore_cache":true}
317]"#;
318 let steps = parse_run_script(text).unwrap();
319 assert_eq!(steps.len(), 2);
320 assert_eq!(steps[1]["cmd"], "reload");
321 }
322
323 #[test]
324 fn parses_single_line_array() {
325 let text = r#"[{"cmd":"goto","url":"about:blank"}]"#;
326 let steps = parse_run_script(text).unwrap();
327 assert_eq!(steps.len(), 1);
328 }
329
330 #[test]
331 fn parses_ndjson_with_utf8_bom() {
332 let text = "\u{FEFF}{\"cmd\":\"goto\",\"url\":\"https://example.com\"}\n";
333 let steps = parse_run_script(text).unwrap();
334 assert_eq!(steps.len(), 1);
335 assert_eq!(steps[0]["cmd"], "goto");
336 }
337
338 #[test]
339 fn rejects_oversized_ndjson_line() {
340 let huge = format!(
341 "{{\"cmd\":\"eval\",\"expression\":\"{}\"}}",
342 "x".repeat(crate::json_util::MAX_NDJSON_LINE_BYTES)
343 );
344 assert!(parse_run_script(&huge).is_err());
345 }
346}
347
348#[derive(Debug, Clone, Copy, Default)]
350pub struct RunFlags {
351 pub experimental_vision: bool,
352 pub experimental_screencast: bool,
353 pub category_memory: bool,
354 pub category_extensions: bool,
355 pub category_third_party: bool,
356 pub category_webmcp: bool,
357 pub json_steps: bool,
359 pub step_timeout_secs: u64,
361}
362
363impl RunFlags {
364 pub fn from_globals(
366 experimental_vision: bool,
367 experimental_screencast: bool,
368 category_memory: bool,
369 category_extensions: bool,
370 category_third_party: bool,
371 category_webmcp: bool,
372 json_steps: bool,
373 step_timeout_secs: u64,
374 ) -> Self {
375 Self {
376 experimental_vision,
377 experimental_screencast,
378 category_memory,
379 category_extensions,
380 category_third_party,
381 category_webmcp,
382 json_steps,
383 step_timeout_secs,
384 }
385 }
386}
387
388pub async fn run_script_with_flags(
390 life: &Lifecycle,
391 script_path: &Path,
392 robots: RobotsPolicy,
393 capture: CaptureOpts,
394 flags: RunFlags,
395) -> Result<Value, CliError> {
396 let text = crate::json_util::read_text_file_limited(
397 script_path,
398 crate::json_util::MAX_JSON_FILE_BYTES,
399 )
400 .map_err(|e| {
401 if e.kind() == ErrorKind::Io || e.kind() == ErrorKind::NoInput {
402 CliError::with_suggestion(
403 ErrorKind::NoInput,
404 format!("cannot read script {}: {}", script_path.display(), e.message()),
405 "Pass an existing NDJSON/JSONL or JSON-array file to --script",
406 )
407 } else {
408 e
409 }
410 })?;
411
412 let steps = parse_run_script(&text)?;
414
415 if steps.is_empty() {
416 return Err(CliError::with_suggestion(
417 ErrorKind::Data,
418 "script has no steps",
419 "Add at least one NDJSON line or a JSON array of objects with a cmd field",
420 ));
421 }
422
423 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
424 life.record_chrome(session.chrome_pid());
425
426 let mut results: Vec<Value> = Vec::new();
427 for (idx, step) in steps.iter().enumerate() {
428 if life.is_cancelled() {
430 let _ = session.shutdown().await;
431 life.clear_chrome();
432 return Err(CliError::with_suggestion(
433 ErrorKind::Cancelled,
434 "cancelled by signal (SIGINT/SIGTERM) between run steps",
435 "Re-run the command; previous invocation was interrupted (exit 130)",
436 ));
437 }
438
439 let cmd = step
440 .get("cmd")
441 .or_else(|| step.get("action"))
442 .and_then(|v| v.as_str())
443 .unwrap_or("");
444
445 let step_fut = execute_step(&mut session, cmd, step, robots, flags);
446 let step_res = if flags.step_timeout_secs > 0 {
447 match tokio::time::timeout(
448 std::time::Duration::from_secs(flags.step_timeout_secs),
449 step_fut,
450 )
451 .await
452 {
453 Ok(inner) => inner,
454 Err(_) => Err(CliError::with_suggestion(
455 ErrorKind::Timeout,
456 format!(
457 "run step {idx} cmd={cmd} exceeded --step-timeout {}s",
458 flags.step_timeout_secs
459 ),
460 "Raise --step-timeout / --timeout or split the script",
461 )),
462 }
463 } else {
464 step_fut.await
465 };
466 match step_res {
467 Ok(data) => {
468 let row = json!({
469 "index": idx,
470 "step": idx,
471 "cmd": cmd,
472 "ok": true,
473 "data": data.clone(),
474 "result": data,
475 });
476 if flags.json_steps {
479 crate::output::write_json_line_ser(&row)?;
480 }
481 results.push(row);
482 }
483 Err(e) => {
484 let _ = session.shutdown().await;
485 life.clear_chrome();
486 let row = json!({
488 "index": idx,
489 "step": idx,
490 "cmd": cmd,
491 "ok": false,
492 "error": {
493 "kind": e.kind().as_str(),
494 "message": e.message(),
495 "suggestion": e.suggestion(),
496 }
497 });
498 if flags.json_steps {
499 crate::output::write_json_line_ser(&row)?;
500 }
501 results.push(row);
502 return Ok(json!({
503 "total": steps.len(),
504 "failed_index": idx,
505 "failed_cmd": cmd,
506 "steps": results,
507 "ok": false,
508 "error": {
509 "kind": e.kind().as_str(),
510 "message": format!("run fail-fast at step {idx} cmd={cmd}: {e}"),
511 "suggestion": crate::i18n::suggestion_key("run_fail_fast", None),
512 "exit_code": e.exit_code(),
513 }
514 }));
515 }
516 }
517 }
518
519 let close = session.shutdown().await;
520 life.clear_chrome();
521 close?;
522
523 Ok(json!({
525 "ok": true,
526 "total": results.len(),
527 "steps": results,
528 }))
529}
530
531fn reject_unknown_step_fields(cmd: &str, step: &Value) -> Result<(), CliError> {
533 let Some(obj) = step.as_object() else {
534 return Ok(());
535 };
536 let allowed: &[&str] = match cmd {
537 "scroll" => &[
538 "cmd", "action", "target", "selector", "delta_x", "delta_y", "deltaX", "deltaY", "dx",
539 "dy",
540 ],
541 "goto" => &[
542 "cmd",
543 "action",
544 "url",
545 "init_script",
546 "initScript",
547 "handle_before_unload",
548 "handleBeforeUnload",
549 "navigation_timeout_ms",
550 "navigationTimeoutMs",
551 "timeout_ms",
552 "timeoutMs",
553 ],
554 _ => return Ok(()),
555 };
556 for key in obj.keys() {
557 if !allowed.iter().any(|a| a == key) {
558 return Err(CliError::with_suggestion(
559 ErrorKind::Usage,
560 format!("unknown field `{key}` on step cmd={cmd}"),
561 format!("Allowed fields for {cmd}: {}", allowed.join(", ")),
562 ));
563 }
564 }
565 Ok(())
566}
567
568async fn execute_step(
569 session: &mut OneShotSession,
570 cmd: &str,
571 step: &Value,
572 robots: RobotsPolicy,
573 flags: RunFlags,
574) -> Result<Value, CliError> {
575 reject_unknown_step_fields(cmd, step)?;
576 match cmd {
577 "goto" => {
578 let url = step
579 .get("url")
580 .and_then(|v| v.as_str())
581 .ok_or_else(|| CliError::new(ErrorKind::Usage, "goto requires url"))?;
582 let init = step
583 .get("init_script")
584 .or_else(|| step.get("initScript"))
585 .and_then(|v| v.as_str());
586 let beforeunload = step_beforeunload_action(step);
587 let nav_timeout_ms = step
588 .get("navigation_timeout_ms")
589 .or_else(|| step.get("timeout"))
590 .and_then(|v| v.as_u64());
591 session
592 .goto_with_options(url, robots, init, beforeunload, nav_timeout_ms)
593 .await
594 }
595 "wait" => {
596 let ms = step
597 .get("ms")
598 .or_else(|| step.get("timeout_ms"))
599 .or_else(|| step.get("timeoutMs"))
600 .and_then(|v| v.as_u64());
601 let texts: Vec<String> = match step.get("text") {
602 Some(Value::Array(arr)) => arr
603 .iter()
604 .filter_map(|v| v.as_str().map(|s| s.to_string()))
605 .collect(),
606 Some(Value::String(s)) => vec![s.clone()],
607 _ => Vec::new(),
608 };
609 let selector = step
611 .get("selector")
612 .or_else(|| step.get("sel"))
613 .and_then(|v| v.as_str());
614 let selectors: Vec<String> = match step.get("selectors") {
615 Some(Value::Array(arr)) => arr
616 .iter()
617 .filter_map(|v| v.as_str().map(|s| s.to_string()))
618 .collect(),
619 Some(Value::String(s)) => vec![s.clone()],
620 _ => match step.get("selector").or_else(|| step.get("sel")) {
621 Some(Value::Array(arr)) => arr
622 .iter()
623 .filter_map(|v| v.as_str().map(|s| s.to_string()))
624 .collect(),
625 _ => Vec::new(),
626 },
627 };
628 let state = step.get("state").and_then(|v| v.as_str());
629 let url_exact = step
631 .get("url")
632 .and_then(|v| v.as_str())
633 .filter(|s| !s.is_empty());
634 let url_contains = step
635 .get("url_contains")
636 .or_else(|| step.get("urlContains"))
637 .and_then(|v| v.as_str())
638 .filter(|s| !s.is_empty());
639 let navigation = step
640 .get("navigation")
641 .and_then(|v| v.as_bool())
642 .unwrap_or(false);
643 let include_snapshot = step
644 .get("include_snapshot")
645 .or_else(|| step.get("includeSnapshot"))
646 .and_then(|v| v.as_bool())
647 .unwrap_or(false);
648 let has_sel = selector.is_some() || !selectors.is_empty();
649 let has_url = url_exact.is_some() || url_contains.is_some();
650 if texts.is_empty()
651 && !has_sel
652 && state.is_none()
653 && !has_url
654 && !navigation
655 && !include_snapshot
656 {
657 session.wait_ms(ms.unwrap_or(0)).await
658 } else {
659 session
660 .wait_for_any_ex(
661 ms,
662 &texts,
663 selector,
664 &selectors,
665 state,
666 url_exact,
667 url_contains,
668 navigation,
669 include_snapshot,
670 )
671 .await
672 }
673 }
674 "hover" => {
675 let target = step
676 .get("target")
677 .or_else(|| step.get("ref"))
678 .or_else(|| step.get("selector"))
679 .and_then(|v| v.as_str())
680 .ok_or_else(|| CliError::new(ErrorKind::Usage, "hover requires target"))?;
681 let include_snapshot = step
682 .get("include_snapshot")
683 .or_else(|| step.get("includeSnapshot"))
684 .and_then(|v| v.as_bool())
685 .unwrap_or(false);
686 session.hover(target, include_snapshot).await
687 }
688 "drag" => {
689 let from = step
690 .get("from")
691 .and_then(|v| v.as_str())
692 .ok_or_else(|| CliError::new(ErrorKind::Usage, "drag requires --from"))?;
693 let to = step
694 .get("to")
695 .and_then(|v| v.as_str())
696 .ok_or_else(|| CliError::new(ErrorKind::Usage, "drag requires --to"))?;
697 let include_snapshot = step
698 .get("include_snapshot")
699 .or_else(|| step.get("includeSnapshot"))
700 .and_then(|v| v.as_bool())
701 .unwrap_or(false);
702 session.drag(from, to, include_snapshot).await
703 }
704 "fill-form" | "fill_form" => {
705 let arr = step
706 .get("fields")
707 .or_else(|| step.get("json"))
708 .ok_or_else(|| {
709 CliError::new(ErrorKind::Usage, "fill-form requires fields array or json")
710 })?;
711 let items = if let Some(s) = arr.as_str() {
712 crate::json_util::value_from_str(s).map_err(|e| {
713 CliError::new(ErrorKind::Usage, format!("fill-form json: {e}"))
714 })?
715 } else {
716 arr.clone()
717 };
718 let list = items.as_array().ok_or_else(|| {
719 CliError::new(ErrorKind::Usage, "fill-form fields must be an array")
720 })?;
721 let mut fields = Vec::new();
722 for item in list {
723 let target = item
724 .get("target")
725 .or_else(|| item.get("uid"))
726 .or_else(|| item.get("selector"))
727 .or_else(|| item.get("ref"))
728 .and_then(|v| v.as_str())
729 .ok_or_else(|| {
730 CliError::new(ErrorKind::Usage, "fill-form field missing target/uid")
731 })?
732 .to_string();
733 let value = item
734 .get("value")
735 .or_else(|| item.get("text"))
736 .and_then(|v| v.as_str())
737 .ok_or_else(|| {
738 CliError::new(ErrorKind::Usage, "fill-form field missing value")
739 })?
740 .to_string();
741 fields.push((target, value));
742 }
743 let include_snapshot = step
744 .get("include_snapshot")
745 .or_else(|| step.get("includeSnapshot"))
746 .and_then(|v| v.as_bool())
747 .unwrap_or(false);
748 session.fill_form(&fields, include_snapshot).await
749 }
750 "select-option" | "select_option" | "pick" => {
751 let target = step
753 .get("target")
754 .or_else(|| step.get("ref"))
755 .or_else(|| step.get("selector"))
756 .or_else(|| step.get("trigger"))
757 .and_then(|v| v.as_str())
758 .ok_or_else(|| {
759 CliError::new(
760 ErrorKind::Usage,
761 "select-option/pick requires target (trigger)",
762 )
763 })?;
764 let option = step
765 .get("option")
766 .or_else(|| step.get("value"))
767 .or_else(|| step.get("text"))
768 .and_then(|v| v.as_str())
769 .ok_or_else(|| {
770 CliError::new(
771 ErrorKind::Usage,
772 "select-option/pick requires option (text, selector, or role label)",
773 )
774 })?;
775 let include_snapshot = step
776 .get("include_snapshot")
777 .or_else(|| step.get("includeSnapshot"))
778 .and_then(|v| v.as_bool())
779 .unwrap_or(false);
780 session.pick_option(target, option, include_snapshot).await
781 }
782 "upload" => {
783 let target = step
784 .get("target")
785 .or_else(|| step.get("ref"))
786 .or_else(|| step.get("selector"))
787 .or_else(|| step.get("uid"))
788 .and_then(|v| v.as_str())
789 .ok_or_else(|| CliError::new(ErrorKind::Usage, "upload requires target"))?;
790 let path = step
791 .get("path")
792 .and_then(|v| v.as_str())
793 .ok_or_else(|| CliError::new(ErrorKind::Usage, "upload requires path"))?;
794 let include_snapshot = step
795 .get("include_snapshot")
796 .or_else(|| step.get("includeSnapshot"))
797 .and_then(|v| v.as_bool())
798 .unwrap_or(false);
799 session
800 .upload(target, Path::new(path), include_snapshot)
801 .await
802 }
803 "back" => session.back().await,
804 "forward" => session.forward().await,
805 "reload" => {
806 let ignore_cache = step
807 .get("ignore_cache")
808 .or_else(|| step.get("ignoreCache"))
809 .and_then(|v| v.as_bool())
810 .unwrap_or(false);
811 let init = step
812 .get("init_script")
813 .or_else(|| step.get("initScript"))
814 .and_then(|v| v.as_str());
815 let beforeunload = step_beforeunload_action(step);
817 session
818 .reload_with_options(ignore_cache, init, beforeunload)
819 .await
820 }
821 "view" => {
822 let verbose = step
823 .get("verbose")
824 .or_else(|| step.get("detailed"))
825 .and_then(|v| v.as_bool())
826 .unwrap_or(false);
827 let allow_empty = step
828 .get("allow_empty")
829 .or_else(|| step.get("allowEmpty"))
830 .and_then(|v| v.as_bool())
831 .unwrap_or(false);
832 let mut data = session.view(verbose).await?;
833 let ref_count = data
834 .get("ref_count")
835 .or_else(|| data.pointer("/snapshot/ref_count"))
836 .and_then(|v| v.as_u64())
837 .unwrap_or_else(|| {
838 data.get("tree")
839 .and_then(|v| v.as_str())
840 .map(|t| if t.contains("(empty") { 0 } else { 1 })
841 .unwrap_or(1)
842 });
843 let info = session.page_info().await.unwrap_or_else(|_| json!({}));
844 let url_now = info
845 .get("url")
846 .and_then(|v| v.as_str())
847 .unwrap_or("about:blank");
848 let empty = ref_count == 0
849 || url_now == "about:blank"
850 || data
851 .get("tree")
852 .and_then(|v| v.as_str())
853 .is_some_and(|t| t.contains("empty page"));
854 if empty && !allow_empty {
855 return Err(CliError::with_suggestion(
856 ErrorKind::Usage,
857 "view returned empty page (no refs); refuse silent success",
858 "Navigate with goto first, or pass allow_empty:true for blank snapshots",
859 ));
860 }
861 if let Some(obj) = data.as_object_mut() {
862 obj.insert("empty".into(), json!(empty));
863 }
864 Ok(data)
865 }
866 "press" | "click" => {
867 let target = step
868 .get("target")
869 .or_else(|| step.get("ref"))
870 .or_else(|| step.get("selector"))
871 .and_then(|v| v.as_str())
872 .ok_or_else(|| CliError::new(ErrorKind::Usage, "press requires target"))?;
873 let dbl = step
874 .get("dblclick")
875 .and_then(|v| v.as_bool())
876 .unwrap_or(false);
877 let include_snapshot = step
878 .get("include_snapshot")
879 .or_else(|| step.get("includeSnapshot"))
880 .and_then(|v| v.as_bool())
881 .unwrap_or(false);
882 session.press(target, dbl, include_snapshot).await
883 }
884 "write" | "fill" => {
885 let target = step
886 .get("target")
887 .or_else(|| step.get("ref"))
888 .or_else(|| step.get("selector"))
889 .and_then(|v| v.as_str())
890 .ok_or_else(|| CliError::new(ErrorKind::Usage, "write requires target"))?;
891 let value = step
892 .get("value")
893 .or_else(|| step.get("text"))
894 .and_then(|v| v.as_str())
895 .ok_or_else(|| CliError::new(ErrorKind::Usage, "write requires value"))?;
896 let include_snapshot = step
897 .get("include_snapshot")
898 .or_else(|| step.get("includeSnapshot"))
899 .and_then(|v| v.as_bool())
900 .unwrap_or(false);
901 session.write(target, value, include_snapshot).await
902 }
903 "keys" => {
904 let key = step
905 .get("key")
906 .and_then(|v| v.as_str())
907 .ok_or_else(|| CliError::new(ErrorKind::Usage, "keys requires key"))?;
908 let include_snapshot = step
909 .get("include_snapshot")
910 .or_else(|| step.get("includeSnapshot"))
911 .and_then(|v| v.as_bool())
912 .unwrap_or(false);
913 session.keys(key, include_snapshot).await
914 }
915 "type" => {
916 let focus_only = step
917 .get("focus_only")
918 .or_else(|| step.get("focusOnly"))
919 .and_then(|v| v.as_bool())
920 .unwrap_or(false);
921 let target = step
922 .get("target")
923 .or_else(|| step.get("ref"))
924 .or_else(|| step.get("selector"))
925 .or_else(|| step.get("uid"))
926 .and_then(|v| v.as_str());
927 if target.is_none() && !focus_only {
928 return Err(CliError::new(
929 ErrorKind::Usage,
930 "type requires target or focus_only",
931 ));
932 }
933 let text = step
934 .get("text")
935 .and_then(|v| v.as_str())
936 .ok_or_else(|| CliError::new(ErrorKind::Usage, "type requires text"))?;
937 let clear = step
938 .get("clear")
939 .and_then(|v| v.as_bool())
940 .unwrap_or(false);
941 let submit = step.get("submit").and_then(|v| v.as_str());
942 session
943 .type_text(target, text, clear, submit, focus_only)
944 .await
945 }
946 "click-at" | "click_at" => {
947 if !flags.experimental_vision {
948 return Err(CliError::with_suggestion(
949 ErrorKind::Usage,
950 "click-at requires --experimental-vision",
951 "Pass --experimental-vision on the same invocation",
952 ));
953 }
954 let x = step
955 .get("x")
956 .and_then(|v| v.as_f64())
957 .ok_or_else(|| CliError::new(ErrorKind::Usage, "click-at requires x"))?;
958 let y = step
959 .get("y")
960 .and_then(|v| v.as_f64())
961 .ok_or_else(|| CliError::new(ErrorKind::Usage, "click-at requires y"))?;
962 let dblclick = step
963 .get("dblclick")
964 .and_then(|v| v.as_bool())
965 .unwrap_or(false);
966 let include_snapshot = step
967 .get("include_snapshot")
968 .or_else(|| step.get("includeSnapshot"))
969 .and_then(|v| v.as_bool())
970 .unwrap_or(false);
971 session.click_at(x, y, dblclick, include_snapshot).await
972 }
973 "eval" => {
974 let expr = step
975 .get("expression")
976 .or_else(|| step.get("function"))
977 .or_else(|| step.get("js"))
978 .and_then(|v| v.as_str())
979 .ok_or_else(|| CliError::new(ErrorKind::Usage, "eval requires expression"))?;
980 let args = step.get("args").map(|v| {
981 if let Some(s) = v.as_str() {
982 s.to_string()
983 } else {
984 v.to_string()
985 }
986 });
987 let dialog_action = step
988 .get("dialog_action")
989 .or_else(|| step.get("dialogAction"))
990 .and_then(|v| v.as_str());
991 let file_path = step
992 .get("file_path")
993 .or_else(|| step.get("filePath"))
994 .and_then(|v| v.as_str())
995 .map(Path::new);
996 session
997 .eval(expr, args.as_deref(), dialog_action, file_path)
998 .await
999 }
1000 "grab" | "screenshot" => {
1001 let path = step
1002 .get("path")
1003 .and_then(|v| v.as_str())
1004 .map(std::path::PathBuf::from);
1005 let format = step
1006 .get("format")
1007 .and_then(|v| v.as_str())
1008 .unwrap_or("png");
1009 let full_page = step
1010 .get("full_page")
1011 .or_else(|| step.get("fullPage"))
1012 .and_then(|v| v.as_bool())
1013 .unwrap_or(false);
1014 let quality = step
1015 .get("quality")
1016 .and_then(|v| v.as_i64())
1017 .map(|n| n as i32);
1018 let element = step
1019 .get("element")
1020 .or_else(|| step.get("selector"))
1021 .or_else(|| step.get("ref"))
1022 .and_then(|v| v.as_str());
1023 session
1024 .grab(path.as_deref(), format, full_page, quality, element)
1025 .await
1026 }
1027 "extract" => {
1028 let target = step
1029 .get("ref")
1030 .or_else(|| step.get("target"))
1031 .or_else(|| step.get("selector"))
1032 .and_then(|v| v.as_str())
1033 .ok_or_else(|| {
1034 CliError::new(ErrorKind::Usage, "extract requires ref, target, or selector")
1035 })?;
1036 let attr = step.get("attr").and_then(|v| v.as_str());
1037 session.extract(target, attr).await
1038 }
1039 "text" => {
1040 let target = step
1041 .get("ref")
1042 .or_else(|| step.get("target"))
1043 .or_else(|| step.get("selector"))
1044 .and_then(|v| v.as_str())
1045 .ok_or_else(|| {
1046 CliError::new(ErrorKind::Usage, "text requires ref, target, or selector")
1047 })?;
1048 session.text(target).await
1049 }
1050 "scroll" => {
1051 let target = step
1052 .get("ref")
1053 .or_else(|| step.get("target"))
1054 .or_else(|| step.get("selector"))
1055 .and_then(|v| v.as_str());
1056 let delta_x = step
1057 .get("delta_x")
1058 .or_else(|| step.get("deltaX"))
1059 .or_else(|| step.get("dx"))
1060 .and_then(|v| v.as_f64())
1061 .unwrap_or(0.0);
1062 let delta_y = step
1063 .get("delta_y")
1064 .or_else(|| step.get("deltaY"))
1065 .or_else(|| step.get("dy"))
1066 .and_then(|v| v.as_f64())
1067 .unwrap_or(0.0);
1068 session.scroll(target, delta_x, delta_y).await
1069 }
1070 "cookie" => {
1071 let action = step
1072 .get("action")
1073 .and_then(|v| v.as_str())
1074 .unwrap_or("list");
1075 match action {
1076 "list" => {
1077 let url = step.get("url").and_then(|v| v.as_str());
1078 session.cookie_list(url).await
1079 }
1080 "set" => {
1081 let body = step
1082 .get("json")
1083 .or_else(|| step.get("cookies"))
1084 .map(|v| {
1085 if let Some(s) = v.as_str() {
1086 s.to_string()
1087 } else {
1088 v.to_string()
1089 }
1090 })
1091 .ok_or_else(|| {
1092 CliError::new(ErrorKind::Usage, "cookie set requires json/cookies")
1093 })?;
1094 session.cookie_set(&body).await
1095 }
1096 "clear" => session.cookie_clear().await,
1097 other => Err(CliError::new(
1098 ErrorKind::Usage,
1099 format!("unknown cookie action: {other}"),
1100 )),
1101 }
1102 }
1103 "attr" => {
1104 let target = step
1105 .get("ref")
1106 .or_else(|| step.get("target"))
1107 .or_else(|| step.get("selector"))
1108 .and_then(|v| v.as_str())
1109 .ok_or_else(|| {
1110 CliError::new(ErrorKind::Usage, "attr requires ref, target, or selector")
1111 })?;
1112 let name = step
1113 .get("name")
1114 .or_else(|| step.get("attr"))
1115 .and_then(|v| v.as_str())
1116 .ok_or_else(|| CliError::new(ErrorKind::Usage, "attr requires name"))?;
1117 session.attr(target, name).await
1118 }
1119 "assert" => execute_assert(session, step).await,
1120 "console" => {
1121 let action = step
1122 .get("action")
1123 .and_then(|v| v.as_str())
1124 .unwrap_or("list");
1125 match action {
1126 "list" => {
1127 let page_idx = step
1128 .get("page_idx")
1129 .or_else(|| step.get("pageIdx"))
1130 .and_then(|v| v.as_u64())
1131 .map(|n| n as usize);
1132 let page_size = step
1133 .get("page_size")
1134 .or_else(|| step.get("pageSize"))
1135 .and_then(|v| v.as_u64())
1136 .map(|n| n as usize);
1137 let types = step.get("types").and_then(|v| v.as_str());
1138 let include_preserved = step
1139 .get("include_preserved")
1140 .or_else(|| step.get("includePreservedMessages"))
1141 .and_then(|v| v.as_bool())
1142 .unwrap_or(false);
1143 let sw = step
1144 .get("service_worker_id")
1145 .or_else(|| step.get("serviceWorkerId"))
1146 .and_then(|v| v.as_str());
1147 session.console_list(page_idx, page_size, types, include_preserved, sw)
1148 }
1149 "get" => {
1150 let id = step
1151 .get("id")
1152 .or_else(|| step.get("msgid"))
1153 .or_else(|| step.get("index"))
1154 .and_then(|v| v.as_u64())
1155 .ok_or_else(|| {
1156 CliError::new(
1157 ErrorKind::Usage,
1158 "console get requires id|msgid|index (0-based)",
1159 )
1160 })? as usize;
1161 session.console_get(id)
1162 }
1163 "clear" => session.console_clear(),
1164 "dump" => {
1165 let path = step
1166 .get("path")
1167 .and_then(|v| v.as_str())
1168 .ok_or_else(|| {
1169 CliError::new(ErrorKind::Usage, "console dump requires path")
1170 })?;
1171 session.console_dump(Path::new(path)).await
1172 }
1173 other => Err(CliError::new(
1174 ErrorKind::Usage,
1175 format!("unknown console action: {other}"),
1176 )),
1177 }
1178 }
1179 "net" => {
1180 let action = step
1181 .get("action")
1182 .and_then(|v| v.as_str())
1183 .unwrap_or("list");
1184 match action {
1185 "list" => {
1186 let page_idx = step
1187 .get("page_idx")
1188 .or_else(|| step.get("pageIdx"))
1189 .and_then(|v| v.as_u64())
1190 .map(|n| n as usize);
1191 let page_size = step
1192 .get("page_size")
1193 .or_else(|| step.get("pageSize"))
1194 .and_then(|v| v.as_u64())
1195 .map(|n| n as usize);
1196 let resource_types = step
1197 .get("resource_types")
1198 .or_else(|| step.get("resourceTypes"))
1199 .and_then(|v| v.as_str());
1200 let include_preserved = step
1201 .get("include_preserved")
1202 .or_else(|| step.get("includePreservedRequests"))
1203 .and_then(|v| v.as_bool())
1204 .unwrap_or(false);
1205 session.net_list(page_idx, page_size, resource_types, include_preserved)
1206 }
1207 "get" => {
1208 let id = step
1209 .get("id")
1210 .map(|v| {
1211 if let Some(s) = v.as_str() {
1212 s.to_string()
1213 } else if let Some(n) = v.as_u64() {
1214 n.to_string()
1215 } else {
1216 String::new()
1217 }
1218 })
1219 .filter(|s| !s.is_empty())
1220 .ok_or_else(|| {
1221 CliError::new(ErrorKind::Usage, "net get requires id (index or requestId)")
1222 })?;
1223 let request_path = step
1224 .get("request_path")
1225 .and_then(|v| v.as_str())
1226 .map(Path::new);
1227 let response_path = step
1228 .get("response_path")
1229 .and_then(|v| v.as_str())
1230 .map(Path::new);
1231 session.net_get(&id, request_path, response_path).await
1232 }
1233 other => Err(CliError::new(
1234 ErrorKind::Usage,
1235 format!("unknown net action: {other}"),
1236 )),
1237 }
1238 }
1239 "page" => {
1240 let action = step
1241 .get("action")
1242 .and_then(|v| v.as_str())
1243 .unwrap_or("info");
1244 match action {
1245 "info" | "" => session.page_info().await,
1246 "list" => session.page_list().await,
1247 "new" => {
1248 let url = step.get("url").and_then(|v| v.as_str());
1249 let background = step
1250 .get("background")
1251 .and_then(|v| v.as_bool())
1252 .unwrap_or(false);
1253 let isolated_name: Option<String> = match step
1255 .get("isolated_context")
1256 .or_else(|| step.get("isolatedContext"))
1257 {
1258 Some(Value::String(s)) if !s.trim().is_empty() => Some(s.clone()),
1259 Some(Value::Bool(true)) => Some("default-isolated".into()),
1260 _ => None,
1261 };
1262 session
1263 .page_new(url, background, isolated_name.as_deref())
1264 .await
1265 }
1266 "select" => {
1267 let index = if let Some(i) = step
1269 .get("index")
1270 .or_else(|| step.get("page_id"))
1271 .or_else(|| step.get("pageId"))
1272 .and_then(|v| v.as_u64())
1273 {
1274 i as usize
1275 } else if let Some(tab_id) = step.get("tab_id").and_then(|v| v.as_u64()) {
1276 if tab_id == 0 {
1277 return Err(CliError::new(
1278 ErrorKind::Usage,
1279 "page select tab_id is 1-based (got 0)",
1280 ));
1281 }
1282 (tab_id - 1) as usize
1283 } else {
1284 return Err(CliError::new(
1285 ErrorKind::Usage,
1286 "page select requires index/pageId (0-based) or tab_id (1-based)",
1287 ));
1288 };
1289 let bring_to_front = step
1290 .get("bring_to_front")
1291 .or_else(|| step.get("bringToFront"))
1292 .and_then(|v| v.as_bool())
1293 .unwrap_or(true);
1294 session.page_select(index, bring_to_front).await
1295 }
1296 "close" => {
1297 let index = step
1298 .get("index")
1299 .or_else(|| step.get("page_id"))
1300 .or_else(|| step.get("pageId"))
1301 .and_then(|v| v.as_u64())
1302 .map(|i| i as usize);
1303 session.page_close(index).await
1304 }
1305 "tab-id" | "tab_id" | "get_tab_id" => {
1306 let tab = session.active_tab_id_string().ok_or_else(|| {
1307 CliError::with_suggestion(
1308 ErrorKind::Browser,
1309 "no active tab id",
1310 "Open a page first (goto / page new)",
1311 )
1312 })?;
1313 Ok(serde_json::json!({
1314 "tab_id": tab,
1315 "tool": "get_tab_id",
1316 }))
1317 }
1318 other => Err(CliError::new(
1319 ErrorKind::Usage,
1320 format!("unknown page action: {other}"),
1321 )),
1322 }
1323 }
1324 "dialog" => {
1325 let action = step
1326 .get("action")
1327 .and_then(|v| v.as_str())
1328 .unwrap_or("accept");
1329 let text = step.get("text").and_then(|v| v.as_str());
1330 let if_present = step
1331 .get("if_present")
1332 .or_else(|| step.get("ifPresent"))
1333 .and_then(|v| v.as_bool())
1334 .unwrap_or(false);
1335 let result = match action {
1336 "accept" => session.dialog(true, text).await,
1337 "dismiss" => session.dialog(false, None).await,
1338 other => {
1339 return Err(CliError::new(
1340 ErrorKind::Usage,
1341 format!("unknown dialog action: {other}"),
1342 ))
1343 }
1344 };
1345 match result {
1346 Ok(v) => Ok(v),
1347 Err(e) if if_present => {
1348 let msg = e.message().to_ascii_lowercase();
1349 if msg.contains("no dialog")
1350 || msg.contains("not showing")
1351 || msg.contains("-32602")
1352 || msg.contains("dialog failed")
1353 {
1354 Ok(json!({
1355 "dialog": action,
1356 "dialog_shown": false,
1357 "if_present": true,
1358 "ok": true,
1359 }))
1360 } else {
1361 Err(e)
1362 }
1363 }
1364 Err(e) => Err(e),
1365 }
1366 }
1367 "emulate" => {
1368 let headers_owned = step.get("extra_headers").map(|v| {
1369 if let Some(s) = v.as_str() {
1370 s.to_string()
1371 } else {
1372 v.to_string()
1373 }
1374 });
1375 session
1376 .emulate(
1377 step.get("user_agent").and_then(|v| v.as_str()),
1378 step.get("locale").and_then(|v| v.as_str()),
1379 step.get("timezone").and_then(|v| v.as_str()),
1380 step.get("offline")
1381 .and_then(|v| v.as_bool())
1382 .unwrap_or(false),
1383 step.get("latitude").and_then(|v| v.as_f64()),
1384 step.get("longitude").and_then(|v| v.as_f64()),
1385 step.get("media").and_then(|v| v.as_str()),
1386 step.get("network_conditions").and_then(|v| v.as_str()),
1387 step.get("cpu_throttling_rate").and_then(|v| v.as_f64()),
1388 step.get("color_scheme").and_then(|v| v.as_str()),
1389 headers_owned.as_deref(),
1390 step.get("viewport").and_then(|v| v.as_str()),
1391 )
1392 .await
1393 }
1394 "resize" => {
1395 let width = step
1396 .get("width")
1397 .and_then(|v| v.as_i64())
1398 .ok_or_else(|| CliError::new(ErrorKind::Usage, "resize requires width"))?
1399 as i32;
1400 let height = step
1401 .get("height")
1402 .and_then(|v| v.as_i64())
1403 .ok_or_else(|| CliError::new(ErrorKind::Usage, "resize requires height"))?
1404 as i32;
1405 let scale = step.get("scale").and_then(|v| v.as_f64()).unwrap_or(1.0);
1406 let mobile = step
1407 .get("mobile")
1408 .and_then(|v| v.as_bool())
1409 .unwrap_or(false);
1410 session.resize(width, height, scale, mobile).await
1411 }
1412 "perf" => {
1413 let action = step
1414 .get("action")
1415 .and_then(|v| v.as_str())
1416 .unwrap_or("insight");
1417 match action {
1418 "start" => {
1419 let path = step.get("path").and_then(|v| v.as_str()).map(Path::new);
1420 let auto_stop = step
1421 .get("auto_stop")
1422 .or_else(|| step.get("autoStop"))
1423 .and_then(|v| v.as_bool())
1424 .unwrap_or(false);
1425 let reload = step
1426 .get("reload")
1427 .and_then(|v| v.as_bool())
1428 .unwrap_or(false);
1429 session.perf_start(path, reload, auto_stop).await
1430 }
1431 "stop" => {
1432 let path = step.get("path").and_then(|v| v.as_str()).map(Path::new);
1433 session.perf_stop(path).await
1434 }
1435 "insight" => {
1436 let name = step
1437 .get("name")
1438 .or_else(|| step.get("insight_name"))
1439 .or_else(|| step.get("insightName"))
1440 .and_then(|v| v.as_str());
1441 let set_id = step
1442 .get("insight_set_id")
1443 .or_else(|| step.get("insightSetId"))
1444 .and_then(|v| v.as_str());
1445 session.perf_insight(name, set_id).await
1446 }
1447 other => Err(CliError::new(
1448 ErrorKind::Usage,
1449 format!("unknown perf action: {other}"),
1450 )),
1451 }
1452 }
1453 "screencast" => {
1454 if !flags.experimental_screencast {
1455 return Err(CliError::with_suggestion(
1456 ErrorKind::Usage,
1457 "screencast requires --experimental-screencast",
1458 "Pass --experimental-screencast on the same invocation",
1459 ));
1460 }
1461 let action = step
1462 .get("action")
1463 .and_then(|v| v.as_str())
1464 .unwrap_or("start");
1465 match action {
1466 "start" => {
1467 let path = step
1468 .get("path")
1469 .or_else(|| step.get("dir"))
1470 .and_then(|v| v.as_str())
1471 .map(Path::new);
1472 session.screencast_start(path).await
1473 }
1474 "stop" => {
1475 let path = step.get("path").and_then(|v| v.as_str()).map(Path::new);
1476 session.screencast_stop(path).await
1477 }
1478 other => Err(CliError::new(
1479 ErrorKind::Usage,
1480 format!("unknown screencast action: {other}"),
1481 )),
1482 }
1483 }
1484 "heap" => {
1485 let action = step
1486 .get("action")
1487 .and_then(|v| v.as_str())
1488 .unwrap_or("take");
1489 let deep = !matches!(action, "take" | "summary" | "close");
1490 if deep && !flags.category_memory {
1491 return Err(CliError::with_suggestion(
1492 ErrorKind::Usage,
1493 "deep heap tools require --category-memory",
1494 "Pass --category-memory (heap take/summary/close work without deep graph ops)",
1495 ));
1496 }
1497 match action {
1498 "take" => {
1499 let path = step
1500 .get("path")
1501 .and_then(|v| v.as_str())
1502 .ok_or_else(|| CliError::new(ErrorKind::Usage, "heap take requires path"))?;
1503 session.heap_take(Path::new(path)).await
1504 }
1505 "summary" => {
1506 let path = step
1507 .get("path")
1508 .and_then(|v| v.as_str())
1509 .ok_or_else(|| CliError::new(ErrorKind::Usage, "heap path required"))?;
1510 OneShotSession::heap_file_summary(Path::new(path))
1511 }
1512 "close" => {
1513 let path = step
1514 .get("path")
1515 .and_then(|v| v.as_str())
1516 .ok_or_else(|| CliError::new(ErrorKind::Usage, "heap path required"))?;
1517 OneShotSession::heap_close(Path::new(path))
1518 }
1519 "details" => {
1520 let path = step
1521 .get("path")
1522 .and_then(|v| v.as_str())
1523 .ok_or_else(|| CliError::new(ErrorKind::Usage, "heap path required"))?;
1524 OneShotSession::heap_details(Path::new(path))
1525 }
1526 "dup-strings" | "dup_strings" => {
1527 let path = step
1528 .get("path")
1529 .and_then(|v| v.as_str())
1530 .ok_or_else(|| CliError::new(ErrorKind::Usage, "heap path required"))?;
1531 OneShotSession::heap_dup_strings(Path::new(path))
1532 }
1533 "compare" => {
1534 let base = step
1535 .get("base")
1536 .and_then(|v| v.as_str())
1537 .ok_or_else(|| CliError::new(ErrorKind::Usage, "heap compare needs base"))?;
1538 let current = step.get("current").and_then(|v| v.as_str()).ok_or_else(|| {
1539 CliError::new(ErrorKind::Usage, "heap compare needs current")
1540 })?;
1541 OneShotSession::heap_compare(Path::new(base), Path::new(current))
1542 }
1543 "class-nodes" => {
1544 let path = step
1545 .get("path")
1546 .and_then(|v| v.as_str())
1547 .ok_or_else(|| CliError::new(ErrorKind::Usage, "heap class-nodes needs path"))?;
1548 let id = step
1549 .get("id")
1550 .and_then(|v| v.as_u64())
1551 .ok_or_else(|| CliError::new(ErrorKind::Usage, "heap class-nodes needs id"))?;
1552 OneShotSession::heap_class_nodes(Path::new(path), id)
1553 }
1554 "dominators" | "edges" | "retainers" | "paths" => {
1555 let path = step
1556 .get("path")
1557 .and_then(|v| v.as_str())
1558 .ok_or_else(|| CliError::new(ErrorKind::Usage, "heap node op needs path"))?;
1559 let node = step
1560 .get("node")
1561 .and_then(|v| v.as_u64())
1562 .ok_or_else(|| CliError::new(ErrorKind::Usage, "heap node op needs node"))?;
1563 OneShotSession::heap_node_op(Path::new(path), node, action)
1564 }
1565 "object-details" | "object_details" => {
1566 let path = step
1567 .get("path")
1568 .and_then(|v| v.as_str())
1569 .ok_or_else(|| {
1570 CliError::new(ErrorKind::Usage, "heap object-details needs path")
1571 })?;
1572 let node = step
1573 .get("node")
1574 .and_then(|v| v.as_u64())
1575 .ok_or_else(|| {
1576 CliError::new(ErrorKind::Usage, "heap object-details needs node")
1577 })?;
1578 OneShotSession::heap_object_details(Path::new(path), node)
1579 }
1580 other => Err(CliError::new(
1581 ErrorKind::Usage,
1582 format!("unknown heap action: {other}"),
1583 )),
1584 }
1585 }
1586 "devtools3p-list" | "devtools3p" if step.get("action").and_then(|v| v.as_str()) == Some("list") => {
1587 if !flags.category_third_party {
1588 return Err(CliError::with_suggestion(
1589 ErrorKind::Usage,
1590 "devtools3p tools require --category-third-party",
1591 "Pass --category-third-party on the same invocation",
1592 ));
1593 }
1594 if let Some(url) = step.get("url").and_then(|v| v.as_str()) {
1595 if url != "about:blank" {
1596 let _ = session
1597 .goto(url, crate::robots::RobotsPolicy::Ignore)
1598 .await?;
1599 }
1600 }
1601 session.devtools3p_list().await
1602 }
1603 "devtools3p-exec" | "devtools3p" if step.get("action").and_then(|v| v.as_str()) == Some("exec") => {
1604 if !flags.category_third_party {
1605 return Err(CliError::with_suggestion(
1606 ErrorKind::Usage,
1607 "devtools3p tools require --category-third-party",
1608 "Pass --category-third-party on the same invocation",
1609 ));
1610 }
1611 let name = step
1612 .get("name")
1613 .and_then(|v| v.as_str())
1614 .ok_or_else(|| CliError::new(ErrorKind::Usage, "devtools3p exec needs name"))?;
1615 let params = step.get("params").and_then(|v| v.as_str());
1616 if let Some(url) = step.get("url").and_then(|v| v.as_str()) {
1617 if url != "about:blank" {
1618 let _ = session
1619 .goto(url, crate::robots::RobotsPolicy::Ignore)
1620 .await?;
1621 }
1622 }
1623 session.devtools3p_exec(name, params).await
1624 }
1625 "webmcp-list" | "webmcp" if step.get("action").and_then(|v| v.as_str()) == Some("list") => {
1626 if !flags.category_webmcp {
1627 return Err(CliError::with_suggestion(
1628 ErrorKind::Usage,
1629 "webmcp tools require --category-webmcp",
1630 "Pass --category-webmcp on the same invocation",
1631 ));
1632 }
1633 if let Some(url) = step.get("url").and_then(|v| v.as_str()) {
1634 if url != "about:blank" {
1635 let _ = session
1636 .goto(url, crate::robots::RobotsPolicy::Ignore)
1637 .await?;
1638 }
1639 }
1640 session.webmcp_list().await
1641 }
1642 "webmcp-exec" | "webmcp" if step.get("action").and_then(|v| v.as_str()) == Some("exec") => {
1643 if !flags.category_webmcp {
1644 return Err(CliError::with_suggestion(
1645 ErrorKind::Usage,
1646 "webmcp tools require --category-webmcp",
1647 "Pass --category-webmcp on the same invocation",
1648 ));
1649 }
1650 let name = step
1651 .get("name")
1652 .and_then(|v| v.as_str())
1653 .ok_or_else(|| CliError::new(ErrorKind::Usage, "webmcp exec needs name"))?;
1654 let input = step.get("input").and_then(|v| v.as_str());
1655 if let Some(url) = step.get("url").and_then(|v| v.as_str()) {
1656 if url != "about:blank" {
1657 let _ = session
1658 .goto(url, crate::robots::RobotsPolicy::Ignore)
1659 .await?;
1660 }
1661 }
1662 session.webmcp_exec(name, input).await
1663 }
1664 "scrape" => {
1665 let url = step
1666 .get("url")
1667 .and_then(|v| v.as_str())
1668 .ok_or_else(|| CliError::new(ErrorKind::Usage, "scrape requires url"))?;
1669 session.scrape(url, robots).await
1671 }
1672 "print-pdf" | "print_pdf" => {
1673 if let Some(url) = step.get("url").and_then(|v| v.as_str()) {
1675 let init = step
1676 .get("init_script")
1677 .or_else(|| step.get("initScript"))
1678 .and_then(|v| v.as_str());
1679 let beforeunload = step_beforeunload_action(step);
1680 let nav_timeout_ms = step
1681 .get("navigation_timeout_ms")
1682 .or_else(|| step.get("timeout_ms"))
1683 .and_then(|v| v.as_u64());
1684 let _ = session
1685 .goto_with_options(url, robots, init, beforeunload, nav_timeout_ms)
1686 .await?;
1687 } else {
1688 let allow_empty = step
1690 .get("allow_empty")
1691 .or_else(|| step.get("allowEmpty"))
1692 .and_then(|v| v.as_bool())
1693 .unwrap_or(false);
1694 let info = session.page_info().await.unwrap_or_else(|_| json!({}));
1695 let url_now = info
1696 .get("url")
1697 .and_then(|v| v.as_str())
1698 .unwrap_or("about:blank");
1699 if !allow_empty
1700 && (url_now.is_empty()
1701 || url_now == "about:blank"
1702 || url_now.starts_with("chrome://"))
1703 {
1704 return Err(CliError::with_suggestion(
1705 ErrorKind::Usage,
1706 "print-pdf requires a navigated page or step url (blank page refused)",
1707 "Add {\"cmd\":\"goto\",\"url\":\"…\"} before print-pdf, or pass \"url\" on the step, or allow_empty:true",
1708 ));
1709 }
1710 }
1711 let path = step
1712 .get("path")
1713 .and_then(|v| v.as_str())
1714 .map(Path::new);
1715 let mut pdf = session.print_pdf(path).await?;
1716 if let Some(land) = step.get("landscape").and_then(|v| v.as_bool()) {
1718 pdf["landscape"] = json!(land);
1719 }
1720 Ok(pdf)
1721 }
1722 "lighthouse" => {
1723 let url = step
1724 .get("url")
1725 .and_then(|v| v.as_str())
1726 .ok_or_else(|| CliError::new(ErrorKind::Usage, "lighthouse requires url"))?;
1727 let out_dir = step
1728 .get("out_dir")
1729 .or_else(|| step.get("outDir"))
1730 .and_then(|v| v.as_str())
1731 .map(Path::new);
1732 let device = step
1733 .get("device")
1734 .and_then(|v| v.as_str())
1735 .unwrap_or("desktop");
1736 let mode = step
1737 .get("mode")
1738 .and_then(|v| v.as_str())
1739 .unwrap_or("navigation");
1740 let lighthouse_path = step
1741 .get("lighthouse_path")
1742 .or_else(|| step.get("lighthousePath"))
1743 .and_then(|v| v.as_str())
1744 .map(Path::new);
1745 super::lighthouse_to_value(url, out_dir, device, mode, lighthouse_path)
1747 }
1748 "extension" => {
1749 if !flags.category_extensions {
1750 return Err(CliError::with_suggestion(
1751 ErrorKind::Usage,
1752 "extension tools require --category-extensions",
1753 "Pass --category-extensions on the same invocation",
1754 ));
1755 }
1756 let action = step
1757 .get("action")
1758 .and_then(|v| v.as_str())
1759 .unwrap_or("list");
1760 match action {
1761 "list" => session.extension_list().await,
1762 "reload" => {
1763 let id = step
1764 .get("id")
1765 .and_then(|v| v.as_str())
1766 .ok_or_else(|| {
1767 CliError::new(ErrorKind::Usage, "extension reload requires id")
1768 })?;
1769 session.extension_reload(id).await
1770 }
1771 "trigger" => {
1772 let id = step
1773 .get("id")
1774 .and_then(|v| v.as_str())
1775 .ok_or_else(|| {
1776 CliError::new(ErrorKind::Usage, "extension trigger requires id")
1777 })?;
1778 session.extension_trigger(id).await
1779 }
1780 other => Err(CliError::with_suggestion(
1781 ErrorKind::Usage,
1782 format!("unsupported extension action in run: {other}"),
1783 "Use list|reload|trigger in run; install/uninstall are top-level one-shot launches",
1784 )),
1785 }
1786 }
1787 "" => Err(CliError::new(
1788 ErrorKind::Usage,
1789 "step missing cmd/action field",
1790 )),
1791 other => Err(CliError::with_suggestion(
1792 ErrorKind::Usage,
1793 format!("unknown script cmd: {other}"),
1794 run_supported_suggestion(),
1795 )),
1796 }
1797}
1798
1799pub fn argv_to_step(args: &[String]) -> Result<Value, CliError> {
1801 if args.is_empty() {
1802 return Err(CliError::new(ErrorKind::Usage, "exec argv empty"));
1803 }
1804 let cmd = args[0].as_str();
1805 let mut step = json!({ "cmd": cmd });
1806 let obj = step.as_object_mut().ok_or_else(|| {
1807 CliError::new(ErrorKind::Software, "exec step object construction failed")
1808 })?;
1809 let mut i = 1;
1810 while i < args.len() {
1811 let a = args[i].as_str();
1812 if let Some(key) = a.strip_prefix("--") {
1813 let key = key.replace('-', "_");
1814 if i + 1 < args.len() && !args[i + 1].starts_with("--") {
1815 let val = &args[i + 1];
1816 if val == "true" || val == "false" {
1817 obj.insert(key, json!(val == "true"));
1818 } else if let Ok(n) = val.parse::<u64>() {
1819 obj.insert(key, json!(n));
1820 } else if let Ok(n) = val.parse::<f64>() {
1821 obj.insert(key, json!(n));
1822 } else {
1823 obj.insert(key, json!(val));
1824 }
1825 i += 2;
1826 } else {
1827 obj.insert(key, json!(true));
1828 i += 1;
1829 }
1830 } else {
1831 match cmd {
1833 "goto" if !obj.contains_key("url") => {
1834 obj.insert("url".into(), json!(a));
1835 }
1836 "press" | "write" | "hover" | "type" | "extract" | "attr" | "upload"
1837 if !obj.contains_key("target") =>
1838 {
1839 obj.insert("target".into(), json!(a));
1840 }
1841 "write" | "type" if obj.contains_key("target") && !obj.contains_key("value") => {
1842 obj.insert("value".into(), json!(a));
1843 obj.insert("text".into(), json!(a));
1844 }
1845 "keys" if !obj.contains_key("key") => {
1846 obj.insert("key".into(), json!(a));
1847 }
1848 "eval" if !obj.contains_key("expression") => {
1849 obj.insert("expression".into(), json!(a));
1850 }
1851 "wait" if !obj.contains_key("ms") => {
1852 if let Ok(n) = a.parse::<u64>() {
1853 obj.insert("ms".into(), json!(n));
1854 }
1855 }
1856 "view" if a == "verbose" || a == "detailed" || a == "--detailed" => {
1858 obj.insert("verbose".into(), json!(true));
1859 }
1860 "net" | "page" | "console" | "dialog" | "perf" | "heap" | "extension"
1861 | "devtools3p" | "webmcp" | "screencast"
1862 if !obj.contains_key("action") =>
1863 {
1864 obj.insert("action".into(), json!(a));
1865 }
1866 "net"
1867 if obj.get("action").and_then(|v| v.as_str()) == Some("get")
1868 && !obj.contains_key("id") =>
1869 {
1870 obj.insert("id".into(), json!(a));
1871 }
1872 _ => {
1873 return Err(CliError::with_suggestion(
1874 ErrorKind::Usage,
1875 format!("unrecognized exec argument: {a}"),
1876 "Use --flags or run --script NDJSON for complex steps",
1877 ));
1878 }
1879 }
1880 i += 1;
1881 }
1882 }
1883 Ok(step)
1884}
1885
1886pub async fn run_one_step(
1888 life: &Lifecycle,
1889 step: Value,
1890 robots: RobotsPolicy,
1891 capture: CaptureOpts,
1892 flags: RunFlags,
1893) -> Result<Value, CliError> {
1894 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1895 life.record_chrome(session.chrome_pid());
1896 let cmd = step
1897 .get("cmd")
1898 .or_else(|| step.get("action"))
1899 .and_then(|v| v.as_str())
1900 .unwrap_or("")
1901 .to_string();
1902 let step_res = execute_step(&mut session, &cmd, &step, robots, flags).await;
1903 let close = session.shutdown().await;
1904 life.clear_chrome();
1905 close?;
1906 step_res.map(|data| {
1907 json!({
1908 "cmd": cmd,
1909 "ok": true,
1910 "data": data,
1911 })
1912 })
1913}
1914
1915async fn execute_assert(session: &mut OneShotSession, step: &Value) -> Result<Value, CliError> {
1916 if let Some(kind) = step.get("kind").and_then(|v| v.as_str()) {
1924 match kind {
1925 "url" => {
1926 let value = step
1927 .get("value")
1928 .or_else(|| step.get("url_contains"))
1929 .or_else(|| step.get("url"))
1930 .and_then(|v| v.as_str())
1931 .ok_or_else(|| {
1932 CliError::with_suggestion(
1933 ErrorKind::Usage,
1934 "assert url requires value",
1935 "Use {\"cmd\":\"assert\",\"kind\":\"url\",\"value\":\"example.com\"} or url_contains",
1936 )
1937 })?;
1938 let contains = step
1939 .get("contains")
1940 .and_then(|v| v.as_bool())
1941 .unwrap_or(true);
1942 return session.assert_url(value, contains).await;
1943 }
1944 "text" => {
1945 let value = step
1946 .get("value")
1947 .or_else(|| step.get("text_contains"))
1948 .or_else(|| step.get("text"))
1949 .and_then(|v| v.as_str())
1950 .ok_or_else(|| {
1951 CliError::with_suggestion(
1952 ErrorKind::Usage,
1953 "assert text requires value",
1954 "Use {\"cmd\":\"assert\",\"kind\":\"text\",\"value\":\"Hello\"}",
1955 )
1956 })?;
1957 let target = step
1958 .get("ref")
1959 .or_else(|| step.get("target"))
1960 .and_then(|v| v.as_str());
1961 return session.assert_text(value, target).await;
1962 }
1963 "console" => {
1964 let level = step
1965 .get("level")
1966 .and_then(|v| v.as_str())
1967 .unwrap_or("error");
1968 let max = step.get("max").and_then(|v| v.as_u64()).unwrap_or(0);
1969 return session.assert_console(level, max).await;
1970 }
1971 "console_empty" | "console-empty" => {
1973 return session.assert_console_empty().await;
1974 }
1975 "console_no_match" | "console-no-match" => {
1976 let pattern = step
1977 .get("pattern")
1978 .or_else(|| step.get("text"))
1979 .or_else(|| step.get("value"))
1980 .and_then(|v| v.as_str())
1981 .ok_or_else(|| {
1982 CliError::with_suggestion(
1983 ErrorKind::Usage,
1984 "assert console_no_match requires pattern",
1985 "Use {\"cmd\":\"assert\",\"kind\":\"console_no_match\",\"pattern\":\"TypeError\"}",
1986 )
1987 })?;
1988 return session.assert_console_no_match(pattern).await;
1989 }
1990 other => {
1991 return Err(CliError::new(
1992 ErrorKind::Usage,
1993 format!("unknown assert kind: {other}"),
1994 ));
1995 }
1996 }
1997 }
1998 if let Some(url) = step
1999 .get("url_contains")
2000 .or_else(|| step.get("url"))
2001 .and_then(|v| v.as_str())
2002 {
2003 let contains = step
2004 .get("contains")
2005 .and_then(|v| v.as_bool())
2006 .unwrap_or(true);
2007 return session.assert_url(url, contains).await;
2008 }
2009 if let Some(text) = step
2010 .get("text_contains")
2011 .or_else(|| step.get("text"))
2012 .and_then(|v| v.as_str())
2013 {
2014 let target = step
2015 .get("ref")
2016 .or_else(|| step.get("target"))
2017 .and_then(|v| v.as_str());
2018 return session.assert_text(text, target).await;
2019 }
2020 Err(CliError::with_suggestion(
2021 ErrorKind::Usage,
2022 "assert requires kind=url|text|console|console_empty|console_no_match or url/text/url_contains fields",
2023 "Example: {\"cmd\":\"assert\",\"kind\":\"console_empty\"} or kind=url value=example.com",
2024 ))
2025}
2026
2027#[cfg(test)]
2028mod tests {
2029 use super::*;
2030
2031 #[test]
2032 fn argv_to_step_goto_and_wait_flags() {
2033 let step = argv_to_step(&[
2034 "wait".into(),
2035 "--ms".into(),
2036 "250".into(),
2037 "--state".into(),
2038 "networkidle".into(),
2039 "--include-snapshot".into(),
2040 ])
2041 .unwrap();
2042 assert_eq!(step["cmd"], "wait");
2043 assert_eq!(step["ms"], 250);
2044 assert_eq!(step["state"], "networkidle");
2045 assert_eq!(step["include_snapshot"], true);
2046 }
2047
2048 #[test]
2049 fn argv_to_step_net_get_id() {
2050 let step = argv_to_step(&["net".into(), "get".into(), "req-abc".into()]).unwrap();
2051 assert_eq!(step["cmd"], "net");
2052 assert_eq!(step["action"], "get");
2053 assert_eq!(step["id"], "req-abc");
2054 }
2055
2056 #[test]
2057 fn argv_to_step_press_target() {
2058 let step = argv_to_step(&[
2059 "press".into(),
2060 "#btn".into(),
2061 "--dblclick".into(),
2062 "--include-snapshot".into(),
2063 ])
2064 .unwrap();
2065 assert_eq!(step["target"], "#btn");
2066 assert_eq!(step["dblclick"], true);
2067 assert_eq!(step["include_snapshot"], true);
2068 }
2069}