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 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 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 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 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
1129pub 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 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
1213pub 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 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}