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