Skip to main content

browser_automation_cli/commands_prd/
run.rs

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