Skip to main content

browser_automation_cli/commands_prd/
run.rs

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