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