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