Skip to main content

browser_automation_cli/commands_prd/
run.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! `run --script` — multi-step NDJSON or JSON array, one launch, fail-fast (layers A+B).
3//!
4//! # Workload
5//!
6//! **Sequential by design (N-134):** script steps run in order with fail-fast.
7//! Internal steps may call parallel callees (scrape/http, filters) under the
8//! process concurrency budget. Never parallelize the step loop itself.
9
10use 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
19/// Commands dispatched by `run`/`exec` (GAP-001 / GAP-017 single source of truth).
20pub 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
73/// Top-level browser-adjacent commands intentionally excluded from `run` (GAP-007 / GAP-017).
74/// Each entry is `(cmd, reason)`.
75pub 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
174/// Human-readable list of dispatched cmds for suggestions (GAP-017).
175pub fn run_supported_suggestion() -> String {
176    format!("Supported: {}", RUN_DISPATCHED_CMDS.join(" "))
177}
178
179/// Parse a `run` script body as NDJSON objects and/or a top-level JSON array (GAP-A003).
180///
181/// Rules (`rules_rust_json_e_ndjson`):
182/// - BOM-aware; LF-delimited NDJSON; one complete JSON value per line
183/// - Per-line size ceiling ([`crate::json_util::MAX_NDJSON_LINE_BYTES`])
184/// - No pretty-print mixing; root of each step must be an object after normalize
185pub 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    // Whole-file JSON array (common agent shape).
193    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                // Fall through to line mode if multi-line NDJSON accidentally starts with [
207                // only when parse fails for a pure array file.
208                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                // Single-line array as the only content.
236                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
269/// True when the step requests auto-handling of beforeunload dialogs (GAP-A009).
270/// Accepts bool `true` or string `"accept"` / `"dismiss"` (dismiss still arms the handler).
271/// GAP-003: tool-ref handleBeforeUnload is accept|dismiss (off when absent/false).
272fn 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/// Backward-compatible bool form (true when any auto-handle is requested).
293#[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/// Feature flags for multi-step `run` (mirrors global CLI gates).
349#[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    /// GAP-020: emit one NDJSON line per step on stdout during run.
358    pub json_steps: bool,
359    /// Per-step wall-clock timeout seconds (0 = no per-step override).
360    pub step_timeout_secs: u64,
361}
362
363impl RunFlags {
364    /// Project CLI global gates into the multi-step dispatcher.
365    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
388/// Execute NDJSON script with feature gates (vision/screencast/memory).
389pub 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    // GAP-A003: accept NDJSON (one object per line) OR a single JSON array of steps.
413    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        // Cooperative cancel between steps (SIGINT/SIGTERM → exit 130).
429        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                // GAP-020: stream NDJSON per step when --json-steps is set.
477                // Compact encode only; propagate encode errors (never swallow).
478                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                // Fail-fast keeps partial steps so agents retain context (GAP-006/016).
487                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    // GAP-020: final envelope always includes per-step results for --json agents.
524    Ok(json!({
525        "ok": true,
526        "total": results.len(),
527        "steps": results,
528    }))
529}
530
531/// Reject unknown fields that look like silent discards (agent-first).
532fn 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            // GAP-019: selector string and/or array of selectors (OR).
610            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            // GAP-024: wait for URL / navigation complete.
630            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            // GAP-023: custom select / badge / popover / role=option.
752            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            // GAP-A009: never inject preventDefault; CDP dialog pump handles beforeunload.
816            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                    // GAP-004: tool-ref isolatedContext is a name string; bool true → auto name.
1254                    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                    // Prefer 0-based index; pageId tool-ref alias; tab_id 1-based from page list.
1268                    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            // Prefer browser engine inside `run` (session already live).
1670            session.scrape(url, robots).await
1671        }
1672        "print-pdf" | "print_pdf" => {
1673            // GAP-001: Page.printToPDF inside multi-step run (same process as goto/view).
1674            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                // GAP-013: refuse blank about:blank PDF unless allow_empty.
1689                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            // GAP-020: optional landscape/scale when provided (passed through if session supports).
1717            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            // External binary; run off the browser session but same process.
1746            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
1799/// Map flat `exec` argv into a single NDJSON step object for the shared dispatcher.
1800pub 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            // positional fallbacks by cmd
1832            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                // CLI flag is --detailed; JSON/tool-ref key remains verbose.
1857                "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
1886/// Run a single step object in one browser process (exec parity with run).
1887pub 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    // Forms:
1917    // {"cmd":"assert","kind":"url","value":"...","contains":true}
1918    // {"cmd":"assert","kind":"url","url_contains":"..."}
1919    // {"cmd":"assert","kind":"text","value":"...","ref":"@e1"}
1920    // {"cmd":"assert","kind":"console","level":"error","max":0}
1921    // {"cmd":"assert","url":"..."} / {"cmd":"assert","text":"..."}
1922    // {"cmd":"assert","url_contains":"..."} / {"cmd":"assert","text_contains":"..."}
1923    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            // GAP-025
1972            "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}