Skip to main content

ui_automata/
action.rs

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// ── ExtractAttribute ──────────────────────────────────────────────────────────
13
14/// Which text property to read from each matched element during an `Extract` action.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema, Default)]
16#[serde(rename_all = "snake_case")]
17pub enum ExtractAttribute {
18    /// The UIA Name property — the element's accessible label or caption.
19    Name,
20    /// The ValuePattern text, falling back to the Name property.
21    /// Use for edit fields and other value-bearing controls.
22    #[default]
23    Text,
24    /// Direct children's names joined by newlines, excluding the element's own name.
25    /// Useful for composite controls like list items or tooltips where the
26    /// meaningful text lives in child elements.
27    InnerText,
28}
29
30// ── Action ────────────────────────────────────────────────────────────────────
31
32#[derive(Debug, Clone, Deserialize, JsonSchema)]
33#[serde(tag = "type")]
34pub enum Action {
35    // ── Mouse ─────────────────────────────────────────────────────────────────
36    /// Left-click the centre of the element found by `selector` under `scope`.
37    Click {
38        scope: String,
39        selector: SelectorPath,
40    },
41
42    /// Double-click the centre of the element found by `selector` under `scope`.
43    DoubleClick {
44        scope: String,
45        selector: SelectorPath,
46    },
47
48    /// Move the mouse cursor to the centre of the element without clicking.
49    /// Useful for triggering hover menus or tooltips.
50    Hover {
51        scope: String,
52        selector: SelectorPath,
53    },
54
55    /// Scroll ancestor containers until the element is within their visible
56    /// viewport. Hovers each scrollable ancestor and sends wheel events,
57    /// stopping as soon as the element's bounding box is fully visible or
58    /// it stops moving (meaning the container doesn't scroll further).
59    ScrollIntoView {
60        scope: String,
61        selector: SelectorPath,
62    },
63
64    /// Click at a fractional position within the element's bounding box.
65    ClickAt {
66        scope: String,
67        selector: SelectorPath,
68        x_pct: f64,
69        y_pct: f64,
70        kind: ClickType,
71    },
72
73    // ── Keyboard ──────────────────────────────────────────────────────────────
74    /// Type `text` into the element found by `selector` under `scope`.
75    TypeText {
76        scope: String,
77        selector: SelectorPath,
78        text: String,
79    },
80
81    /// Send a key expression (e.g. `"{ENTER}"`, `"{TAB}"`) to the element.
82    PressKey {
83        scope: String,
84        selector: SelectorPath,
85        key: String,
86    },
87
88    // ── Focus / window ────────────────────────────────────────────────────────
89    /// Give keyboard focus to the element found by `selector` under `scope`.
90    Focus {
91        scope: String,
92        selector: SelectorPath,
93    },
94
95    /// Activate an element via UIA `IInvokePattern::Invoke()`.
96    ///
97    /// Unlike `Click`, `Invoke` does not require a valid bounding rect, so it
98    /// works on elements that are scrolled out of view (bounds `(0,0,1,1)`).
99    /// Falls back to `Click` when the element does not support `InvokePattern`.
100    /// Prefer this over `Click` + `ScrollIntoView` for items in WinUI/UWP
101    /// scrollable lists where mouse-wheel scrolling causes elastic snap-back.
102    Invoke {
103        scope: String,
104        selector: SelectorPath,
105    },
106    /// Bring the window for `scope` to the foreground and restore it if minimized.
107    ActivateWindow { scope: String },
108    /// Minimize the window for `scope`.
109    MinimizeWindow { scope: String },
110    /// Close the window for `scope` via its close button (sends WM_CLOSE).
111    CloseWindow { scope: String },
112
113    // ── Value ─────────────────────────────────────────────────────────────────
114    /// Set the value of an edit / combo-box directly via IValuePattern.
115    SetValue {
116        scope: String,
117        selector: SelectorPath,
118        value: String,
119    },
120
121    // ── Dialog helpers ────────────────────────────────────────────────────────
122    /// Find the first dialog child of `scope` and close it.
123    DismissDialog { scope: String },
124
125    /// Click a button named `name` inside the current foreground window.
126    ClickForegroundButton { name: String },
127
128    /// Click any element named `name` in the foreground window tree.
129    ClickForeground { name: String },
130
131    /// Do nothing. Used as a placeholder action when a step only waits.
132    NoOp,
133
134    /// Pause for the given duration before the expect condition is evaluated.
135    /// Useful after `Hover` to wait for a tooltip to appear before the next step.
136    Sleep {
137        #[serde(with = "crate::duration::serde")]
138        #[schemars(schema_with = "crate::schema::duration_schema")]
139        duration: Duration,
140    },
141
142    /// Write all values stored under `key` in the workflow output buffer to a
143    /// file. Each value is written as one CSV-quoted line. The file is created
144    /// or truncated. `path` supports `{output.*}` substitution.
145    WriteOutput { key: String, path: String },
146
147    /// Read text from one or more elements and store the values in the workflow
148    /// output buffer under `key`. The value is accessible via `{output.<key>}`
149    /// substitution in subsequent steps.
150    ///
151    /// Extracts a text value from a matched element and stores it under `key`.
152    /// Whether the value is propagated to the parent workflow is controlled by
153    /// the workflow's `outputs` declaration (set at load time, not in YAML).
154    Extract {
155        /// Output key. Accessible as `{output.<key>}` in later steps.
156        key: String,
157        /// Anchor name that provides the search root.
158        scope: String,
159        /// Selector path. Matches the first element unless `multiple` is true.
160        selector: SelectorPath,
161        /// Which text property to read from each matched element.
162        #[serde(default)]
163        attribute: ExtractAttribute,
164        /// If true, extract all matching elements. If false (default), extract only the first match.
165        #[serde(default)]
166        multiple: bool,
167        /// If true, store in local scope only — not propagated to parent workflow.
168        /// Set automatically from the workflow's `outputs` list; not read from YAML.
169        #[serde(skip_deserializing, default)]
170        local: bool,
171    },
172
173    /// Evaluate a simple expression and store the result under `key`.
174    ///
175    /// The expression language supports arithmetic (`+`, `-`, `*`, `/`, `%`),
176    /// comparison operators (`==`, `<`, `<=`, `>`, `>=`), logical operators
177    /// (`&&`, `||`, both requiring `Bool` operands), parenthesised grouping,
178    /// single-quoted string literals, numeric literals, variable references
179    /// (`local.key` / bare identifier for locals, `param.key` for immutable
180    /// workflow params, `output.key` for the output buffer), and built-in
181    /// functions (`split_lines`, `round`, `floor`, `ceil`, `min`, `max`,
182    /// `trim`, `len`).
183    ///
184    /// The result is always stored as a **local variable** (overwrite semantics).
185    /// Bare identifiers resolve from locals first, falling back to the output buffer.
186    Eval {
187        /// Key under which the result is stored in local variables.
188        key: String,
189        /// Expression to evaluate.
190        expr: String,
191        /// If set, also appends the result to the output buffer under this key.
192        #[serde(default)]
193        output: Option<String>,
194    },
195
196    /// Spawn an external process and wait for it to exit.
197    /// Fails if the exit code is non-zero.
198    /// `{output.*}` tokens in `command` and `args` are substituted before execution.
199    Exec {
200        /// Executable path or command name (resolved via PATH).
201        command: String,
202        /// Arguments to pass to the process.
203        #[serde(default)]
204        args: Vec<String>,
205        /// If set, stdout is captured and each line stored in the output buffer under this key.
206        #[serde(default)]
207        key: Option<String>,
208    },
209
210    /// Move a file from `source` to `destination`.
211    /// Fails if the destination already exists.
212    /// Creates the destination directory if it does not exist.
213    /// `{output.*}` tokens in both fields are substituted before execution.
214    MoveFile { source: String, destination: String },
215
216    /// Navigate the browser tab anchored to `scope` to `url`.
217    /// Polls `document.readyState` until `"complete"` before reporting success.
218    /// `scope` must name a `Tab` anchor.
219    BrowserNavigate { scope: String, url: String },
220
221    /// Evaluate a JavaScript expression in the browser tab anchored to `scope`.
222    /// Stores the string result in the output buffer under `key`.
223    /// `scope` must name a `Tab` anchor.
224    BrowserEval {
225        scope: String,
226        expr: String,
227        /// Output key to store the result. If omitted the result is discarded.
228        key: Option<String>,
229    },
230}
231
232impl Action {
233    /// Short human-readable description for trace output.
234    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                        // Local extracts overwrite; only the last value is kept.
463                        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                // Poll until document.readyState === 'complete'.
558                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                // Press Escape on the browser window to dismiss the address bar and
574                // return UIA focus to the page content. Edge steals focus into the
575                // address bar during navigation; without this, RootWebArea children
576                // are hidden from UIA.
577                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    /// Return a clone with all `{output.<key>}` tokens substituted in string fields.
607    /// Checks the output buffer first (first value per key), then local vars.
608    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    /// Set `local` on `Extract` actions based on the workflow's `outputs` declaration.
668    /// Keys listed in `outputs` are returned to the parent workflow; all others are local.
669    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
678// ── Helpers ───────────────────────────────────────────────────────────────────
679
680/// Replace all `{output.<key>}` tokens in `s`.
681/// Checks the output buffer first (first value per key), then local vars.
682/// Unknown keys expand to an empty string.
683pub 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}