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/// Parse a `run` script body as NDJSON objects and/or a top-level JSON array (GAP-A003).
13pub fn parse_run_script(text: &str) -> Result<Vec<Value>, CliError> {
14    let trimmed = text.trim();
15    if trimmed.is_empty() {
16        return Ok(Vec::new());
17    }
18
19    // Whole-file JSON array (common agent shape).
20    if trimmed.starts_with('[') {
21        match serde_json::from_str::<Value>(trimmed) {
22            Ok(Value::Array(items)) => {
23                return normalize_step_values(items, "script array");
24            }
25            Ok(_) => {
26                return Err(CliError::with_suggestion(
27                    ErrorKind::Data,
28                    "script starts with '[' but is not a JSON array of step objects",
29                    "Use [{\"cmd\":\"goto\",\"url\":\"…\"}, …] or NDJSON one object per line",
30                ));
31            }
32            Err(e) => {
33                // Fall through to line mode if multi-line NDJSON accidentally starts with [
34                // only when parse fails for a pure array file.
35                if !trimmed.contains('\n') {
36                    return Err(CliError::with_suggestion(
37                        ErrorKind::Data,
38                        format!("invalid JSON array script: {e}"),
39                        "Each array element must be an object with \"cmd\" or \"action\"",
40                    ));
41                }
42            }
43        }
44    }
45
46    let mut steps: Vec<Value> = Vec::new();
47    for (lineno, line) in text.lines().enumerate() {
48        let line = line.trim();
49        if line.is_empty() || line.starts_with('#') {
50            continue;
51        }
52        let v: Value = serde_json::from_str(line).map_err(|e| {
53            CliError::with_suggestion(
54                ErrorKind::Data,
55                format!("script line {}: invalid JSON: {e}", lineno + 1),
56                "Each non-empty line must be one JSON object with \"cmd\", or use a JSON array file",
57            )
58        })?;
59        match v {
60            Value::Array(items) if steps.is_empty() && lineno == 0 => {
61                // Single-line array as the only content.
62                return normalize_step_values(items, &format!("script line {}", lineno + 1));
63            }
64            Value::Array(_) => {
65                return Err(CliError::with_suggestion(
66                    ErrorKind::Data,
67                    format!(
68                        "script line {}: nested JSON array not allowed in NDJSON mode",
69                        lineno + 1
70                    ),
71                    "Use either one JSON array for the whole file, or one object per line",
72                ));
73            }
74            other => steps.push(other),
75        }
76    }
77    normalize_step_values(steps, "script")
78}
79
80fn normalize_step_values(items: Vec<Value>, ctx: &str) -> Result<Vec<Value>, CliError> {
81    let mut out = Vec::with_capacity(items.len());
82    for (i, v) in items.into_iter().enumerate() {
83        if !v.is_object() {
84            return Err(CliError::with_suggestion(
85                ErrorKind::Data,
86                format!("{ctx} step {i}: expected object with \"cmd\""),
87                "Example: {\"cmd\":\"goto\",\"url\":\"https://example.com\"}",
88            ));
89        }
90        out.push(v);
91    }
92    Ok(out)
93}
94
95/// True when the step requests auto-handling of beforeunload dialogs (GAP-A009).
96/// Accepts bool `true` or string `"accept"` / `"dismiss"` (dismiss still arms the handler).
97fn step_wants_beforeunload_handle(step: &Value) -> bool {
98    let v = step
99        .get("handle_before_unload")
100        .or_else(|| step.get("handleBeforeUnload"));
101    match v {
102        Some(Value::Bool(b)) => *b,
103        Some(Value::String(s)) => {
104            let s = s.trim().to_ascii_lowercase();
105            s == "accept" || s == "dismiss" || s == "true" || s == "1"
106        }
107        _ => false,
108    }
109}
110
111#[cfg(test)]
112mod parse_script_tests {
113    use super::*;
114
115    #[test]
116    fn parses_ndjson_objects() {
117        let text = r#"{"cmd":"goto","url":"https://example.com"}
118{"cmd":"eval","expression":"1+1"}
119"#;
120        let steps = parse_run_script(text).unwrap();
121        assert_eq!(steps.len(), 2);
122        assert_eq!(steps[0]["cmd"], "goto");
123    }
124
125    #[test]
126    fn parses_json_array() {
127        let text = r#"[
128  {"cmd":"goto","url":"https://example.com"},
129  {"cmd":"reload","ignore_cache":true}
130]"#;
131        let steps = parse_run_script(text).unwrap();
132        assert_eq!(steps.len(), 2);
133        assert_eq!(steps[1]["cmd"], "reload");
134    }
135
136    #[test]
137    fn parses_single_line_array() {
138        let text = r#"[{"cmd":"goto","url":"about:blank"}]"#;
139        let steps = parse_run_script(text).unwrap();
140        assert_eq!(steps.len(), 1);
141    }
142}
143
144/// Feature flags for multi-step `run` (mirrors global CLI gates).
145#[derive(Debug, Clone, Copy, Default)]
146pub struct RunFlags {
147    pub experimental_vision: bool,
148    pub experimental_screencast: bool,
149    pub category_memory: bool,
150    pub category_extensions: bool,
151    pub category_third_party: bool,
152    pub category_webmcp: bool,
153}
154
155impl RunFlags {
156    /// Project CLI global gates into the multi-step dispatcher.
157    pub fn from_globals(
158        experimental_vision: bool,
159        experimental_screencast: bool,
160        category_memory: bool,
161        category_extensions: bool,
162        category_third_party: bool,
163        category_webmcp: bool,
164    ) -> Self {
165        Self {
166            experimental_vision,
167            experimental_screencast,
168            category_memory,
169            category_extensions,
170            category_third_party,
171            category_webmcp,
172        }
173    }
174}
175
176/// Execute NDJSON script with feature gates (vision/screencast/memory).
177pub async fn run_script_with_flags(
178    life: &Lifecycle,
179    script_path: &Path,
180    robots: RobotsPolicy,
181    capture: CaptureOpts,
182    flags: RunFlags,
183) -> Result<Value, CliError> {
184    let text = std::fs::read_to_string(script_path).map_err(|e| {
185        CliError::with_suggestion(
186            ErrorKind::NoInput,
187            format!("cannot read script {}: {e}", script_path.display()),
188            "Pass an existing NDJSON/JSONL or JSON-array file to --script",
189        )
190    })?;
191
192    // GAP-A003: accept NDJSON (one object per line) OR a single JSON array of steps.
193    let steps = parse_run_script(&text)?;
194
195    if steps.is_empty() {
196        return Err(CliError::with_suggestion(
197            ErrorKind::Data,
198            "script has no steps",
199            "Add at least one NDJSON line or a JSON array of objects with a cmd field",
200        ));
201    }
202
203    let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
204    if let Ok(mut ledger) = life.ledger.lock() {
205        ledger.chrome_launched = true;
206        ledger.chrome_pid = session.chrome_pid();
207    }
208
209    let mut results: Vec<Value> = Vec::new();
210    for (idx, step) in steps.iter().enumerate() {
211        let cmd = step
212            .get("cmd")
213            .or_else(|| step.get("action"))
214            .and_then(|v| v.as_str())
215            .unwrap_or("");
216
217        let step_res = execute_step(&mut session, cmd, step, robots, flags).await;
218        match step_res {
219            Ok(data) => {
220                results.push(json!({
221                    "index": idx,
222                    "cmd": cmd,
223                    "ok": true,
224                    "data": data,
225                }));
226            }
227            Err(e) => {
228                let _ = session.shutdown().await;
229                if let Ok(mut ledger) = life.ledger.lock() {
230                    ledger.chrome_launched = false;
231                    ledger.chrome_pid = None;
232                }
233                // Fail-fast keeps partial steps so agents retain context (GAP-006/016).
234                results.push(json!({
235                    "index": idx,
236                    "cmd": cmd,
237                    "ok": false,
238                    "error": {
239                        "kind": e.kind().as_str(),
240                        "message": e.message(),
241                        "suggestion": e.suggestion(),
242                    }
243                }));
244                return Ok(json!({
245                    "total": steps.len(),
246                    "failed_index": idx,
247                    "failed_cmd": cmd,
248                    "steps": results,
249                    "ok": false,
250                    "error": {
251                        "kind": e.kind().as_str(),
252                        "message": format!("run fail-fast at step {idx} cmd={cmd}: {e}"),
253                        "suggestion": crate::i18n::suggestion_key("run_fail_fast", None),
254                        "exit_code": e.exit_code(),
255                    }
256                }));
257            }
258        }
259    }
260
261    let close = session.shutdown().await;
262    if let Ok(mut ledger) = life.ledger.lock() {
263        ledger.chrome_launched = false;
264        ledger.chrome_pid = None;
265    }
266    close?;
267
268    Ok(json!({
269        "total": results.len(),
270        "steps": results,
271    }))
272}
273
274/// Reject unknown fields that look like silent discards (agent-first).
275fn reject_unknown_step_fields(cmd: &str, step: &Value) -> Result<(), CliError> {
276    let Some(obj) = step.as_object() else {
277        return Ok(());
278    };
279    let allowed: &[&str] = match cmd {
280        "scroll" => &[
281            "cmd", "action", "target", "selector", "delta_x", "delta_y", "deltaX", "deltaY", "dx",
282            "dy",
283        ],
284        "goto" => &[
285            "cmd",
286            "action",
287            "url",
288            "init_script",
289            "initScript",
290            "handle_before_unload",
291            "handleBeforeUnload",
292            "navigation_timeout_ms",
293            "navigationTimeoutMs",
294            "timeout_ms",
295            "timeoutMs",
296        ],
297        _ => return Ok(()),
298    };
299    for key in obj.keys() {
300        if !allowed.iter().any(|a| a == key) {
301            return Err(CliError::with_suggestion(
302                ErrorKind::Usage,
303                format!("unknown field `{key}` on step cmd={cmd}"),
304                format!("Allowed fields for {cmd}: {}", allowed.join(", ")),
305            ));
306        }
307    }
308    Ok(())
309}
310
311async fn execute_step(
312    session: &mut OneShotSession,
313    cmd: &str,
314    step: &Value,
315    robots: RobotsPolicy,
316    flags: RunFlags,
317) -> Result<Value, CliError> {
318    reject_unknown_step_fields(cmd, step)?;
319    match cmd {
320        "goto" => {
321            let url = step
322                .get("url")
323                .and_then(|v| v.as_str())
324                .ok_or_else(|| CliError::new(ErrorKind::Usage, "goto requires url"))?;
325            let init = step
326                .get("init_script")
327                .or_else(|| step.get("initScript"))
328                .and_then(|v| v.as_str());
329            let beforeunload = step_wants_beforeunload_handle(step);
330            let nav_timeout_ms = step
331                .get("navigation_timeout_ms")
332                .or_else(|| step.get("timeout"))
333                .and_then(|v| v.as_u64());
334            session
335                .goto_with_options(url, robots, init, beforeunload, nav_timeout_ms)
336                .await
337        }
338        "wait" => {
339            let ms = step.get("ms").and_then(|v| v.as_u64());
340            let texts: Vec<String> = match step.get("text") {
341                Some(Value::Array(arr)) => arr
342                    .iter()
343                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
344                    .collect(),
345                Some(Value::String(s)) => vec![s.clone()],
346                _ => Vec::new(),
347            };
348            let selector = step
349                .get("selector")
350                .or_else(|| step.get("sel"))
351                .and_then(|v| v.as_str());
352            let state = step.get("state").and_then(|v| v.as_str());
353            let include_snapshot = step
354                .get("include_snapshot")
355                .or_else(|| step.get("includeSnapshot"))
356                .and_then(|v| v.as_bool())
357                .unwrap_or(false);
358            if texts.is_empty() && selector.is_none() && state.is_none() && !include_snapshot {
359                session.wait_ms(ms.unwrap_or(0)).await
360            } else {
361                session
362                    .wait_for_any(ms, &texts, selector, state, include_snapshot)
363                    .await
364            }
365        }
366        "hover" => {
367            let target = step
368                .get("target")
369                .or_else(|| step.get("ref"))
370                .or_else(|| step.get("selector"))
371                .and_then(|v| v.as_str())
372                .ok_or_else(|| CliError::new(ErrorKind::Usage, "hover requires target"))?;
373            let include_snapshot = step
374                .get("include_snapshot")
375                .or_else(|| step.get("includeSnapshot"))
376                .and_then(|v| v.as_bool())
377                .unwrap_or(false);
378            session.hover(target, include_snapshot).await
379        }
380        "drag" => {
381            let from = step
382                .get("from")
383                .and_then(|v| v.as_str())
384                .ok_or_else(|| CliError::new(ErrorKind::Usage, "drag requires --from"))?;
385            let to = step
386                .get("to")
387                .and_then(|v| v.as_str())
388                .ok_or_else(|| CliError::new(ErrorKind::Usage, "drag requires --to"))?;
389            let include_snapshot = step
390                .get("include_snapshot")
391                .or_else(|| step.get("includeSnapshot"))
392                .and_then(|v| v.as_bool())
393                .unwrap_or(false);
394            session.drag(from, to, include_snapshot).await
395        }
396        "fill-form" | "fill_form" => {
397            let arr = step
398                .get("fields")
399                .or_else(|| step.get("json"))
400                .ok_or_else(|| {
401                    CliError::new(ErrorKind::Usage, "fill-form requires fields array or json")
402                })?;
403            let items = if let Some(s) = arr.as_str() {
404                serde_json::from_str::<Value>(s).map_err(|e| {
405                    CliError::new(ErrorKind::Usage, format!("fill-form json: {e}"))
406                })?
407            } else {
408                arr.clone()
409            };
410            let list = items.as_array().ok_or_else(|| {
411                CliError::new(ErrorKind::Usage, "fill-form fields must be an array")
412            })?;
413            let mut fields = Vec::new();
414            for item in list {
415                let target = item
416                    .get("target")
417                    .or_else(|| item.get("uid"))
418                    .or_else(|| item.get("selector"))
419                    .or_else(|| item.get("ref"))
420                    .and_then(|v| v.as_str())
421                    .ok_or_else(|| {
422                        CliError::new(ErrorKind::Usage, "fill-form field missing target/uid")
423                    })?
424                    .to_string();
425                let value = item
426                    .get("value")
427                    .or_else(|| item.get("text"))
428                    .and_then(|v| v.as_str())
429                    .ok_or_else(|| {
430                        CliError::new(ErrorKind::Usage, "fill-form field missing value")
431                    })?
432                    .to_string();
433                fields.push((target, value));
434            }
435            let include_snapshot = step
436                .get("include_snapshot")
437                .or_else(|| step.get("includeSnapshot"))
438                .and_then(|v| v.as_bool())
439                .unwrap_or(false);
440            session.fill_form(&fields, include_snapshot).await
441        }
442        "upload" => {
443            let target = step
444                .get("target")
445                .or_else(|| step.get("ref"))
446                .or_else(|| step.get("selector"))
447                .or_else(|| step.get("uid"))
448                .and_then(|v| v.as_str())
449                .ok_or_else(|| CliError::new(ErrorKind::Usage, "upload requires target"))?;
450            let path = step
451                .get("path")
452                .and_then(|v| v.as_str())
453                .ok_or_else(|| CliError::new(ErrorKind::Usage, "upload requires path"))?;
454            let include_snapshot = step
455                .get("include_snapshot")
456                .or_else(|| step.get("includeSnapshot"))
457                .and_then(|v| v.as_bool())
458                .unwrap_or(false);
459            session
460                .upload(target, Path::new(path), include_snapshot)
461                .await
462        }
463        "back" => session.back().await,
464        "forward" => session.forward().await,
465        "reload" => {
466            let ignore_cache = step
467                .get("ignore_cache")
468                .or_else(|| step.get("ignoreCache"))
469                .and_then(|v| v.as_bool())
470                .unwrap_or(false);
471            let init = step
472                .get("init_script")
473                .or_else(|| step.get("initScript"))
474                .and_then(|v| v.as_str());
475            // GAP-A009: never inject preventDefault; CDP dialog pump handles beforeunload.
476            let beforeunload = step_wants_beforeunload_handle(step);
477            session
478                .reload_with_options(ignore_cache, init, beforeunload)
479                .await
480        }
481        "view" => {
482            let verbose = step
483                .get("verbose")
484                .and_then(|v| v.as_bool())
485                .unwrap_or(false);
486            session.view(verbose).await
487        }
488        "press" | "click" => {
489            let target = step
490                .get("target")
491                .or_else(|| step.get("ref"))
492                .or_else(|| step.get("selector"))
493                .and_then(|v| v.as_str())
494                .ok_or_else(|| CliError::new(ErrorKind::Usage, "press requires target"))?;
495            let dbl = step
496                .get("dblclick")
497                .and_then(|v| v.as_bool())
498                .unwrap_or(false);
499            let include_snapshot = step
500                .get("include_snapshot")
501                .or_else(|| step.get("includeSnapshot"))
502                .and_then(|v| v.as_bool())
503                .unwrap_or(false);
504            session.press(target, dbl, include_snapshot).await
505        }
506        "write" | "fill" => {
507            let target = step
508                .get("target")
509                .or_else(|| step.get("ref"))
510                .or_else(|| step.get("selector"))
511                .and_then(|v| v.as_str())
512                .ok_or_else(|| CliError::new(ErrorKind::Usage, "write requires target"))?;
513            let value = step
514                .get("value")
515                .or_else(|| step.get("text"))
516                .and_then(|v| v.as_str())
517                .ok_or_else(|| CliError::new(ErrorKind::Usage, "write requires value"))?;
518            let include_snapshot = step
519                .get("include_snapshot")
520                .or_else(|| step.get("includeSnapshot"))
521                .and_then(|v| v.as_bool())
522                .unwrap_or(false);
523            session.write(target, value, include_snapshot).await
524        }
525        "keys" => {
526            let key = step
527                .get("key")
528                .and_then(|v| v.as_str())
529                .ok_or_else(|| CliError::new(ErrorKind::Usage, "keys requires key"))?;
530            let include_snapshot = step
531                .get("include_snapshot")
532                .or_else(|| step.get("includeSnapshot"))
533                .and_then(|v| v.as_bool())
534                .unwrap_or(false);
535            session.keys(key, include_snapshot).await
536        }
537        "type" => {
538            let focus_only = step
539                .get("focus_only")
540                .or_else(|| step.get("focusOnly"))
541                .and_then(|v| v.as_bool())
542                .unwrap_or(false);
543            let target = step
544                .get("target")
545                .or_else(|| step.get("ref"))
546                .or_else(|| step.get("selector"))
547                .or_else(|| step.get("uid"))
548                .and_then(|v| v.as_str());
549            if target.is_none() && !focus_only {
550                return Err(CliError::new(
551                    ErrorKind::Usage,
552                    "type requires target or focus_only",
553                ));
554            }
555            let text = step
556                .get("text")
557                .and_then(|v| v.as_str())
558                .ok_or_else(|| CliError::new(ErrorKind::Usage, "type requires text"))?;
559            let clear = step
560                .get("clear")
561                .and_then(|v| v.as_bool())
562                .unwrap_or(false);
563            let submit = step.get("submit").and_then(|v| v.as_str());
564            session
565                .type_text(target, text, clear, submit, focus_only)
566                .await
567        }
568        "click-at" | "click_at" => {
569            if !flags.experimental_vision {
570                return Err(CliError::with_suggestion(
571                    ErrorKind::Usage,
572                    "click-at requires --experimental-vision",
573                    "Pass --experimental-vision on the same invocation",
574                ));
575            }
576            let x = step
577                .get("x")
578                .and_then(|v| v.as_f64())
579                .ok_or_else(|| CliError::new(ErrorKind::Usage, "click-at requires x"))?;
580            let y = step
581                .get("y")
582                .and_then(|v| v.as_f64())
583                .ok_or_else(|| CliError::new(ErrorKind::Usage, "click-at requires y"))?;
584            let dblclick = step
585                .get("dblclick")
586                .and_then(|v| v.as_bool())
587                .unwrap_or(false);
588            let include_snapshot = step
589                .get("include_snapshot")
590                .or_else(|| step.get("includeSnapshot"))
591                .and_then(|v| v.as_bool())
592                .unwrap_or(false);
593            session.click_at(x, y, dblclick, include_snapshot).await
594        }
595        "eval" => {
596            let expr = step
597                .get("expression")
598                .or_else(|| step.get("function"))
599                .or_else(|| step.get("js"))
600                .and_then(|v| v.as_str())
601                .ok_or_else(|| CliError::new(ErrorKind::Usage, "eval requires expression"))?;
602            let args = step.get("args").map(|v| {
603                if let Some(s) = v.as_str() {
604                    s.to_string()
605                } else {
606                    v.to_string()
607                }
608            });
609            let dialog_action = step
610                .get("dialog_action")
611                .or_else(|| step.get("dialogAction"))
612                .and_then(|v| v.as_str());
613            let file_path = step
614                .get("file_path")
615                .or_else(|| step.get("filePath"))
616                .and_then(|v| v.as_str())
617                .map(Path::new);
618            session
619                .eval(expr, args.as_deref(), dialog_action, file_path)
620                .await
621        }
622        "grab" | "screenshot" => {
623            let path = step
624                .get("path")
625                .and_then(|v| v.as_str())
626                .map(std::path::PathBuf::from);
627            let format = step
628                .get("format")
629                .and_then(|v| v.as_str())
630                .unwrap_or("png");
631            let full_page = step
632                .get("full_page")
633                .or_else(|| step.get("fullPage"))
634                .and_then(|v| v.as_bool())
635                .unwrap_or(false);
636            let quality = step
637                .get("quality")
638                .and_then(|v| v.as_i64())
639                .map(|n| n as i32);
640            let element = step
641                .get("element")
642                .or_else(|| step.get("selector"))
643                .or_else(|| step.get("ref"))
644                .and_then(|v| v.as_str());
645            session
646                .grab(path.as_deref(), format, full_page, quality, element)
647                .await
648        }
649        "extract" => {
650            let target = step
651                .get("ref")
652                .or_else(|| step.get("target"))
653                .or_else(|| step.get("selector"))
654                .and_then(|v| v.as_str())
655                .ok_or_else(|| {
656                    CliError::new(ErrorKind::Usage, "extract requires ref, target, or selector")
657                })?;
658            let attr = step.get("attr").and_then(|v| v.as_str());
659            session.extract(target, attr).await
660        }
661        "text" => {
662            let target = step
663                .get("ref")
664                .or_else(|| step.get("target"))
665                .or_else(|| step.get("selector"))
666                .and_then(|v| v.as_str())
667                .ok_or_else(|| {
668                    CliError::new(ErrorKind::Usage, "text requires ref, target, or selector")
669                })?;
670            session.text(target).await
671        }
672        "scroll" => {
673            let target = step
674                .get("ref")
675                .or_else(|| step.get("target"))
676                .or_else(|| step.get("selector"))
677                .and_then(|v| v.as_str());
678            let delta_x = step
679                .get("delta_x")
680                .or_else(|| step.get("deltaX"))
681                .or_else(|| step.get("dx"))
682                .and_then(|v| v.as_f64())
683                .unwrap_or(0.0);
684            let delta_y = step
685                .get("delta_y")
686                .or_else(|| step.get("deltaY"))
687                .or_else(|| step.get("dy"))
688                .and_then(|v| v.as_f64())
689                .unwrap_or(0.0);
690            session.scroll(target, delta_x, delta_y).await
691        }
692        "cookie" => {
693            let action = step
694                .get("action")
695                .and_then(|v| v.as_str())
696                .unwrap_or("list");
697            match action {
698                "list" => {
699                    let url = step.get("url").and_then(|v| v.as_str());
700                    session.cookie_list(url).await
701                }
702                "set" => {
703                    let body = step
704                        .get("json")
705                        .or_else(|| step.get("cookies"))
706                        .map(|v| {
707                            if let Some(s) = v.as_str() {
708                                s.to_string()
709                            } else {
710                                v.to_string()
711                            }
712                        })
713                        .ok_or_else(|| {
714                            CliError::new(ErrorKind::Usage, "cookie set requires json/cookies")
715                        })?;
716                    session.cookie_set(&body).await
717                }
718                "clear" => session.cookie_clear().await,
719                other => Err(CliError::new(
720                    ErrorKind::Usage,
721                    format!("unknown cookie action: {other}"),
722                )),
723            }
724        }
725        "attr" => {
726            let target = step
727                .get("ref")
728                .or_else(|| step.get("target"))
729                .or_else(|| step.get("selector"))
730                .and_then(|v| v.as_str())
731                .ok_or_else(|| {
732                    CliError::new(ErrorKind::Usage, "attr requires ref, target, or selector")
733                })?;
734            let name = step
735                .get("name")
736                .or_else(|| step.get("attr"))
737                .and_then(|v| v.as_str())
738                .ok_or_else(|| CliError::new(ErrorKind::Usage, "attr requires name"))?;
739            session.attr(target, name).await
740        }
741        "assert" => execute_assert(session, step).await,
742        "console" => {
743            let action = step
744                .get("action")
745                .and_then(|v| v.as_str())
746                .unwrap_or("list");
747            match action {
748                "list" => {
749                    let page_idx = step
750                        .get("page_idx")
751                        .or_else(|| step.get("pageIdx"))
752                        .and_then(|v| v.as_u64())
753                        .map(|n| n as usize);
754                    let page_size = step
755                        .get("page_size")
756                        .or_else(|| step.get("pageSize"))
757                        .and_then(|v| v.as_u64())
758                        .map(|n| n as usize);
759                    let types = step.get("types").and_then(|v| v.as_str());
760                    let include_preserved = step
761                        .get("include_preserved")
762                        .or_else(|| step.get("includePreservedMessages"))
763                        .and_then(|v| v.as_bool())
764                        .unwrap_or(false);
765                    let sw = step
766                        .get("service_worker_id")
767                        .or_else(|| step.get("serviceWorkerId"))
768                        .and_then(|v| v.as_str());
769                    session.console_list(page_idx, page_size, types, include_preserved, sw)
770                }
771                "get" => {
772                    let id = step
773                        .get("id")
774                        .or_else(|| step.get("msgid"))
775                        .or_else(|| step.get("index"))
776                        .and_then(|v| v.as_u64())
777                        .ok_or_else(|| {
778                            CliError::new(
779                                ErrorKind::Usage,
780                                "console get requires id|msgid|index (0-based)",
781                            )
782                        })? as usize;
783                    session.console_get(id)
784                }
785                "clear" => session.console_clear(),
786                "dump" => {
787                    let path = step
788                        .get("path")
789                        .and_then(|v| v.as_str())
790                        .ok_or_else(|| {
791                            CliError::new(ErrorKind::Usage, "console dump requires path")
792                        })?;
793                    session.console_dump(Path::new(path))
794                }
795                other => Err(CliError::new(
796                    ErrorKind::Usage,
797                    format!("unknown console action: {other}"),
798                )),
799            }
800        }
801        "net" => {
802            let action = step
803                .get("action")
804                .and_then(|v| v.as_str())
805                .unwrap_or("list");
806            match action {
807                "list" => {
808                    let page_idx = step
809                        .get("page_idx")
810                        .or_else(|| step.get("pageIdx"))
811                        .and_then(|v| v.as_u64())
812                        .map(|n| n as usize);
813                    let page_size = step
814                        .get("page_size")
815                        .or_else(|| step.get("pageSize"))
816                        .and_then(|v| v.as_u64())
817                        .map(|n| n as usize);
818                    let resource_types = step
819                        .get("resource_types")
820                        .or_else(|| step.get("resourceTypes"))
821                        .and_then(|v| v.as_str());
822                    let include_preserved = step
823                        .get("include_preserved")
824                        .or_else(|| step.get("includePreservedRequests"))
825                        .and_then(|v| v.as_bool())
826                        .unwrap_or(false);
827                    session.net_list(page_idx, page_size, resource_types, include_preserved)
828                }
829                "get" => {
830                    let id = step
831                        .get("id")
832                        .map(|v| {
833                            if let Some(s) = v.as_str() {
834                                s.to_string()
835                            } else if let Some(n) = v.as_u64() {
836                                n.to_string()
837                            } else {
838                                String::new()
839                            }
840                        })
841                        .filter(|s| !s.is_empty())
842                        .ok_or_else(|| {
843                            CliError::new(ErrorKind::Usage, "net get requires id (index or requestId)")
844                        })?;
845                    let request_path = step
846                        .get("request_path")
847                        .and_then(|v| v.as_str())
848                        .map(Path::new);
849                    let response_path = step
850                        .get("response_path")
851                        .and_then(|v| v.as_str())
852                        .map(Path::new);
853                    session.net_get(&id, request_path, response_path)
854                }
855                other => Err(CliError::new(
856                    ErrorKind::Usage,
857                    format!("unknown net action: {other}"),
858                )),
859            }
860        }
861        "page" => {
862            let action = step
863                .get("action")
864                .and_then(|v| v.as_str())
865                .unwrap_or("info");
866            match action {
867                "info" | "" => session.page_info().await,
868                "list" => session.page_list().await,
869                "new" => {
870                    let url = step.get("url").and_then(|v| v.as_str());
871                    let background = step
872                        .get("background")
873                        .and_then(|v| v.as_bool())
874                        .unwrap_or(false);
875                    let isolated = step
876                        .get("isolated_context")
877                        .or_else(|| step.get("isolatedContext"))
878                        .and_then(|v| v.as_bool())
879                        .unwrap_or(false);
880                    session.page_new(url, background, isolated).await
881                }
882                "select" => {
883                    // Prefer 0-based index; pageId tool-ref alias; tab_id 1-based from page list.
884                    let index = if let Some(i) = step
885                        .get("index")
886                        .or_else(|| step.get("page_id"))
887                        .or_else(|| step.get("pageId"))
888                        .and_then(|v| v.as_u64())
889                    {
890                        i as usize
891                    } else if let Some(tab_id) = step.get("tab_id").and_then(|v| v.as_u64()) {
892                        if tab_id == 0 {
893                            return Err(CliError::new(
894                                ErrorKind::Usage,
895                                "page select tab_id is 1-based (got 0)",
896                            ));
897                        }
898                        (tab_id - 1) as usize
899                    } else {
900                        return Err(CliError::new(
901                            ErrorKind::Usage,
902                            "page select requires index/pageId (0-based) or tab_id (1-based)",
903                        ));
904                    };
905                    let bring_to_front = step
906                        .get("bring_to_front")
907                        .or_else(|| step.get("bringToFront"))
908                        .and_then(|v| v.as_bool())
909                        .unwrap_or(true);
910                    session.page_select(index, bring_to_front).await
911                }
912                "close" => {
913                    let index = step
914                        .get("index")
915                        .or_else(|| step.get("page_id"))
916                        .or_else(|| step.get("pageId"))
917                        .and_then(|v| v.as_u64())
918                        .map(|i| i as usize);
919                    session.page_close(index).await
920                }
921                "tab-id" | "tab_id" | "get_tab_id" => {
922                    let tab = session.active_tab_id_string().ok_or_else(|| {
923                        CliError::with_suggestion(
924                            ErrorKind::Browser,
925                            "no active tab id",
926                            "Open a page first (goto / page new)",
927                        )
928                    })?;
929                    Ok(serde_json::json!({
930                        "tab_id": tab,
931                        "tool": "get_tab_id",
932                    }))
933                }
934                other => Err(CliError::new(
935                    ErrorKind::Usage,
936                    format!("unknown page action: {other}"),
937                )),
938            }
939        }
940        "dialog" => {
941            let action = step
942                .get("action")
943                .and_then(|v| v.as_str())
944                .unwrap_or("accept");
945            let text = step.get("text").and_then(|v| v.as_str());
946            match action {
947                "accept" => session.dialog(true, text).await,
948                "dismiss" => session.dialog(false, None).await,
949                other => Err(CliError::new(
950                    ErrorKind::Usage,
951                    format!("unknown dialog action: {other}"),
952                )),
953            }
954        }
955        "emulate" => {
956            let headers_owned = step.get("extra_headers").map(|v| {
957                if let Some(s) = v.as_str() {
958                    s.to_string()
959                } else {
960                    v.to_string()
961                }
962            });
963            session
964                .emulate(
965                    step.get("user_agent").and_then(|v| v.as_str()),
966                    step.get("locale").and_then(|v| v.as_str()),
967                    step.get("timezone").and_then(|v| v.as_str()),
968                    step.get("offline")
969                        .and_then(|v| v.as_bool())
970                        .unwrap_or(false),
971                    step.get("latitude").and_then(|v| v.as_f64()),
972                    step.get("longitude").and_then(|v| v.as_f64()),
973                    step.get("media").and_then(|v| v.as_str()),
974                    step.get("network_conditions").and_then(|v| v.as_str()),
975                    step.get("cpu_throttling_rate").and_then(|v| v.as_f64()),
976                    step.get("color_scheme").and_then(|v| v.as_str()),
977                    headers_owned.as_deref(),
978                    step.get("viewport").and_then(|v| v.as_str()),
979                )
980                .await
981        }
982        "resize" => {
983            let width = step
984                .get("width")
985                .and_then(|v| v.as_i64())
986                .ok_or_else(|| CliError::new(ErrorKind::Usage, "resize requires width"))?
987                as i32;
988            let height = step
989                .get("height")
990                .and_then(|v| v.as_i64())
991                .ok_or_else(|| CliError::new(ErrorKind::Usage, "resize requires height"))?
992                as i32;
993            let scale = step.get("scale").and_then(|v| v.as_f64()).unwrap_or(1.0);
994            let mobile = step
995                .get("mobile")
996                .and_then(|v| v.as_bool())
997                .unwrap_or(false);
998            session.resize(width, height, scale, mobile).await
999        }
1000        "perf" => {
1001            let action = step
1002                .get("action")
1003                .and_then(|v| v.as_str())
1004                .unwrap_or("insight");
1005            match action {
1006                "start" => {
1007                    let path = step.get("path").and_then(|v| v.as_str()).map(Path::new);
1008                    let auto_stop = step
1009                        .get("auto_stop")
1010                        .or_else(|| step.get("autoStop"))
1011                        .and_then(|v| v.as_bool())
1012                        .unwrap_or(false);
1013                    let reload = step
1014                        .get("reload")
1015                        .and_then(|v| v.as_bool())
1016                        .unwrap_or(false);
1017                    session.perf_start(path, reload, auto_stop).await
1018                }
1019                "stop" => {
1020                    let path = step.get("path").and_then(|v| v.as_str()).map(Path::new);
1021                    session.perf_stop(path).await
1022                }
1023                "insight" => {
1024                    let name = step
1025                        .get("name")
1026                        .or_else(|| step.get("insight_name"))
1027                        .or_else(|| step.get("insightName"))
1028                        .and_then(|v| v.as_str());
1029                    let set_id = step
1030                        .get("insight_set_id")
1031                        .or_else(|| step.get("insightSetId"))
1032                        .and_then(|v| v.as_str());
1033                    session.perf_insight(name, set_id).await
1034                }
1035                other => Err(CliError::new(
1036                    ErrorKind::Usage,
1037                    format!("unknown perf action: {other}"),
1038                )),
1039            }
1040        }
1041        "screencast" => {
1042            if !flags.experimental_screencast {
1043                return Err(CliError::with_suggestion(
1044                    ErrorKind::Usage,
1045                    "screencast requires --experimental-screencast",
1046                    "Pass --experimental-screencast on the same invocation",
1047                ));
1048            }
1049            let action = step
1050                .get("action")
1051                .and_then(|v| v.as_str())
1052                .unwrap_or("start");
1053            match action {
1054                "start" => {
1055                    let path = step
1056                        .get("path")
1057                        .or_else(|| step.get("dir"))
1058                        .and_then(|v| v.as_str())
1059                        .map(Path::new);
1060                    session.screencast_start(path).await
1061                }
1062                "stop" => {
1063                    let path = step.get("path").and_then(|v| v.as_str()).map(Path::new);
1064                    session.screencast_stop(path).await
1065                }
1066                other => Err(CliError::new(
1067                    ErrorKind::Usage,
1068                    format!("unknown screencast action: {other}"),
1069                )),
1070            }
1071        }
1072        "heap" => {
1073            let action = step
1074                .get("action")
1075                .and_then(|v| v.as_str())
1076                .unwrap_or("take");
1077            let deep = !matches!(action, "take" | "summary" | "close");
1078            if deep && !flags.category_memory {
1079                return Err(CliError::with_suggestion(
1080                    ErrorKind::Usage,
1081                    "deep heap tools require --category-memory",
1082                    "Pass --category-memory (heap take/summary/close work without deep graph ops)",
1083                ));
1084            }
1085            match action {
1086                "take" => {
1087                    let path = step
1088                        .get("path")
1089                        .and_then(|v| v.as_str())
1090                        .ok_or_else(|| CliError::new(ErrorKind::Usage, "heap take requires path"))?;
1091                    session.heap_take(Path::new(path)).await
1092                }
1093                "summary" => {
1094                    let path = step
1095                        .get("path")
1096                        .and_then(|v| v.as_str())
1097                        .ok_or_else(|| CliError::new(ErrorKind::Usage, "heap path required"))?;
1098                    OneShotSession::heap_file_summary(Path::new(path))
1099                }
1100                "close" => {
1101                    let path = step
1102                        .get("path")
1103                        .and_then(|v| v.as_str())
1104                        .ok_or_else(|| CliError::new(ErrorKind::Usage, "heap path required"))?;
1105                    OneShotSession::heap_close(Path::new(path))
1106                }
1107                "details" => {
1108                    let path = step
1109                        .get("path")
1110                        .and_then(|v| v.as_str())
1111                        .ok_or_else(|| CliError::new(ErrorKind::Usage, "heap path required"))?;
1112                    OneShotSession::heap_details(Path::new(path))
1113                }
1114                "dup-strings" | "dup_strings" => {
1115                    let path = step
1116                        .get("path")
1117                        .and_then(|v| v.as_str())
1118                        .ok_or_else(|| CliError::new(ErrorKind::Usage, "heap path required"))?;
1119                    OneShotSession::heap_dup_strings(Path::new(path))
1120                }
1121                "compare" => {
1122                    let base = step
1123                        .get("base")
1124                        .and_then(|v| v.as_str())
1125                        .ok_or_else(|| CliError::new(ErrorKind::Usage, "heap compare needs base"))?;
1126                    let current = step.get("current").and_then(|v| v.as_str()).ok_or_else(|| {
1127                        CliError::new(ErrorKind::Usage, "heap compare needs current")
1128                    })?;
1129                    OneShotSession::heap_compare(Path::new(base), Path::new(current))
1130                }
1131                "class-nodes" => {
1132                    let path = step
1133                        .get("path")
1134                        .and_then(|v| v.as_str())
1135                        .ok_or_else(|| CliError::new(ErrorKind::Usage, "heap class-nodes needs path"))?;
1136                    let id = step
1137                        .get("id")
1138                        .and_then(|v| v.as_u64())
1139                        .ok_or_else(|| CliError::new(ErrorKind::Usage, "heap class-nodes needs id"))?;
1140                    OneShotSession::heap_class_nodes(Path::new(path), id)
1141                }
1142                "dominators" | "edges" | "retainers" | "paths" => {
1143                    let path = step
1144                        .get("path")
1145                        .and_then(|v| v.as_str())
1146                        .ok_or_else(|| CliError::new(ErrorKind::Usage, "heap node op needs path"))?;
1147                    let node = step
1148                        .get("node")
1149                        .and_then(|v| v.as_u64())
1150                        .ok_or_else(|| CliError::new(ErrorKind::Usage, "heap node op needs node"))?;
1151                    OneShotSession::heap_node_op(Path::new(path), node, action)
1152                }
1153                "object-details" | "object_details" => {
1154                    let path = step
1155                        .get("path")
1156                        .and_then(|v| v.as_str())
1157                        .ok_or_else(|| {
1158                            CliError::new(ErrorKind::Usage, "heap object-details needs path")
1159                        })?;
1160                    let node = step
1161                        .get("node")
1162                        .and_then(|v| v.as_u64())
1163                        .ok_or_else(|| {
1164                            CliError::new(ErrorKind::Usage, "heap object-details needs node")
1165                        })?;
1166                    OneShotSession::heap_object_details(Path::new(path), node)
1167                }
1168                other => Err(CliError::new(
1169                    ErrorKind::Usage,
1170                    format!("unknown heap action: {other}"),
1171                )),
1172            }
1173        }
1174        "devtools3p-list" | "devtools3p" if step.get("action").and_then(|v| v.as_str()) == Some("list") => {
1175            if !flags.category_third_party {
1176                return Err(CliError::with_suggestion(
1177                    ErrorKind::Usage,
1178                    "devtools3p tools require --category-third-party",
1179                    "Pass --category-third-party on the same invocation",
1180                ));
1181            }
1182            if let Some(url) = step.get("url").and_then(|v| v.as_str()) {
1183                if url != "about:blank" {
1184                    let _ = session
1185                        .goto(url, crate::robots::RobotsPolicy::Ignore)
1186                        .await?;
1187                }
1188            }
1189            session.devtools3p_list().await
1190        }
1191        "devtools3p-exec" | "devtools3p" if step.get("action").and_then(|v| v.as_str()) == Some("exec") => {
1192            if !flags.category_third_party {
1193                return Err(CliError::with_suggestion(
1194                    ErrorKind::Usage,
1195                    "devtools3p tools require --category-third-party",
1196                    "Pass --category-third-party on the same invocation",
1197                ));
1198            }
1199            let name = step
1200                .get("name")
1201                .and_then(|v| v.as_str())
1202                .ok_or_else(|| CliError::new(ErrorKind::Usage, "devtools3p exec needs name"))?;
1203            let params = step.get("params").and_then(|v| v.as_str());
1204            if let Some(url) = step.get("url").and_then(|v| v.as_str()) {
1205                if url != "about:blank" {
1206                    let _ = session
1207                        .goto(url, crate::robots::RobotsPolicy::Ignore)
1208                        .await?;
1209                }
1210            }
1211            session.devtools3p_exec(name, params).await
1212        }
1213        "webmcp-list" | "webmcp" if step.get("action").and_then(|v| v.as_str()) == Some("list") => {
1214            if !flags.category_webmcp {
1215                return Err(CliError::with_suggestion(
1216                    ErrorKind::Usage,
1217                    "webmcp tools require --category-webmcp",
1218                    "Pass --category-webmcp on the same invocation",
1219                ));
1220            }
1221            if let Some(url) = step.get("url").and_then(|v| v.as_str()) {
1222                if url != "about:blank" {
1223                    let _ = session
1224                        .goto(url, crate::robots::RobotsPolicy::Ignore)
1225                        .await?;
1226                }
1227            }
1228            session.webmcp_list().await
1229        }
1230        "webmcp-exec" | "webmcp" if step.get("action").and_then(|v| v.as_str()) == Some("exec") => {
1231            if !flags.category_webmcp {
1232                return Err(CliError::with_suggestion(
1233                    ErrorKind::Usage,
1234                    "webmcp tools require --category-webmcp",
1235                    "Pass --category-webmcp on the same invocation",
1236                ));
1237            }
1238            let name = step
1239                .get("name")
1240                .and_then(|v| v.as_str())
1241                .ok_or_else(|| CliError::new(ErrorKind::Usage, "webmcp exec needs name"))?;
1242            let input = step.get("input").and_then(|v| v.as_str());
1243            if let Some(url) = step.get("url").and_then(|v| v.as_str()) {
1244                if url != "about:blank" {
1245                    let _ = session
1246                        .goto(url, crate::robots::RobotsPolicy::Ignore)
1247                        .await?;
1248                }
1249            }
1250            session.webmcp_exec(name, input).await
1251        }
1252        "scrape" => {
1253            let url = step
1254                .get("url")
1255                .and_then(|v| v.as_str())
1256                .ok_or_else(|| CliError::new(ErrorKind::Usage, "scrape requires url"))?;
1257            // Prefer browser engine inside `run` (session already live).
1258            session.scrape(url, robots).await
1259        }
1260        "lighthouse" => {
1261            let url = step
1262                .get("url")
1263                .and_then(|v| v.as_str())
1264                .ok_or_else(|| CliError::new(ErrorKind::Usage, "lighthouse requires url"))?;
1265            let out_dir = step
1266                .get("out_dir")
1267                .or_else(|| step.get("outDir"))
1268                .and_then(|v| v.as_str())
1269                .map(Path::new);
1270            let device = step
1271                .get("device")
1272                .and_then(|v| v.as_str())
1273                .unwrap_or("desktop");
1274            let mode = step
1275                .get("mode")
1276                .and_then(|v| v.as_str())
1277                .unwrap_or("navigation");
1278            let lighthouse_path = step
1279                .get("lighthouse_path")
1280                .or_else(|| step.get("lighthousePath"))
1281                .and_then(|v| v.as_str())
1282                .map(Path::new);
1283            // External binary; run off the browser session but same process.
1284            super::lighthouse_to_value(url, out_dir, device, mode, lighthouse_path)
1285        }
1286        "extension" => {
1287            if !flags.category_extensions {
1288                return Err(CliError::with_suggestion(
1289                    ErrorKind::Usage,
1290                    "extension tools require --category-extensions",
1291                    "Pass --category-extensions on the same invocation",
1292                ));
1293            }
1294            let action = step
1295                .get("action")
1296                .and_then(|v| v.as_str())
1297                .unwrap_or("list");
1298            match action {
1299                "list" => session.extension_list().await,
1300                "reload" => {
1301                    let id = step
1302                        .get("id")
1303                        .and_then(|v| v.as_str())
1304                        .ok_or_else(|| {
1305                            CliError::new(ErrorKind::Usage, "extension reload requires id")
1306                        })?;
1307                    session.extension_reload(id).await
1308                }
1309                "trigger" => {
1310                    let id = step
1311                        .get("id")
1312                        .and_then(|v| v.as_str())
1313                        .ok_or_else(|| {
1314                            CliError::new(ErrorKind::Usage, "extension trigger requires id")
1315                        })?;
1316                    session.extension_trigger(id).await
1317                }
1318                other => Err(CliError::with_suggestion(
1319                    ErrorKind::Usage,
1320                    format!("unsupported extension action in run: {other}"),
1321                    "Use list|reload|trigger in run; install/uninstall are top-level one-shot launches",
1322                )),
1323            }
1324        }
1325        "" => Err(CliError::new(
1326            ErrorKind::Usage,
1327            "step missing cmd/action field",
1328        )),
1329        other => Err(CliError::with_suggestion(
1330            ErrorKind::Usage,
1331            format!("unknown script cmd: {other}"),
1332            "Supported: goto wait hover drag fill-form upload back forward reload view press write keys type click-at eval grab extract text scroll cookie attr assert console net page dialog scrape emulate resize perf lighthouse screencast heap extension devtools3p-list devtools3p-exec webmcp-list webmcp-exec",
1333        )),
1334    }
1335}
1336
1337/// Map flat `exec` argv into a single NDJSON step object for the shared dispatcher.
1338pub fn argv_to_step(args: &[String]) -> Result<Value, CliError> {
1339    if args.is_empty() {
1340        return Err(CliError::new(ErrorKind::Usage, "exec argv empty"));
1341    }
1342    let cmd = args[0].as_str();
1343    let mut step = json!({ "cmd": cmd });
1344    let obj = step.as_object_mut().ok_or_else(|| {
1345        CliError::new(ErrorKind::Software, "exec step object construction failed")
1346    })?;
1347    let mut i = 1;
1348    while i < args.len() {
1349        let a = args[i].as_str();
1350        if let Some(key) = a.strip_prefix("--") {
1351            let key = key.replace('-', "_");
1352            if i + 1 < args.len() && !args[i + 1].starts_with("--") {
1353                let val = &args[i + 1];
1354                if val == "true" || val == "false" {
1355                    obj.insert(key, json!(val == "true"));
1356                } else if let Ok(n) = val.parse::<u64>() {
1357                    obj.insert(key, json!(n));
1358                } else if let Ok(n) = val.parse::<f64>() {
1359                    obj.insert(key, json!(n));
1360                } else {
1361                    obj.insert(key, json!(val));
1362                }
1363                i += 2;
1364            } else {
1365                obj.insert(key, json!(true));
1366                i += 1;
1367            }
1368        } else {
1369            // positional fallbacks by cmd
1370            match cmd {
1371                "goto" if !obj.contains_key("url") => {
1372                    obj.insert("url".into(), json!(a));
1373                }
1374                "press" | "write" | "hover" | "type" | "extract" | "attr" | "upload"
1375                    if !obj.contains_key("target") =>
1376                {
1377                    obj.insert("target".into(), json!(a));
1378                }
1379                "write" | "type" if obj.contains_key("target") && !obj.contains_key("value") => {
1380                    obj.insert("value".into(), json!(a));
1381                    obj.insert("text".into(), json!(a));
1382                }
1383                "keys" if !obj.contains_key("key") => {
1384                    obj.insert("key".into(), json!(a));
1385                }
1386                "eval" if !obj.contains_key("expression") => {
1387                    obj.insert("expression".into(), json!(a));
1388                }
1389                "wait" if !obj.contains_key("ms") => {
1390                    if let Ok(n) = a.parse::<u64>() {
1391                        obj.insert("ms".into(), json!(n));
1392                    }
1393                }
1394                "view" if a == "verbose" => {
1395                    obj.insert("verbose".into(), json!(true));
1396                }
1397                "net" | "page" | "console" | "dialog" | "perf" | "heap" | "extension"
1398                | "devtools3p" | "webmcp" | "screencast"
1399                    if !obj.contains_key("action") =>
1400                {
1401                    obj.insert("action".into(), json!(a));
1402                }
1403                "net"
1404                    if obj.get("action").and_then(|v| v.as_str()) == Some("get")
1405                        && !obj.contains_key("id") =>
1406                {
1407                    obj.insert("id".into(), json!(a));
1408                }
1409                _ => {
1410                    return Err(CliError::with_suggestion(
1411                        ErrorKind::Usage,
1412                        format!("unrecognized exec argument: {a}"),
1413                        "Use --flags or run --script NDJSON for complex steps",
1414                    ));
1415                }
1416            }
1417            i += 1;
1418        }
1419    }
1420    Ok(step)
1421}
1422
1423/// Run a single step object in one browser process (exec parity with run).
1424pub async fn run_one_step(
1425    life: &Lifecycle,
1426    step: Value,
1427    robots: RobotsPolicy,
1428    capture: CaptureOpts,
1429    flags: RunFlags,
1430) -> Result<Value, CliError> {
1431    let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1432    if let Ok(mut ledger) = life.ledger.lock() {
1433        ledger.chrome_launched = true;
1434        ledger.chrome_pid = session.chrome_pid();
1435    }
1436    let cmd = step
1437        .get("cmd")
1438        .or_else(|| step.get("action"))
1439        .and_then(|v| v.as_str())
1440        .unwrap_or("")
1441        .to_string();
1442    let step_res = execute_step(&mut session, &cmd, &step, robots, flags).await;
1443    let close = session.shutdown().await;
1444    if let Ok(mut ledger) = life.ledger.lock() {
1445        ledger.chrome_launched = false;
1446        ledger.chrome_pid = None;
1447    }
1448    close?;
1449    step_res.map(|data| {
1450        json!({
1451            "cmd": cmd,
1452            "ok": true,
1453            "data": data,
1454        })
1455    })
1456}
1457
1458async fn execute_assert(session: &mut OneShotSession, step: &Value) -> Result<Value, CliError> {
1459    // Forms:
1460    // {"cmd":"assert","kind":"url","value":"...","contains":true}
1461    // {"cmd":"assert","kind":"url","url_contains":"..."}
1462    // {"cmd":"assert","kind":"text","value":"...","ref":"@e1"}
1463    // {"cmd":"assert","kind":"console","level":"error","max":0}
1464    // {"cmd":"assert","url":"..."} / {"cmd":"assert","text":"..."}
1465    // {"cmd":"assert","url_contains":"..."} / {"cmd":"assert","text_contains":"..."}
1466    if let Some(kind) = step.get("kind").and_then(|v| v.as_str()) {
1467        match kind {
1468            "url" => {
1469                let value = step
1470                    .get("value")
1471                    .or_else(|| step.get("url_contains"))
1472                    .or_else(|| step.get("url"))
1473                    .and_then(|v| v.as_str())
1474                    .ok_or_else(|| {
1475                        CliError::with_suggestion(
1476                            ErrorKind::Usage,
1477                            "assert url requires value",
1478                            "Use {\"cmd\":\"assert\",\"kind\":\"url\",\"value\":\"example.com\"} or url_contains",
1479                        )
1480                    })?;
1481                let contains = step
1482                    .get("contains")
1483                    .and_then(|v| v.as_bool())
1484                    .unwrap_or(true);
1485                return session.assert_url(value, contains).await;
1486            }
1487            "text" => {
1488                let value = step
1489                    .get("value")
1490                    .or_else(|| step.get("text_contains"))
1491                    .or_else(|| step.get("text"))
1492                    .and_then(|v| v.as_str())
1493                    .ok_or_else(|| {
1494                        CliError::with_suggestion(
1495                            ErrorKind::Usage,
1496                            "assert text requires value",
1497                            "Use {\"cmd\":\"assert\",\"kind\":\"text\",\"value\":\"Hello\"}",
1498                        )
1499                    })?;
1500                let target = step
1501                    .get("ref")
1502                    .or_else(|| step.get("target"))
1503                    .and_then(|v| v.as_str());
1504                return session.assert_text(value, target).await;
1505            }
1506            "console" => {
1507                let level = step
1508                    .get("level")
1509                    .and_then(|v| v.as_str())
1510                    .unwrap_or("error");
1511                let max = step.get("max").and_then(|v| v.as_u64()).unwrap_or(0);
1512                return session.assert_console(level, max).await;
1513            }
1514            other => {
1515                return Err(CliError::new(
1516                    ErrorKind::Usage,
1517                    format!("unknown assert kind: {other}"),
1518                ));
1519            }
1520        }
1521    }
1522    if let Some(url) = step
1523        .get("url_contains")
1524        .or_else(|| step.get("url"))
1525        .and_then(|v| v.as_str())
1526    {
1527        let contains = step
1528            .get("contains")
1529            .and_then(|v| v.as_bool())
1530            .unwrap_or(true);
1531        return session.assert_url(url, contains).await;
1532    }
1533    if let Some(text) = step
1534        .get("text_contains")
1535        .or_else(|| step.get("text"))
1536        .and_then(|v| v.as_str())
1537    {
1538        let target = step
1539            .get("ref")
1540            .or_else(|| step.get("target"))
1541            .and_then(|v| v.as_str());
1542        return session.assert_text(text, target).await;
1543    }
1544    Err(CliError::with_suggestion(
1545        ErrorKind::Usage,
1546        "assert requires kind=url|text|console or url/text/url_contains fields",
1547        "Example: {\"cmd\":\"assert\",\"kind\":\"url\",\"value\":\"example.com\"}",
1548    ))
1549}
1550
1551#[cfg(test)]
1552mod tests {
1553    use super::*;
1554
1555    #[test]
1556    fn argv_to_step_goto_and_wait_flags() {
1557        let step = argv_to_step(&[
1558            "wait".into(),
1559            "--ms".into(),
1560            "250".into(),
1561            "--state".into(),
1562            "networkidle".into(),
1563            "--include-snapshot".into(),
1564        ])
1565        .unwrap();
1566        assert_eq!(step["cmd"], "wait");
1567        assert_eq!(step["ms"], 250);
1568        assert_eq!(step["state"], "networkidle");
1569        assert_eq!(step["include_snapshot"], true);
1570    }
1571
1572    #[test]
1573    fn argv_to_step_net_get_id() {
1574        let step = argv_to_step(&["net".into(), "get".into(), "req-abc".into()]).unwrap();
1575        assert_eq!(step["cmd"], "net");
1576        assert_eq!(step["action"], "get");
1577        assert_eq!(step["id"], "req-abc");
1578    }
1579
1580    #[test]
1581    fn argv_to_step_press_target() {
1582        let step = argv_to_step(&[
1583            "press".into(),
1584            "#btn".into(),
1585            "--dblclick".into(),
1586            "--include-snapshot".into(),
1587        ])
1588        .unwrap();
1589        assert_eq!(step["target"], "#btn");
1590        assert_eq!(step["dblclick"], true);
1591        assert_eq!(step["include_snapshot"], true);
1592    }
1593}