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