Skip to main content

ui_automata/
step.rs

1use std::time::Duration;
2
3use schemars::JsonSchema;
4use serde::Deserialize;
5
6use crate::Action;
7use crate::Condition;
8
9/// A single automation intent: execute an action, then wait for an expected UI state.
10#[derive(Deserialize, JsonSchema)]
11pub struct Step {
12    /// Human-readable label shown in logs, e.g. `"click the Save button"`.
13    pub intent: String,
14    /// Optional guard evaluated before the action. If false, the step is skipped (not an error).
15    /// Useful for conditional steps such as "dismiss dialog if it appeared".
16    #[serde(default)]
17    pub precondition: Option<Condition>,
18    /// The UI action to perform: click, type text, press a key, close a window, etc.
19    pub action: Action,
20    /// Optional fallback action run when `expect` times out on the primary action.
21    /// After the fallback runs, `expect` is re-polled once with a fresh timeout.
22    /// If it succeeds, the step succeeds; otherwise `on_failure` decides what happens.
23    #[serde(default)]
24    pub fallback: Option<Action>,
25    /// Condition that must become true after the action for the step to succeed.
26    /// Polled every 100 ms until satisfied or the timeout elapses.
27    pub expect: Condition,
28    /// Maximum time to wait for `expect` to become true. Overrides the workflow default.
29    /// Accepts duration strings such as `"5s"`, `"300ms"`, `"2m"`.
30    #[serde(default, with = "crate::duration::serde::option")]
31    #[schemars(schema_with = "crate::schema::duration_schema")]
32    pub timeout: Option<Duration>,
33    /// Retry policy on timeout. Overrides the workflow default.
34    /// Default: `none` — falls back to the workflow-level default.
35    #[serde(default)]
36    pub retry: RetryPolicy,
37    /// What to do when this step fails (expect condition times out, or fallback also fails).
38    /// Default: `abort` — propagate the error and stop the phase.
39    #[serde(default)]
40    pub on_failure: OnFailure,
41    /// What to do immediately after this step succeeds.
42    /// Default: `continue` — proceed to the next step.
43    #[serde(default)]
44    pub on_success: OnSuccess,
45}
46
47/// Controls executor behaviour when a step's `expect` condition times out (and any
48/// `fallback` action also fails to satisfy it).
49#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
50#[serde(rename_all = "snake_case")]
51pub enum OnFailure {
52    /// Propagate the error; abort the current phase. This is the default.
53    #[default]
54    Abort,
55    /// Log the failure, then continue to the next step as if the step had succeeded.
56    Continue,
57}
58
59/// Controls executor behaviour immediately after a step succeeds.
60#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
61#[serde(rename_all = "snake_case")]
62pub enum OnSuccess {
63    /// Proceed to the next step. This is the default.
64    #[default]
65    Continue,
66    /// Stop executing steps in the current phase immediately (not an error).
67    ReturnPhase,
68}
69
70/// What the executor does when a step's `expect` condition times out.
71///
72/// Custom `Deserialize` via `TryFrom<serde_yaml::Value>` so YAML
73/// `fixed: { count: 1, delay: 300ms }` maps cleanly without serde_yaml's
74/// externally-tagged enum quirks.
75#[derive(Debug, Clone, Default, Deserialize)]
76#[serde(try_from = "serde_yaml::Value")]
77pub enum RetryPolicy {
78    /// No retries — fall back to the workflow default, or fail immediately if there is none.
79    #[default]
80    None,
81    /// Re-execute the action up to `count` additional times.
82    Fixed { count: u32, delay: Duration },
83    /// Run recovery handlers on timeout, then retry the step.
84    WithRecovery,
85}
86
87impl TryFrom<serde_yaml::Value> for RetryPolicy {
88    type Error = String;
89
90    fn try_from(v: serde_yaml::Value) -> Result<Self, String> {
91        match &v {
92            serde_yaml::Value::String(s) => match s.as_str() {
93                "none" => Ok(RetryPolicy::None),
94                "with_recovery" => Ok(RetryPolicy::WithRecovery),
95                other => Err(format!("unknown RetryPolicy '{other}'")),
96            },
97            serde_yaml::Value::Mapping(map) => {
98                if let Some(fixed) = map.get("fixed") {
99                    let count = fixed
100                        .get("count")
101                        .and_then(|v| v.as_u64())
102                        .ok_or("RetryPolicy.fixed missing 'count'")?
103                        as u32;
104                    let delay = fixed
105                        .get("delay")
106                        .and_then(|v| v.as_str())
107                        .ok_or("RetryPolicy.fixed missing 'delay'")
108                        .and_then(|s| crate::duration::from_str(s))?;
109                    Ok(RetryPolicy::Fixed { count, delay })
110                } else {
111                    Err(format!("unknown RetryPolicy mapping: {v:?}"))
112                }
113            }
114            _ => Err(format!("invalid RetryPolicy value: {v:?}")),
115        }
116    }
117}