1use std::collections::HashMap;
2use std::time::Duration;
3
4use schemars::JsonSchema;
5use serde::Deserialize;
6
7use crate::{
8 AutomataError, Browser, ClickType, Desktop, Element, SelectorPath, ShadowDom, debug::dump_tree,
9 output::Output,
10};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema, Default)]
16#[serde(rename_all = "snake_case")]
17pub enum ExtractAttribute {
18 Name,
20 #[default]
23 Text,
24 InnerText,
28}
29
30#[derive(Debug, Clone, Deserialize, JsonSchema)]
33#[serde(tag = "type")]
34pub enum Action {
35 Click {
38 scope: String,
39 selector: SelectorPath,
40 },
41
42 DoubleClick {
44 scope: String,
45 selector: SelectorPath,
46 },
47
48 Hover {
51 scope: String,
52 selector: SelectorPath,
53 },
54
55 ScrollIntoView {
60 scope: String,
61 selector: SelectorPath,
62 },
63
64 ClickAt {
66 scope: String,
67 selector: SelectorPath,
68 x_pct: f64,
69 y_pct: f64,
70 kind: ClickType,
71 },
72
73 TypeText {
76 scope: String,
77 selector: SelectorPath,
78 text: String,
79 },
80
81 PressKey {
83 scope: String,
84 selector: SelectorPath,
85 key: String,
86 },
87
88 Focus {
91 scope: String,
92 selector: SelectorPath,
93 },
94
95 Invoke {
103 scope: String,
104 selector: SelectorPath,
105 },
106 ActivateWindow { scope: String },
108 MinimizeWindow { scope: String },
110 CloseWindow { scope: String },
112
113 SetValue {
116 scope: String,
117 selector: SelectorPath,
118 value: String,
119 },
120
121 DismissDialog { scope: String },
124
125 ClickForegroundButton { name: String },
127
128 ClickForeground { name: String },
130
131 NoOp,
133
134 Sleep {
137 #[serde(with = "crate::duration::serde")]
138 #[schemars(schema_with = "crate::schema::duration_schema")]
139 duration: Duration,
140 },
141
142 WriteOutput { key: String, path: String },
146
147 Extract {
155 key: String,
157 scope: String,
159 selector: SelectorPath,
161 #[serde(default)]
163 attribute: ExtractAttribute,
164 #[serde(default)]
166 multiple: bool,
167 #[serde(skip_deserializing, default)]
170 local: bool,
171 },
172
173 Eval {
187 key: String,
189 expr: String,
191 #[serde(default)]
193 output: Option<String>,
194 },
195
196 Exec {
200 command: String,
202 #[serde(default)]
204 args: Vec<String>,
205 #[serde(default)]
207 key: Option<String>,
208 },
209
210 MoveFile { source: String, destination: String },
215
216 BrowserNavigate { scope: String, url: String },
220
221 BrowserEval {
225 scope: String,
226 expr: String,
227 key: Option<String>,
229 },
230}
231
232impl Action {
233 pub fn describe(&self) -> String {
235 match self {
236 Action::Click { scope, selector } => format!("Click({scope}:{selector})"),
237 Action::DoubleClick { scope, selector } => format!("DoubleClick({scope}:{selector})"),
238 Action::Hover { scope, selector } => format!("Hover({scope}:{selector})"),
239 Action::ScrollIntoView { scope, selector } => {
240 format!("ScrollIntoView({scope}:{selector})")
241 }
242 Action::ClickAt {
243 scope,
244 selector,
245 x_pct,
246 y_pct,
247 ..
248 } => {
249 format!("ClickAt({scope}:{selector} @{x_pct:.2},{y_pct:.2})")
250 }
251 Action::TypeText {
252 scope,
253 selector,
254 text,
255 } => {
256 let preview: String = text.chars().take(20).collect();
257 format!("TypeText({scope}:{selector} {preview:?})")
258 }
259 Action::PressKey {
260 scope,
261 selector,
262 key,
263 } => {
264 format!("PressKey({scope}:{selector} {key:?})")
265 }
266 Action::Focus { scope, selector } => format!("Focus({scope}:{selector})"),
267 Action::Invoke { scope, selector } => format!("Invoke({scope}:{selector})"),
268 Action::ActivateWindow { scope } => format!("ActivateWindow({scope})"),
269 Action::MinimizeWindow { scope } => format!("MinimizeWindow({scope})"),
270 Action::CloseWindow { scope } => format!("CloseWindow({scope})"),
271 Action::SetValue {
272 scope,
273 selector,
274 value,
275 } => {
276 format!("SetValue({scope}:{selector} {value:?})")
277 }
278 Action::DismissDialog { scope } => format!("DismissDialog({scope})"),
279 Action::ClickForegroundButton { name } => format!("ClickForegroundButton({name:?})"),
280 Action::ClickForeground { name } => format!("ClickForeground({name:?})"),
281 Action::NoOp => "NoOp".into(),
282 Action::Sleep { duration } => format!("Sleep({}ms)", duration.as_millis()),
283 Action::WriteOutput { key, path } => format!("WriteOutput({key} → {path})"),
284 Action::Eval { key, expr, .. } => format!("Eval({key} = {expr:?})"),
285 Action::Extract {
286 key,
287 scope,
288 selector,
289 attribute,
290 multiple,
291 local,
292 } => format!(
293 "Extract({key}={scope}:{selector} attr={attribute:?} multi={multiple} local={local})"
294 ),
295 Action::Exec { command, args, key } => {
296 let k = key
297 .as_deref()
298 .map(|k| format!(" → {k}"))
299 .unwrap_or_default();
300 format!("Exec({command} {}){k}", args.join(" "))
301 }
302 Action::MoveFile {
303 source,
304 destination,
305 } => {
306 format!("MoveFile({source} → {destination})")
307 }
308 Action::BrowserNavigate { scope, url } => {
309 format!("BrowserNavigate({scope} → {url:?})")
310 }
311 Action::BrowserEval { scope, expr, key } => match key {
312 Some(k) => format!("BrowserEval({scope}:{k}={expr:?})"),
313 None => format!("BrowserEval({scope}:{expr:?})"),
314 },
315 }
316 }
317
318 pub fn execute<D: Desktop>(
319 &self,
320 dom: &mut ShadowDom<D>,
321 desktop: &D,
322 output: &mut Output,
323 locals: &mut HashMap<String, String>,
324 params: &HashMap<String, String>,
325 ) -> Result<(), AutomataError> {
326 match self {
327 Action::Click { scope, selector } => {
328 find_required(dom, desktop, scope, selector)?.click()
329 }
330
331 Action::DoubleClick { scope, selector } => {
332 find_required(dom, desktop, scope, selector)?.double_click()
333 }
334
335 Action::Hover { scope, selector } => {
336 find_required(dom, desktop, scope, selector)?.hover()
337 }
338
339 Action::ScrollIntoView { scope, selector } => {
340 find_required(dom, desktop, scope, selector)?.scroll_into_view()
341 }
342
343 Action::ClickAt {
344 scope,
345 selector,
346 x_pct,
347 y_pct,
348 kind,
349 } => find_required(dom, desktop, scope, selector)?.click_at(*x_pct, *y_pct, *kind),
350
351 Action::TypeText {
352 scope,
353 selector,
354 text,
355 } => find_required(dom, desktop, scope, selector)?.type_text(text),
356
357 Action::PressKey {
358 scope,
359 selector,
360 key,
361 } => find_required(dom, desktop, scope, selector)?.press_key(key),
362
363 Action::Focus { scope, selector } => {
364 find_required(dom, desktop, scope, selector)?.focus()
365 }
366
367 Action::Invoke { scope, selector } => {
368 find_required(dom, desktop, scope, selector)?.invoke()
369 }
370
371 Action::ActivateWindow { scope } => dom.get(scope, desktop)?.clone().activate_window(),
372
373 Action::MinimizeWindow { scope } => dom.get(scope, desktop)?.clone().minimize_window(),
374
375 Action::CloseWindow { scope } => dom.get(scope, desktop)?.clone().close(),
376
377 Action::SetValue {
378 scope,
379 selector,
380 value,
381 } => find_required(dom, desktop, scope, selector)?.set_value(value),
382
383 Action::DismissDialog { scope } => {
384 let root = dom.get(scope, desktop)?.clone();
385 let dialog = root
386 .children()
387 .unwrap_or_default()
388 .into_iter()
389 .find(|c| c.role() == "dialog")
390 .ok_or_else(|| {
391 AutomataError::Internal(format!(
392 "DismissDialog: no dialog child found under '{scope}'"
393 ))
394 })?;
395 if dialog.close().is_ok() {
396 return Ok(());
397 }
398 let button = dialog
399 .children()
400 .unwrap_or_default()
401 .into_iter()
402 .find(|c| c.role() == "button")
403 .ok_or_else(|| {
404 AutomataError::Internal(format!(
405 "DismissDialog: no button found in dialog under '{scope}'"
406 ))
407 })?;
408 button.click()
409 }
410
411 Action::ClickForegroundButton { name } => click_in_foreground(desktop, name, "button"),
412
413 Action::ClickForeground { name } => click_in_foreground(desktop, name, ""),
414
415 Action::NoOp => Ok(()),
416
417 Action::Sleep { duration } => {
418 std::thread::sleep(*duration);
419 Ok(())
420 }
421
422 Action::WriteOutput { key, path } => {
423 use std::io::Write;
424 let rows = output.get(key);
425 let mut f = std::fs::File::create(path)
426 .map_err(|e| AutomataError::Internal(format!("WriteOutput: {e}")))?;
427 for row in rows {
428 let escaped = row.replace('"', "\"\"");
429 writeln!(f, "\"{escaped}\"")
430 .map_err(|e| AutomataError::Internal(format!("WriteOutput: {e}")))?;
431 }
432 Ok(())
433 }
434
435 Action::Extract {
436 key,
437 scope,
438 selector,
439 attribute,
440 multiple,
441 local,
442 } => {
443 let elements = if *multiple {
444 let root = dom.get(scope, desktop)?.clone();
445 selector.find_all(&root)
446 } else {
447 dom.find_descendant(scope, selector, desktop)?
448 .into_iter()
449 .collect()
450 };
451 if elements.is_empty() {
452 log::warn!("extract[{key}]: no elements matched selector");
453 }
454 for el in elements {
455 let value = match attribute {
456 ExtractAttribute::Name => el.name().unwrap_or_default(),
457 ExtractAttribute::Text => el.text().unwrap_or_default(),
458 ExtractAttribute::InnerText => el.inner_text().unwrap_or_default(),
459 };
460 log::info!("extract[{key}]: {value:?}");
461 if *local {
462 locals.insert(key.clone(), value);
464 } else {
465 output.push(key, value);
466 }
467 }
468 Ok(())
469 }
470
471 Action::Exec { command, args, key } => {
472 use std::process::{Command, Stdio};
473 let mut cmd = Command::new(command);
474 cmd.args(args);
475 if key.is_some() {
476 cmd.stdout(Stdio::piped());
477 }
478 let child = cmd.spawn().map_err(|e| {
479 AutomataError::Internal(format!("Exec: failed to spawn '{command}': {e}"))
480 })?;
481 let result = child
482 .wait_with_output()
483 .map_err(|e| AutomataError::Internal(format!("Exec: wait failed: {e}")))?;
484 let exit_code = result.status.code().unwrap_or(-1);
485 locals.insert(
486 crate::condition::EXEC_EXIT_CODE_KEY.to_owned(),
487 exit_code.to_string(),
488 );
489 if !result.status.success() {
490 let stderr = String::from_utf8_lossy(&result.stderr);
491 return Err(AutomataError::Internal(format!(
492 "Exec: '{command}' exited with {}: {stderr}",
493 result.status
494 )));
495 }
496 if let Some(k) = key {
497 for line in String::from_utf8_lossy(&result.stdout).lines() {
498 output.push(k, line.to_string());
499 }
500 }
501 Ok(())
502 }
503
504 Action::MoveFile {
505 source,
506 destination,
507 } => {
508 let dest = std::path::Path::new(destination.as_str());
509 if dest.exists() {
510 return Err(AutomataError::Internal(format!(
511 "MoveFile: destination already exists: {destination}"
512 )));
513 }
514 if let Some(parent) = dest.parent() {
515 std::fs::create_dir_all(parent).map_err(|e| {
516 AutomataError::Internal(format!(
517 "MoveFile: failed to create destination directory: {e}"
518 ))
519 })?;
520 }
521 std::fs::rename(source, destination)
522 .map_err(|e| AutomataError::Internal(format!("MoveFile: {e}")))?;
523 log::info!("move_file: {source} → {destination}");
524 Ok(())
525 }
526
527 Action::Eval {
528 key,
529 expr,
530 output: out_key,
531 } => {
532 let value = crate::expression::eval_expr(expr, locals, params, output)
533 .map_err(|e| AutomataError::Internal(format!("Eval({key}): {e}")))?
534 .into_string();
535 locals.insert(key.clone(), value.clone());
536 if let Some(ok) = out_key {
537 log::info!("eval[{key}] = {:?} (output[{ok}])", value);
538 output.push(ok, value);
539 } else {
540 log::info!("eval[{key}] = {:?}", value);
541 }
542 Ok(())
543 }
544
545 Action::BrowserNavigate { scope, url } => {
546 let tab = dom
547 .tab_handle(scope)
548 .ok_or_else(|| {
549 AutomataError::Internal(format!("'{scope}' is not a mounted Tab anchor"))
550 })?
551 .clone();
552 let tab_id = tab.tab_id.clone();
553 let browser = desktop.browser();
554 browser
555 .navigate(&tab_id, url)
556 .map_err(|e| AutomataError::Internal(format!("navigate: {e}")))?;
557 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
559 loop {
560 let ready = browser
561 .eval(&tab_id, "document.readyState")
562 .unwrap_or_default();
563 if ready == "complete" {
564 break;
565 }
566 if std::time::Instant::now() >= deadline {
567 return Err(AutomataError::Internal(format!(
568 "BrowserNavigate({scope}): timed out waiting for readyState=complete"
569 )));
570 }
571 std::thread::sleep(std::time::Duration::from_millis(200));
572 }
573 if let Ok(elem) = dom.get(&tab.parent_browser, desktop) {
578 if let Err(e) = elem.press_key("{ESCAPE}") {
579 log::warn!("BrowserNavigate({scope}): press Escape failed: {e}");
580 }
581 }
582 Ok(())
583 }
584
585 Action::BrowserEval { scope, expr, key } => {
586 let tab_id = dom
587 .tab_handle(scope)
588 .ok_or_else(|| {
589 AutomataError::Internal(format!("'{scope}' is not a mounted Tab anchor"))
590 })?
591 .tab_id
592 .clone();
593 let result = desktop
594 .browser()
595 .eval(&tab_id, expr)
596 .map_err(|e| AutomataError::Internal(format!("browser eval: {e}")))?;
597 if let Some(k) = key {
598 log::info!("browser_eval[{k}] = {result:?}");
599 output.push(k, result);
600 }
601 Ok(())
602 }
603 }
604 }
605
606 pub fn apply_output(&self, locals: &HashMap<String, String>, output: &Output) -> Self {
609 let sub = |s: &str| sub_output(s, locals, output);
610 match self {
611 Action::TypeText {
612 scope,
613 selector,
614 text,
615 } => Action::TypeText {
616 scope: scope.clone(),
617 selector: selector.clone(),
618 text: sub(text),
619 },
620 Action::SetValue {
621 scope,
622 selector,
623 value,
624 } => Action::SetValue {
625 scope: scope.clone(),
626 selector: selector.clone(),
627 value: sub(value),
628 },
629 Action::PressKey {
630 scope,
631 selector,
632 key,
633 } => Action::PressKey {
634 scope: scope.clone(),
635 selector: selector.clone(),
636 key: sub(key),
637 },
638 Action::WriteOutput { key, path } => Action::WriteOutput {
639 key: key.clone(),
640 path: sub(path),
641 },
642 Action::Exec { command, args, key } => Action::Exec {
643 command: sub(command),
644 args: args.iter().map(|a| sub(a)).collect(),
645 key: key.clone(),
646 },
647 Action::MoveFile {
648 source,
649 destination,
650 } => Action::MoveFile {
651 source: sub(source),
652 destination: sub(destination),
653 },
654 Action::BrowserNavigate { scope, url } => Action::BrowserNavigate {
655 scope: scope.clone(),
656 url: sub(url),
657 },
658 Action::BrowserEval { scope, expr, key } => Action::BrowserEval {
659 scope: scope.clone(),
660 expr: sub(expr),
661 key: key.as_deref().map(sub),
662 },
663 _ => self.clone(),
664 }
665 }
666
667 pub(crate) fn apply_outputs(&mut self, outputs: &std::collections::HashSet<String>) {
670 let (key, local) = match self {
671 Action::Extract { key, local, .. } => (key as &str, local),
672 _ => return,
673 };
674 *local = !outputs.contains(key);
675 }
676}
677
678pub fn sub_output(s: &str, locals: &HashMap<String, String>, output: &Output) -> String {
684 let mut out = s.to_owned();
685 for (k, values) in output.as_map() {
686 if let Some(v) = values.first() {
687 out = out.replace(&format!("{{output.{k}}}"), v);
688 }
689 }
690 for (k, v) in locals {
691 out = out.replace(&format!("{{output.{k}}}"), v);
692 }
693 out
694}
695
696fn find_required<D: Desktop>(
697 dom: &mut ShadowDom<D>,
698 desktop: &D,
699 scope: &str,
700 selector: &SelectorPath,
701) -> Result<D::Elem, AutomataError> {
702 match dom.find_descendant(scope, selector, desktop)? {
703 Some(el) => Ok(el),
704 None => {
705 let tree = dom
706 .get(scope, desktop)
707 .ok()
708 .map(|root| dump_tree(root, 3))
709 .unwrap_or_default();
710 Err(AutomataError::Internal(format!(
711 "element not found: selector '{selector}' under scope '{scope}'\n{tree}"
712 )))
713 }
714 }
715}
716
717fn click_in_foreground<D: Desktop>(
718 desktop: &D,
719 name: &str,
720 role: &str,
721) -> Result<(), AutomataError> {
722 let fg = desktop
723 .foreground_window()
724 .ok_or_else(|| AutomataError::Internal("no foreground window".into()))?;
725
726 let matches = |el: &D::Elem| -> bool {
727 let name_ok = el.name().as_deref() == Some(name);
728 let role_ok = role.is_empty() || el.role() == role;
729 name_ok && role_ok
730 };
731
732 let children = fg.children().unwrap_or_default();
733 if let Some(el) = children.iter().find(|c| matches(c)) {
734 return el.click();
735 }
736
737 for child in &children {
738 if let Ok(grandchildren) = child.children() {
739 if let Some(el) = grandchildren.iter().find(|c| matches(c)) {
740 return el.click();
741 }
742 }
743 }
744
745 let all_windows = desktop.application_windows().unwrap_or_default();
746 let all_trees: String = all_windows
747 .iter()
748 .map(|w| {
749 let title = w.name().unwrap_or_else(|| "<unnamed>".to_string());
750 format!("=== {title} ===\n{}", dump_tree(w, 3))
751 })
752 .collect::<Vec<_>>()
753 .join("\n");
754 Err(AutomataError::Internal(format!(
755 "element '{name}' not found in foreground window\nAll windows:\n{all_trees}"
756 )))
757}