use std::collections::HashMap;
use std::time::Duration;
use schemars::JsonSchema;
use serde::Deserialize;
use crate::{
AnchorDef, Condition, LaunchWait, ResumeStrategy, RetryPolicy, SelectorPath, Step, Tier,
};
mod runner;
#[derive(Deserialize, Clone, JsonSchema)]
pub struct ParamDef {
pub name: String,
#[serde(default)]
pub description: Option<String>,
pub default: Option<String>,
}
#[derive(Deserialize, Clone, JsonSchema)]
pub struct OutputDef {
pub name: String,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
pub struct WorkflowFile {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub params: Vec<ParamDef>,
#[serde(default)]
pub defaults: Defaults,
#[serde(default)]
pub anchors: HashMap<String, YamlAnchor>,
#[serde(default)]
pub recovery_handlers: HashMap<String, YamlRecoveryHandler>,
pub launch: Option<LaunchConfig>,
#[serde(default)]
pub phases: Vec<YamlPhase>,
#[serde(default)]
pub outputs: Option<Vec<OutputDef>>,
#[serde(skip)]
#[schemars(skip)]
pub source_path: Option<std::path::PathBuf>,
#[serde(skip)]
#[schemars(skip)]
pub params_resolved: HashMap<String, String>,
}
#[derive(Deserialize, JsonSchema, Default)]
pub struct DefaultsRecovery {
pub limit: Option<u32>,
}
#[derive(Deserialize, JsonSchema)]
pub struct Defaults {
#[serde(default, with = "crate::duration::serde::option")]
#[schemars(schema_with = "crate::schema::duration_schema")]
pub timeout: Option<Duration>,
#[serde(default = "default_retry")]
pub retry: RetryPolicy,
#[serde(default)]
pub action_snapshot: bool,
#[serde(default)]
pub recovery: DefaultsRecovery,
}
fn default_retry() -> RetryPolicy {
RetryPolicy::Fixed {
count: 1,
delay: Duration::from_secs(1),
}
}
impl Default for Defaults {
fn default() -> Self {
Self {
timeout: None,
retry: default_retry(),
action_snapshot: false,
recovery: DefaultsRecovery::default(),
}
}
}
#[derive(Deserialize, JsonSchema)]
pub struct LaunchConfig {
pub exe: Option<String>,
pub app: Option<String>,
#[serde(default, with = "crate::duration::serde::option")]
#[schemars(schema_with = "crate::schema::duration_schema")]
pub timeout: Option<Duration>,
#[serde(default)]
pub wait: LaunchWait,
#[serde(default)]
pub wait_for: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
pub struct YamlAnchor {
#[serde(rename = "type")]
pub kind: AnchorKind,
#[serde(default)]
pub selector: Option<SelectorPath>,
pub parent: Option<String>,
#[serde(default)]
pub process: Option<String>,
#[serde(default)]
pub pid: Option<u32>,
}
#[derive(Deserialize, JsonSchema)]
pub enum AnchorKind {
Root,
Session,
Stable,
Ephemeral,
Browser,
Tab,
}
impl YamlAnchor {
fn into_def(self, name: String) -> AnchorDef {
let selector = self.selector.unwrap_or_else(|| {
SelectorPath::parse("*").expect("wildcard selector is always valid")
});
match self.kind {
AnchorKind::Root => AnchorDef {
name,
parent: None,
selector,
tier: Tier::Root,
pid: self.pid,
process_name: self.process,
mount_depth: 0,
},
AnchorKind::Session => AnchorDef {
name,
parent: None,
selector,
tier: Tier::Session,
pid: self.pid,
process_name: self.process,
mount_depth: 0,
},
AnchorKind::Stable => AnchorDef {
name,
parent: self.parent,
selector,
tier: Tier::Stable,
pid: self.pid,
process_name: self.process,
mount_depth: 0,
},
AnchorKind::Ephemeral => AnchorDef {
name,
parent: self.parent,
selector,
tier: Tier::Ephemeral,
pid: self.pid,
process_name: self.process,
mount_depth: 0,
},
AnchorKind::Browser => AnchorDef {
name,
parent: None,
selector: SelectorPath::parse("*").expect("wildcard selector is always valid"),
tier: Tier::Browser,
pid: None,
process_name: self.process.clone().or_else(|| Some("msedge".into())),
mount_depth: 0,
},
AnchorKind::Tab => AnchorDef {
name,
parent: self.parent,
selector,
tier: Tier::Tab,
pid: None,
process_name: None,
mount_depth: 0,
},
}
}
}
#[derive(Deserialize, JsonSchema)]
pub struct YamlRecoveryHandler {
pub trigger: Condition,
pub actions: Vec<crate::Action>,
pub resume: ResumeStrategy,
}
#[derive(Deserialize, JsonSchema, Default)]
pub struct YamlPhaseRecovery {
#[serde(default)]
pub handlers: Vec<String>,
pub limit: Option<u32>,
}
#[derive(Deserialize, JsonSchema)]
pub struct FlowControl {
pub condition: Condition,
pub go_to: String,
}
#[derive(Deserialize, JsonSchema)]
pub struct YamlFlowControlPhase {
pub name: String,
pub flow_control: FlowControl,
}
#[derive(Deserialize, JsonSchema)]
pub struct YamlActionPhase {
pub name: String,
#[serde(default)]
pub finally: bool,
#[serde(default)]
pub precondition: Option<Condition>,
#[serde(default)]
pub mount: Vec<String>,
#[serde(default)]
pub unmount: Vec<String>,
#[serde(default)]
pub recovery: Option<YamlPhaseRecovery>,
pub steps: Vec<Step>,
}
#[derive(Deserialize, JsonSchema)]
pub struct YamlSubflowPhase {
pub name: String,
pub subflow: String,
#[serde(default)]
pub params: HashMap<String, String>,
}
#[derive(JsonSchema)]
#[serde(untagged)] pub enum YamlPhase {
FlowControl(YamlFlowControlPhase),
Subflow(YamlSubflowPhase),
Action(YamlActionPhase),
}
impl<'de> serde::Deserialize<'de> for YamlPhase {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let v = serde_yaml::Value::deserialize(deserializer)?;
YamlPhase::try_from(v).map_err(serde::de::Error::custom)
}
}
impl TryFrom<serde_yaml::Value> for YamlPhase {
type Error = String;
fn try_from(v: serde_yaml::Value) -> Result<Self, String> {
let map = v.as_mapping().ok_or("phase must be a YAML mapping")?;
let has = |k: &str| map.contains_key(&serde_yaml::Value::String(k.into()));
if has("flow_control") {
serde_yaml::from_value::<YamlFlowControlPhase>(v)
.map(YamlPhase::FlowControl)
.map_err(|e| format!("flow_control phase: {e}"))
} else if has("subflow") {
serde_yaml::from_value::<YamlSubflowPhase>(v)
.map(YamlPhase::Subflow)
.map_err(|e| format!("subflow phase: {e}"))
} else {
serde_yaml::from_value::<YamlActionPhase>(v)
.map(YamlPhase::Action)
.map_err(|e| format!("action phase: {e}"))
}
}
}
impl YamlPhase {
pub fn name(&self) -> &str {
match self {
YamlPhase::FlowControl(p) => &p.name,
YamlPhase::Subflow(p) => &p.name,
YamlPhase::Action(p) => &p.name,
}
}
}
#[derive(Deserialize)]
pub struct WorkflowName {
pub name: String,
}
impl WorkflowName {
pub fn read(raw: &str) -> Option<String> {
serde_yaml::from_str::<WorkflowName>(raw)
.ok()
.map(|w| w.name)
}
}
#[derive(Deserialize)]
pub struct WorkflowHeader {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub params: Vec<ParamDef>,
}
#[derive(Debug, Clone)]
pub enum PhaseEvent {
PhaseStarted(String),
PhaseCompleted(String),
PhaseSkipped(String),
PhaseFailed {
phase: String,
error: String,
},
Completed,
Failed(String),
}
impl WorkflowFile {
pub fn load(path: &str, params: &HashMap<String, String>) -> Result<Self, String> {
let raw = std::fs::read_to_string(path).map_err(|e| format!("cannot read {path}: {e}"))?;
let source_path = std::path::PathBuf::from(path);
let workflow_dir = source_path
.parent()
.map(|p| p.to_string_lossy().into_owned());
let mut wf = Self::load_from_str_with_dir(&raw, params, workflow_dir.as_deref())?;
wf.source_path = Some(source_path);
Ok(wf)
}
pub fn load_from_str(raw: &str, params: &HashMap<String, String>) -> Result<Self, String> {
Self::load_from_str_with_dir(raw, params, None)
}
fn load_from_str_with_dir(
raw: &str,
params: &HashMap<String, String>,
workflow_dir: Option<&str>,
) -> Result<Self, String> {
let diags = crate::lint::lint(raw);
if !diags.is_empty() {
let lines: Vec<String> = diags
.iter()
.map(|d| {
if let (Some(line), Some(col)) = (d.line, d.col) {
format!(" {}:{} [{}] {}", line, col, d.path, d.message)
} else {
format!(" [{}] {}", d.path, d.message)
}
})
.collect();
return Err(format!("workflow lint errors:\n{}", lines.join("\n")));
}
let mut value: serde_yaml::Value =
serde_yaml::from_str(raw).map_err(|e| format!("YAML parse error: {e}"))?;
let param_defs: Vec<ParamDef> = value
.get("params")
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|v| serde_yaml::from_value(v.clone()).ok())
.collect()
})
.unwrap_or_default();
for key in params.keys() {
if !param_defs.iter().any(|p| p.name == *key) {
return Err(format!("unknown parameter '--{}'", key.replace('_', "-")));
}
}
let mut merged = HashMap::new();
for def in ¶m_defs {
if let Some(val) = params.get(&def.name) {
merged.insert(def.name.clone(), val.clone());
} else if let Some(default) = &def.default {
merged.insert(def.name.clone(), default.clone());
} else {
return Err(format!(
"required parameter '--{}' not provided",
def.name.replace('_', "-")
));
}
}
substitute_params(&mut value, &merged, workflow_dir);
let mut wf: WorkflowFile = {
let json_str = serde_json::to_value(&value)
.map_err(|e| format!("internal serialization error: {e}"))?
.to_string();
let mut de = serde_json::Deserializer::from_str(&json_str);
serde_path_to_error::deserialize(&mut de)
.map_err(|e| format!("workflow error at {}: {}", e.path(), e.inner()))?
};
wf.params_resolved = merged;
for name in wf.anchors.keys() {
if name.starts_with(':') {
return Err(format!("anchor name '{name}' must not start with ':'"));
}
}
if let Some(outputs) = &wf.outputs {
let outputs_set: std::collections::HashSet<String> =
outputs.iter().map(|o| o.name.clone()).collect();
for phase in &mut wf.phases {
if let YamlPhase::Action(ap) = phase {
for step in &mut ap.steps {
step.action.apply_outputs(&outputs_set);
}
}
}
for handler in wf.recovery_handlers.values_mut() {
for action in &mut handler.actions {
action.apply_outputs(&outputs_set);
}
}
}
Ok(wf)
}
pub fn resolve_path(&self, relative: &str) -> std::path::PathBuf {
if let Some(src) = &self.source_path {
if let Some(parent) = src.parent() {
return parent.join(relative);
}
}
std::path::PathBuf::from(relative)
}
}
fn substitute_params(
value: &mut serde_yaml::Value,
params: &HashMap<String, String>,
workflow_dir: Option<&str>,
) {
match value {
serde_yaml::Value::String(s) => {
for (k, v) in params {
*s = s.replace(&format!("{{param.{k}}}"), v);
}
if let Some(dir) = workflow_dir {
*s = s.replace("{workflow.dir}", dir);
}
while let Some(start) = s.find("{env.") {
let rest = &s[start + 5..];
if let Some(end) = rest.find('}') {
let var_name = &rest[..end];
let replacement = std::env::var(var_name).unwrap_or_default();
s.replace_range(start..start + 5 + end + 1, &replacement);
} else {
break;
}
}
}
serde_yaml::Value::Sequence(seq) => {
for item in seq {
substitute_params(item, params, workflow_dir);
}
}
serde_yaml::Value::Mapping(map) => {
for (_, v) in map.iter_mut() {
substitute_params(v, params, workflow_dir);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Condition, RetryPolicy};
use std::time::Duration;
fn parse(yaml: &str) -> WorkflowFile {
WorkflowFile::load_from_str(yaml, &HashMap::new()).expect("parse failed")
}
fn action_phase(phase: &YamlPhase) -> &YamlActionPhase {
match phase {
YamlPhase::Action(p) => p,
_ => panic!("expected Action phase"),
}
}
#[test]
fn minimal_workflow_parses() {
let wf = parse(
r#"
name: smoke
anchors:
root: { type: Root, selector: "*" }
phases:
- name: do_nothing
steps:
- intent: noop
action: { type: NoOp }
expect: { type: DialogAbsent, scope: root }
"#,
);
assert_eq!(wf.name, "smoke");
assert_eq!(wf.phases.len(), 1);
assert_eq!(action_phase(&wf.phases[0]).steps.len(), 1);
}
#[test]
fn params_substituted() {
let mut cli: HashMap<String, String> = HashMap::new();
cli.insert("text".into(), "hello world".into());
let raw = r#"
name: test
params:
- name: text
default: default
anchors:
ed: { type: Root, selector: "*" }
phases:
- name: type_it
steps:
- intent: type
action: { type: TypeText, scope: ed, selector: "[role=edit]", text: "{param.text}" }
expect: { type: ElementHasText, scope: ed, selector: "[role=edit]", pattern: { contains: "{param.text}" } }
"#;
let wf = WorkflowFile::load_from_str(raw, &cli).unwrap();
let step = &action_phase(&wf.phases[0]).steps[0];
match &step.action {
crate::Action::TypeText { text, .. } => assert_eq!(text, "hello world"),
_ => panic!("expected TypeText"),
}
}
#[test]
fn env_substituted() {
unsafe { std::env::set_var("TEST_UI_AUTOMATA_HOME", "C:\\Users\\testuser") };
let raw = r#"
name: test
params:
- name: save_dir
default: "{env.TEST_UI_AUTOMATA_HOME}\\Documents\\"
phases: []
"#;
let wf = WorkflowFile::load_from_str(raw, &HashMap::new()).unwrap();
let param = wf.params.iter().find(|p| p.name == "save_dir").unwrap();
assert_eq!(
param.default.as_deref(),
Some("C:\\Users\\testuser\\Documents\\")
);
}
#[test]
fn missing_required_param_errors() {
let raw = r#"
name: test
params:
- name: required_thing
phases: []
"#;
let result = WorkflowFile::load_from_str(raw, &HashMap::new());
assert!(result.is_err());
assert!(result.err().unwrap().contains("required-thing"));
}
#[test]
fn unknown_param_errors() {
let raw = r#"
name: test
params:
- name: text
default: hi
phases: []
"#;
let mut cli = HashMap::new();
cli.insert("unknown_key".into(), "val".into());
let result = WorkflowFile::load_from_str(raw, &cli);
assert!(result.is_err());
assert!(result.err().unwrap().contains("unknown-key"));
}
#[test]
fn window_with_title_flat_fields() {
let wf = parse(
r#"
name: test
phases:
- name: check
steps:
- intent: wait
action: { type: NoOp }
expect:
type: WindowWithAttribute
title:
contains: Notepad
"#,
);
let expect = &action_phase(&wf.phases[0]).steps[0].expect;
match expect {
Condition::WindowWithAttribute { title, .. } => {
let t = title.as_ref().expect("expected title");
assert_eq!(t.contains.as_deref(), Some("Notepad"));
assert!(t.exact.is_none());
}
_ => panic!("expected WindowWithAttribute"),
}
}
#[test]
fn not_condition_wraps_correctly() {
let wf = parse(
r#"
name: test
phases:
- name: check
steps:
- intent: wait for window gone
action: { type: NoOp }
expect:
type: Not
condition:
type: WindowWithAttribute
title:
contains: Notepad
"#,
);
let expect = &action_phase(&wf.phases[0]).steps[0].expect;
match expect {
Condition::Not { condition } => match condition.as_ref() {
Condition::WindowWithAttribute { title, .. } => {
let t = title.as_ref().expect("expected title");
assert_eq!(t.contains.as_deref(), Some("Notepad"));
}
_ => panic!("expected WindowWithAttribute inside Not"),
},
_ => panic!("expected Not"),
}
}
#[test]
fn retry_policy_fixed_parses() {
let wf = parse(
r#"
name: test
defaults:
timeout: 5s
retry:
fixed: { count: 2, delay: 500ms }
anchors:
root: { type: Root, selector: "*" }
phases:
- name: p
steps:
- intent: x
action: { type: NoOp }
expect: { type: DialogAbsent, scope: root }
"#,
);
assert_eq!(wf.defaults.timeout, Some(Duration::from_secs(5)));
match &wf.defaults.retry {
RetryPolicy::Fixed { count, delay } => {
assert_eq!(*count, 2);
assert_eq!(*delay, Duration::from_millis(500));
}
_ => panic!("expected Fixed retry"),
}
}
#[test]
fn anchor_library_parses() {
let wf = parse(
r#"
name: test
anchors:
notepad:
type: Root
selector: "[name~=Notepad]"
editor:
type: Stable
parent: notepad
selector: "[role=edit]"
phases: []
"#,
);
assert!(wf.anchors.contains_key("notepad"));
assert!(wf.anchors.contains_key("editor"));
assert_eq!(wf.anchors["editor"].parent.as_deref(), Some("notepad"));
}
#[test]
fn recovery_handler_library_parses() {
let wf = parse(
r#"
name: test
anchors:
main: { type: Root, selector: "*" }
recovery_handlers:
dismiss_error:
trigger: { type: DialogPresent, scope: main }
actions:
- { type: ClickForegroundButton, name: OK }
resume: retry_step
phases: []
"#,
);
let handler = &wf.recovery_handlers["dismiss_error"];
assert!(matches!(handler.resume, ResumeStrategy::RetryStep));
}
}