Skip to main content

browser_automation_cli/commands_prd/
run.rs

1//! `run --script` — multi-step NDJSON or JSON array, one launch, fail-fast (layers A+B).
2
3use 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
12/// Commands dispatched by `run`/`exec` (GAP-001 / GAP-017 single source of truth).
13pub 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
66/// Top-level browser-adjacent commands intentionally excluded from `run` (GAP-007 / GAP-017).
67/// Each entry is `(cmd, reason)`.
68pub 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
163/// Human-readable list of dispatched cmds for suggestions (GAP-017).
164pub fn run_supported_suggestion() -> String {
165    format!("Supported: {}", RUN_DISPATCHED_CMDS.join(" "))
166}
167
168/// Parse a `run` script body as NDJSON objects and/or a top-level JSON array (GAP-A003).
169pub 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    // Whole-file JSON array (common agent shape).
176    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                // Fall through to line mode if multi-line NDJSON accidentally starts with [
190                // only when parse fails for a pure array file.
191                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                // Single-line array as the only content.
218                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
251/// True when the step requests auto-handling of beforeunload dialogs (GAP-A009).
252/// Accepts bool `true` or string `"accept"` / `"dismiss"` (dismiss still arms the handler).
253/// GAP-003: tool-ref handleBeforeUnload is accept|dismiss (off when absent/false).
254fn 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/// Backward-compatible bool form (true when any auto-handle is requested).
275#[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/// Feature flags for multi-step `run` (mirrors global CLI gates).
314#[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    /// GAP-020: emit one NDJSON line per step on stdout during run.
323    pub json_steps: bool,
324}
325
326impl RunFlags {
327    /// Project CLI global gates into the multi-step dispatcher.
328    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
349/// Execute NDJSON script with feature gates (vision/screencast/memory).
350pub 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    // GAP-A003: accept NDJSON (one object per line) OR a single JSON array of steps.
366    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                // GAP-020: stream NDJSON per step when --json-steps is set.
402                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                // Fail-fast keeps partial steps so agents retain context (GAP-006/016).
416                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    // GAP-020: final envelope always includes per-step results for --json agents.
458    Ok(json!({
459        "ok": true,
460        "total": results.len(),
461        "steps": results,
462    }))
463}
464
465/// Reject unknown fields that look like silent discards (agent-first).
466fn 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            // GAP-019: selector string and/or array of selectors (OR).
544            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            // GAP-024: wait for URL / navigation complete.
564            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            // GAP-023: custom select / badge / popover / role=option.
686            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            // GAP-A009: never inject preventDefault; CDP dialog pump handles beforeunload.
750            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                    // GAP-004: tool-ref isolatedContext is a name string; bool true → auto name.
1187                    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                    // Prefer 0-based index; pageId tool-ref alias; tab_id 1-based from page list.
1201                    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            // Prefer browser engine inside `run` (session already live).
1603            session.scrape(url, robots).await
1604        }
1605        "print-pdf" | "print_pdf" => {
1606            // GAP-001: Page.printToPDF inside multi-step run (same process as goto/view).
1607            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                // GAP-013: refuse blank about:blank PDF unless allow_empty.
1622                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            // GAP-020: optional landscape/scale when provided (passed through if session supports).
1650            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            // External binary; run off the browser session but same process.
1679            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
1732/// Map flat `exec` argv into a single NDJSON step object for the shared dispatcher.
1733pub 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            // positional fallbacks by cmd
1765            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
1818/// Run a single step object in one browser process (exec parity with run).
1819pub 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    // Forms:
1855    // {"cmd":"assert","kind":"url","value":"...","contains":true}
1856    // {"cmd":"assert","kind":"url","url_contains":"..."}
1857    // {"cmd":"assert","kind":"text","value":"...","ref":"@e1"}
1858    // {"cmd":"assert","kind":"console","level":"error","max":0}
1859    // {"cmd":"assert","url":"..."} / {"cmd":"assert","text":"..."}
1860    // {"cmd":"assert","url_contains":"..."} / {"cmd":"assert","text_contains":"..."}
1861    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            // GAP-025
1910            "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}