Skip to main content

ui_automata/
condition.rs

1use schemars::JsonSchema;
2use serde::Deserialize;
3
4use std::collections::HashMap;
5
6use crate::{
7    AutomataError, Browser, Desktop, Element, SelectorPath, ShadowDom, action::sub_output,
8    output::Output,
9};
10
11// ── Text / title match helpers ────────────────────────────────────────────────
12
13/// Matches element text. Exactly one field should be set.
14#[derive(Debug, Clone, Deserialize, JsonSchema)]
15pub struct TextMatch {
16    pub exact: Option<String>,
17    pub contains: Option<String>,
18    pub starts_with: Option<String>,
19    /// Fancy-regex pattern (supports backreferences, lookahead, etc.).
20    pub regex: Option<String>,
21    #[serde(default)]
22    pub non_empty: bool,
23}
24
25impl TextMatch {
26    pub fn exact(s: impl Into<String>) -> Self {
27        Self {
28            exact: Some(s.into()),
29            contains: None,
30            starts_with: None,
31            regex: None,
32            non_empty: false,
33        }
34    }
35    pub fn contains(s: impl Into<String>) -> Self {
36        Self {
37            exact: None,
38            contains: Some(s.into()),
39            starts_with: None,
40            regex: None,
41            non_empty: false,
42        }
43    }
44    pub fn non_empty() -> Self {
45        Self {
46            exact: None,
47            contains: None,
48            starts_with: None,
49            regex: None,
50            non_empty: true,
51        }
52    }
53
54    pub fn test(&self, s: &str) -> bool {
55        if let Some(v) = &self.exact {
56            return s == v;
57        }
58        if let Some(v) = &self.contains {
59            return s.contains(v.as_str());
60        }
61        if let Some(v) = &self.starts_with {
62            return s.starts_with(v.as_str());
63        }
64        if let Some(v) = &self.regex {
65            return fancy_regex::Regex::new(v)
66                .ok()
67                .and_then(|re| re.is_match(s).ok())
68                .unwrap_or(false);
69        }
70        if self.non_empty {
71            return !s.is_empty();
72        }
73        false
74    }
75}
76
77/// Matches a window title. Exactly one field should be set.
78#[derive(Debug, Clone, Deserialize, JsonSchema)]
79pub struct TitleMatch {
80    pub exact: Option<String>,
81    pub contains: Option<String>,
82    pub starts_with: Option<String>,
83}
84
85impl TitleMatch {
86    pub fn exact(s: impl Into<String>) -> Self {
87        Self {
88            exact: Some(s.into()),
89            contains: None,
90            starts_with: None,
91        }
92    }
93    pub fn contains(s: impl Into<String>) -> Self {
94        Self {
95            exact: None,
96            contains: Some(s.into()),
97            starts_with: None,
98        }
99    }
100    pub fn starts_with(s: impl Into<String>) -> Self {
101        Self {
102            exact: None,
103            contains: None,
104            starts_with: Some(s.into()),
105        }
106    }
107
108    pub fn test(&self, s: &str) -> bool {
109        if let Some(v) = &self.exact {
110            return s == v;
111        }
112        if let Some(v) = &self.contains {
113            return s.contains(v.as_str());
114        }
115        if let Some(v) = &self.starts_with {
116            return s.starts_with(v.as_str());
117        }
118        false
119    }
120}
121
122/// Key written to locals by every `Exec` action — holds the integer exit code as a string.
123/// Read by the [`Condition::ExecSucceeded`] condition.
124pub const EXEC_EXIT_CODE_KEY: &str = "__exec_exit_code__";
125
126// ── WindowState ───────────────────────────────────────────────────────────────
127
128/// Observable state of a window anchor.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
130#[serde(rename_all = "snake_case")]
131pub enum WindowState {
132    /// The window is currently the OS foreground window (has keyboard focus).
133    Active,
134    /// The window is visible on screen — not minimized or hidden.
135    Visible,
136}
137
138// ── Condition ─────────────────────────────────────────────────────────────────
139
140/// Custom `Deserialize` via `TryFrom<serde_yaml::Value>` to work around the
141/// serde limitation that `#[serde(tag)]` + `#[serde(flatten)]` don't compose
142/// in serde_yaml. We hand-roll the mapping from a YAML map to enum variants.
143#[derive(Debug, Clone, Deserialize)]
144#[serde(try_from = "serde_yaml::Value")]
145pub enum Condition {
146    ElementFound {
147        scope: String,
148        selector: SelectorPath,
149    },
150    ElementEnabled {
151        scope: String,
152        selector: SelectorPath,
153    },
154    ElementVisible {
155        scope: String,
156        selector: SelectorPath,
157    },
158    ElementHasText {
159        scope: String,
160        selector: SelectorPath,
161        pattern: TextMatch,
162    },
163    ElementHasChildren {
164        scope: String,
165        selector: SelectorPath,
166    },
167
168    /// Any application window matches the given attribute filters.
169    /// YAML: `type: WindowWithAttribute` + at least one of:
170    ///   - flat title fields: `exact`, `contains`, `starts_with`
171    ///   - `automation_id: <string>` (exact match on UIA AutomationId / AXIdentifier)
172    ///   - `pid: <u32>` (exact process ID match)
173    /// Optional `process: <name>` restricts to a specific process (case-insensitive, no .exe).
174    WindowWithAttribute {
175        title: Option<TitleMatch>,
176        automation_id: Option<String>,
177        pid: Option<u32>,
178        process: Option<String>,
179    },
180
181    /// True when any application window belongs to a process whose name
182    /// (without `.exe`) matches `process` (case-insensitive).
183    /// YAML: `type: ProcessRunning` + `process: <name>`.
184    ProcessRunning {
185        process: String,
186    },
187    /// True when the window anchored to `anchor` is no longer open.
188    /// If the anchor was mounted with a PID (`AnchorDef::session_pid`), the
189    /// check is done at the process level (no window for that PID exists).
190    /// Otherwise it falls back to attempting re-resolution of the anchor.
191    WindowClosed {
192        anchor: String,
193    },
194    /// True when the anchor's window is in the given state.
195    WindowWithState {
196        anchor: String,
197        state: WindowState,
198    },
199    DialogPresent {
200        scope: String,
201    },
202    DialogAbsent {
203        scope: String,
204    },
205
206    ForegroundIsDialog {
207        scope: String,
208        title: Option<TitleMatch>,
209    },
210
211    /// True when the file at `path` exists on disk.
212    /// `path` supports `{output.*}` substitution via `apply_output`.
213    FileExists {
214        path: String,
215    },
216
217    /// Always evaluates to true immediately. Use as `expect` on steps where
218    /// success is guaranteed by the action itself (e.g. `Capture`, `NoOp`).
219    Always,
220
221    /// True when the most recent `Exec` action exited with code 0.
222    /// Fails (step times out) if the process exited non-zero.
223    ExecSucceeded,
224
225    /// Evaluates a boolean expression against the current output, locals, and params.
226    /// The expression **must** return a `Bool` (use a comparison operator).
227    /// Example: `"count % 10 == 0"`, `"score >= param.threshold"`
228    EvalCondition {
229        expr: String,
230    },
231
232    /// True when the browser tab anchored to `scope` matches the given attribute filters.
233    /// YAML: `type: TabWithAttribute` + at least one of:
234    ///   - `title`: `TextMatch` against the tab's current title.
235    ///   - `url`: `TextMatch` against the tab's current URL.
236    /// `scope` must name a mounted `Tab` anchor.
237    TabWithAttribute {
238        scope: String,
239        title: Option<TextMatch>,
240        url: Option<TextMatch>,
241    },
242
243    /// True when the JS expression `expr` evaluates to a truthy value in the browser tab `scope`.
244    /// YAML: `type: TabWithState` + `scope` (Tab anchor) + `expr` (JS expression string).
245    /// Example: `expr: "document.readyState === 'complete'"`
246    TabWithState {
247        scope: String,
248        expr: String,
249    },
250
251    AllOf {
252        conditions: Vec<Condition>,
253    },
254    AnyOf {
255        conditions: Vec<Condition>,
256    },
257    Not {
258        condition: Box<Condition>,
259    },
260}
261
262// ── Custom TryFrom for serde_yaml::Value ──────────────────────────────────────
263
264impl TryFrom<serde_yaml::Value> for Condition {
265    type Error = String;
266
267    fn try_from(v: serde_yaml::Value) -> Result<Self, String> {
268        let map = v.as_mapping().ok_or("Condition must be a YAML mapping")?;
269
270        let type_str = map
271            .get("type")
272            .and_then(|v| v.as_str())
273            .ok_or("Condition missing string field 'type'")?;
274
275        let str_field = |key: &str| -> Option<String> {
276            map.get(key).and_then(|v| v.as_str()).map(String::from)
277        };
278        let req_str = |key: &str| -> Result<String, String> {
279            str_field(key).ok_or_else(|| format!("Condition '{type_str}' missing '{key}'"))
280        };
281        let req_selector = |key: &str| -> Result<SelectorPath, String> {
282            let s = req_str(key)?;
283            SelectorPath::parse(&s).map_err(|e| e.to_string())
284        };
285
286        match type_str {
287            "ElementFound" => Ok(Condition::ElementFound {
288                scope: req_str("scope")?,
289                selector: req_selector("selector")?,
290            }),
291            "ElementEnabled" => Ok(Condition::ElementEnabled {
292                scope: req_str("scope")?,
293                selector: req_selector("selector")?,
294            }),
295            "ElementVisible" => Ok(Condition::ElementVisible {
296                scope: req_str("scope")?,
297                selector: req_selector("selector")?,
298            }),
299            "ElementHasText" => {
300                let pattern_val = map
301                    .get("pattern")
302                    .ok_or("ElementHasText missing 'pattern'")?;
303                let pattern: TextMatch = serde_yaml::from_value(pattern_val.clone())
304                    .map_err(|e| format!("ElementHasText.pattern: {e}"))?;
305                Ok(Condition::ElementHasText {
306                    scope: req_str("scope")?,
307                    selector: req_selector("selector")?,
308                    pattern,
309                })
310            }
311            "ElementHasChildren" => Ok(Condition::ElementHasChildren {
312                scope: req_str("scope")?,
313                selector: req_selector("selector")?,
314            }),
315            "WindowWithAttribute" => {
316                let title: Option<TitleMatch> = map
317                    .get("title")
318                    .and_then(|v| serde_yaml::from_value(v.clone()).ok());
319                let automation_id = str_field("automation_id");
320                let pid = map.get("pid").and_then(|v| v.as_u64()).map(|v| v as u32);
321                if title.is_none() && automation_id.is_none() && pid.is_none() {
322                    return Err(
323                        "WindowWithAttribute requires at least one of: title, automation_id, pid"
324                            .into(),
325                    );
326                }
327                Ok(Condition::WindowWithAttribute {
328                    title,
329                    automation_id,
330                    pid,
331                    process: str_field("process"),
332                })
333            }
334            "ProcessRunning" => Ok(Condition::ProcessRunning {
335                process: req_str("process")?,
336            }),
337            "WindowClosed" => Ok(Condition::WindowClosed {
338                anchor: req_str("anchor")?,
339            }),
340            "WindowWithState" => {
341                let anchor = req_str("anchor")?;
342                let state_str = req_str("state")?;
343                let state = match state_str.as_str() {
344                    "active" => WindowState::Active,
345                    "visible" => WindowState::Visible,
346                    other => return Err(format!("unknown WindowState '{other}'")),
347                };
348                Ok(Condition::WindowWithState { anchor, state })
349            }
350            "DialogPresent" => Ok(Condition::DialogPresent {
351                scope: req_str("scope")?,
352            }),
353            "DialogAbsent" => Ok(Condition::DialogAbsent {
354                scope: req_str("scope")?,
355            }),
356            "ForegroundIsDialog" => {
357                let title = if let Some(t) = map.get("title") {
358                    Some(
359                        serde_yaml::from_value(t.clone())
360                            .map_err(|e| format!("ForegroundIsDialog.title: {e}"))?,
361                    )
362                } else {
363                    None
364                };
365                Ok(Condition::ForegroundIsDialog {
366                    scope: req_str("scope")?,
367                    title,
368                })
369            }
370            "FileExists" => Ok(Condition::FileExists {
371                path: req_str("path")?,
372            }),
373            "AllOf" => {
374                let conditions = parse_condition_list(map, "conditions", type_str)?;
375                Ok(Condition::AllOf { conditions })
376            }
377            "AnyOf" => {
378                let conditions = parse_condition_list(map, "conditions", type_str)?;
379                Ok(Condition::AnyOf { conditions })
380            }
381            "Not" => {
382                let inner_val = map
383                    .get("condition")
384                    .ok_or("Not missing 'condition'")?
385                    .clone();
386                let condition = Box::new(Condition::try_from(inner_val)?);
387                Ok(Condition::Not { condition })
388            }
389            "TabWithAttribute" => {
390                let title: Option<TextMatch> = map
391                    .get("title")
392                    .and_then(|v| serde_yaml::from_value(v.clone()).ok());
393                let url: Option<TextMatch> = map
394                    .get("url")
395                    .and_then(|v| serde_yaml::from_value(v.clone()).ok());
396                if title.is_none() && url.is_none() {
397                    return Err("TabWithAttribute requires at least one of: title, url".into());
398                }
399                Ok(Condition::TabWithAttribute {
400                    scope: req_str("scope")?,
401                    title,
402                    url,
403                })
404            }
405            "TabWithState" => Ok(Condition::TabWithState {
406                scope: req_str("scope")?,
407                expr: req_str("expr")?,
408            }),
409            "Always" => Ok(Condition::Always),
410            "ExecSucceeded" => Ok(Condition::ExecSucceeded),
411            "EvalCondition" => {
412                let expr = map
413                    .get("expr")
414                    .and_then(|v| v.as_str())
415                    .ok_or("EvalCondition missing 'expr'")?
416                    .to_string();
417                Ok(Condition::EvalCondition { expr })
418            }
419            other => Err(format!("unknown Condition type '{other}'")),
420        }
421    }
422}
423
424fn parse_condition_list(
425    map: &serde_yaml::Mapping,
426    key: &str,
427    type_str: &str,
428) -> Result<Vec<Condition>, String> {
429    let seq = map
430        .get(key)
431        .and_then(|v| v.as_sequence())
432        .ok_or_else(|| format!("{type_str} missing sequence field '{key}'"))?;
433    seq.iter().map(|v| Condition::try_from(v.clone())).collect()
434}
435
436// ── describe / scope_name / evaluate ─────────────────────────────────────────
437
438impl Condition {
439    /// Return a clone with all `{output.<key>}` tokens substituted in pattern strings.
440    pub fn apply_output(&self, locals: &HashMap<String, String>, output: &Output) -> Self {
441        let sub = |s: &str| sub_output(s, locals, output);
442        let sub_tm = |tm: &TextMatch| TextMatch {
443            exact: tm.exact.as_deref().map(|s| sub(s)),
444            contains: tm.contains.as_deref().map(|s| sub(s)),
445            starts_with: tm.starts_with.as_deref().map(|s| sub(s)),
446            regex: tm.regex.clone(),
447            non_empty: tm.non_empty,
448        };
449        match self {
450            Condition::ElementHasText {
451                scope,
452                selector,
453                pattern,
454            } => Condition::ElementHasText {
455                scope: scope.clone(),
456                selector: selector.clone(),
457                pattern: sub_tm(pattern),
458            },
459            Condition::AllOf { conditions } => Condition::AllOf {
460                conditions: conditions
461                    .iter()
462                    .map(|c| c.apply_output(locals, output))
463                    .collect(),
464            },
465            Condition::AnyOf { conditions } => Condition::AnyOf {
466                conditions: conditions
467                    .iter()
468                    .map(|c| c.apply_output(locals, output))
469                    .collect(),
470            },
471            Condition::FileExists { path } => Condition::FileExists { path: sub(path) },
472            Condition::Not { condition } => Condition::Not {
473                condition: Box::new(condition.apply_output(locals, output)),
474            },
475            Condition::TabWithAttribute { scope, title, url } => Condition::TabWithAttribute {
476                scope: scope.clone(),
477                title: title.as_ref().map(|t| sub_tm(t)),
478                url: url.as_ref().map(|u| sub_tm(u)),
479            },
480            Condition::TabWithState { scope, expr } => Condition::TabWithState {
481                scope: scope.clone(),
482                expr: sub(expr),
483            },
484            _ => self.clone(),
485        }
486    }
487
488    pub fn scope_name(&self) -> Option<&str> {
489        match self {
490            Condition::ElementFound { scope, .. }
491            | Condition::ElementEnabled { scope, .. }
492            | Condition::ElementVisible { scope, .. }
493            | Condition::ElementHasText { scope, .. }
494            | Condition::ElementHasChildren { scope, .. }
495            | Condition::DialogPresent { scope }
496            | Condition::DialogAbsent { scope }
497            | Condition::ForegroundIsDialog { scope, .. } => Some(scope),
498            _ => None,
499        }
500    }
501
502    pub fn describe(&self) -> String {
503        match self {
504            Condition::ElementFound { scope, selector } => {
505                format!("ElementFound({scope}:{selector})")
506            }
507            Condition::ElementEnabled { scope, selector } => {
508                format!("ElementEnabled({scope}:{selector})")
509            }
510            Condition::ElementVisible { scope, selector } => {
511                format!("ElementVisible({scope}:{selector})")
512            }
513            Condition::ElementHasText {
514                scope, selector, ..
515            } => {
516                format!("ElementHasText({scope}:{selector})")
517            }
518            Condition::ElementHasChildren { scope, selector } => {
519                format!("ElementHasChildren({scope}:{selector})")
520            }
521            Condition::WindowWithAttribute {
522                title,
523                automation_id,
524                pid,
525                process,
526            } => {
527                let mut parts = Vec::new();
528                if let Some(t) = title {
529                    parts.push(format!("{t:?}"));
530                }
531                if let Some(aid) = automation_id {
532                    parts.push(format!("automation_id={aid}"));
533                }
534                if let Some(p) = pid {
535                    parts.push(format!("pid={p}"));
536                }
537                if let Some(p) = process {
538                    parts.push(format!("process={p}"));
539                }
540                format!("WindowWithAttribute({})", parts.join(", "))
541            }
542            Condition::ProcessRunning { process } => format!("ProcessRunning({process})"),
543            Condition::WindowClosed { anchor } => format!("WindowClosed({anchor})"),
544            Condition::WindowWithState { anchor, state } => {
545                format!("WindowWithState({anchor}:{state:?})")
546            }
547            Condition::DialogPresent { scope } => format!("DialogPresent({scope})"),
548            Condition::DialogAbsent { scope } => format!("DialogAbsent({scope})"),
549            Condition::ForegroundIsDialog { scope, .. } => {
550                format!("ForegroundIsDialog({scope})")
551            }
552            Condition::Always => "Always".to_string(),
553            Condition::ExecSucceeded => "ExecSucceeded".to_string(),
554            Condition::AllOf { conditions } => format!(
555                "AllOf({})",
556                conditions
557                    .iter()
558                    .map(|c| c.describe())
559                    .collect::<Vec<_>>()
560                    .join(", ")
561            ),
562            Condition::AnyOf { conditions } => format!(
563                "AnyOf({})",
564                conditions
565                    .iter()
566                    .map(|c| c.describe())
567                    .collect::<Vec<_>>()
568                    .join(", ")
569            ),
570            Condition::FileExists { path } => format!("FileExists({path})"),
571            Condition::Not { condition } => format!("Not({})", condition.describe()),
572            Condition::EvalCondition { expr } => format!("EvalCondition({expr:?})"),
573            Condition::TabWithAttribute { scope, .. } => format!("TabWithAttribute({scope})"),
574            Condition::TabWithState { scope, expr } => {
575                format!("TabWithState({scope}: {expr:?})")
576            }
577        }
578    }
579
580    pub fn evaluate<D: Desktop>(
581        &self,
582        dom: &mut ShadowDom<D>,
583        desktop: &D,
584        locals: &std::collections::HashMap<String, String>,
585        params: &std::collections::HashMap<String, String>,
586        output: &crate::Output,
587    ) -> Result<bool, AutomataError> {
588        match self {
589            Condition::ElementFound { scope, selector } => {
590                Ok(find_in_scope(dom, desktop, scope, selector)?.is_some())
591            }
592            Condition::ElementEnabled { scope, selector } => {
593                Ok(find_in_scope(dom, desktop, scope, selector)?
594                    .and_then(|el| el.is_enabled().ok())
595                    .unwrap_or(false))
596            }
597            Condition::ElementVisible { scope, selector } => {
598                Ok(find_in_scope(dom, desktop, scope, selector)?
599                    .and_then(|el| el.is_visible().ok())
600                    .unwrap_or(false))
601            }
602            Condition::ElementHasText {
603                scope,
604                selector,
605                pattern,
606            } => Ok(find_in_scope(dom, desktop, scope, selector)?
607                .and_then(|el| el.text().ok())
608                .map(|t| pattern.test(&t))
609                .unwrap_or(false)),
610            Condition::ElementHasChildren { scope, selector } => {
611                Ok(find_in_scope(dom, desktop, scope, selector)?
612                    .and_then(|el| el.children().ok())
613                    .map(|ch| !ch.is_empty())
614                    .unwrap_or(false))
615            }
616            Condition::WindowWithAttribute {
617                title,
618                automation_id,
619                pid,
620                process,
621            } => {
622                let proc_filter = process.as_deref().map(|s| s.to_lowercase());
623                Ok(desktop
624                    .application_windows()
625                    .unwrap_or_default()
626                    .iter()
627                    .filter(|w| {
628                        proc_filter.as_deref().map_or(true, |pf| {
629                            w.process_name()
630                                .map(|n| n.to_lowercase() == pf)
631                                .unwrap_or(false)
632                        })
633                    })
634                    .any(|w| {
635                        let title_ok = title
636                            .as_ref()
637                            .map_or(true, |t| w.name().map(|n| t.test(&n)).unwrap_or(false));
638                        let aid_ok = automation_id
639                            .as_ref()
640                            .map_or(true, |aid| w.automation_id().as_deref() == Some(aid));
641                        let pid_ok =
642                            pid.map_or(true, |p| w.process_id().map_or(false, |wp| wp == p));
643                        title_ok && aid_ok && pid_ok
644                    }))
645            }
646            Condition::ProcessRunning { process } => {
647                let target = process.to_lowercase();
648                Ok(desktop
649                    .application_windows()
650                    .unwrap_or_default()
651                    .iter()
652                    .any(|w| {
653                        w.process_name()
654                            .map(|n| n.to_lowercase() == target)
655                            .unwrap_or(false)
656                    }))
657            }
658            Condition::WindowClosed { anchor } => {
659                let windows = desktop.application_windows().unwrap_or_default();
660                if let Some(hwnd) = dom.anchor_hwnd(anchor) {
661                    // HWND-locked anchor: closed when that specific window is gone.
662                    Ok(!windows.iter().any(|w| w.hwnd() == Some(hwnd)))
663                } else if let Some(pid) = dom.anchor_pid(anchor) {
664                    // PID-only anchor (e.g. single-instance process): closed when
665                    // no window exists for that process.
666                    Ok(!windows
667                        .iter()
668                        .any(|w| w.process_id().map_or(false, |p| p == pid)))
669                } else {
670                    // Unpinned anchor: closed when re-resolution fails.
671                    Ok(dom.get(anchor, desktop).is_err())
672                }
673            }
674            Condition::WindowWithState { anchor, state } => {
675                let el = match dom.get(anchor, desktop).ok().cloned() {
676                    Some(e) => e,
677                    None => return Ok(false),
678                };
679                Ok(match state {
680                    WindowState::Active => {
681                        let fg = match desktop.foreground_window() {
682                            Some(w) => w,
683                            None => return Ok(false),
684                        };
685                        el.process_id().unwrap_or(0) != 0
686                            && el.process_id().ok() == fg.process_id().ok()
687                    }
688                    WindowState::Visible => el.is_visible().unwrap_or(false),
689                })
690            }
691            Condition::DialogPresent { scope } => has_dialog_child(dom, desktop, scope),
692            Condition::DialogAbsent { scope } => Ok(!has_dialog_child(dom, desktop, scope)?),
693            Condition::ForegroundIsDialog { scope: _, title } => {
694                let fg = match desktop.foreground_window() {
695                    Some(w) => w,
696                    None => return Ok(false),
697                };
698                if fg.role() != "dialog" {
699                    return Ok(false);
700                }
701                if let Some(tm) = title {
702                    if !tm.test(&fg.name().unwrap_or_default()) {
703                        return Ok(false);
704                    }
705                }
706                Ok(true)
707            }
708            Condition::AllOf { conditions } => {
709                for c in conditions {
710                    if !c.evaluate(dom, desktop, locals, params, output)? {
711                        return Ok(false);
712                    }
713                }
714                Ok(true)
715            }
716            Condition::AnyOf { conditions } => {
717                for c in conditions {
718                    if c.evaluate(dom, desktop, locals, params, output)? {
719                        return Ok(true);
720                    }
721                }
722                Ok(false)
723            }
724            Condition::Always => Ok(true),
725            Condition::ExecSucceeded => {
726                Ok(locals.get(EXEC_EXIT_CODE_KEY).map(String::as_str) == Some("0"))
727            }
728            Condition::FileExists { path } => Ok(std::path::Path::new(path).exists()),
729            Condition::Not { condition } => {
730                Ok(!condition.evaluate(dom, desktop, locals, params, output)?)
731            }
732            Condition::EvalCondition { expr } => {
733                crate::expression::eval_bool_expr(expr, locals, params, output)
734                    .map_err(|e| AutomataError::Internal(format!("EvalCondition: {e}")))
735            }
736            Condition::TabWithAttribute { scope, title, url } => {
737                let tab_id = match dom.tab_handle(scope) {
738                    Some(h) => h.tab_id.clone(),
739                    None => return Ok(false),
740                };
741                let info = desktop
742                    .browser()
743                    .tab_info(&tab_id)
744                    .map_err(|e| AutomataError::Internal(format!("tab_info: {e}")))?;
745                let title_ok = title.as_ref().map_or(true, |t| t.test(&info.title));
746                let url_ok = url.as_ref().map_or(true, |u| u.test(&info.url));
747                Ok(title_ok && url_ok)
748            }
749            Condition::TabWithState { scope, expr } => {
750                let tab_id = match dom.tab_handle(scope) {
751                    Some(h) => h.tab_id.clone(),
752                    None => return Ok(false),
753                };
754                let result = desktop
755                    .browser()
756                    .eval(&tab_id, expr)
757                    .map_err(|e| AutomataError::Internal(format!("TabWithState eval: {e}")))?;
758                Ok(result.trim() == "true")
759            }
760        }
761    }
762}
763
764// ── Helpers ───────────────────────────────────────────────────────────────────
765
766fn find_in_scope<D: Desktop>(
767    dom: &mut ShadowDom<D>,
768    desktop: &D,
769    scope: &str,
770    selector: &SelectorPath,
771) -> Result<Option<D::Elem>, AutomataError> {
772    dom.find_descendant(scope, selector, desktop)
773}
774
775fn has_dialog_child<D: Desktop>(
776    dom: &mut ShadowDom<D>,
777    desktop: &D,
778    scope: &str,
779) -> Result<bool, AutomataError> {
780    let root = match dom.get(scope, desktop).ok().cloned() {
781        Some(el) => el,
782        None => return Ok(false),
783    };
784    Ok(root
785        .children()
786        .unwrap_or_default()
787        .iter()
788        .any(|c| c.role() == "dialog"))
789}