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