1use super::{ValueExpr, WorkflowError};
2use serde::{Deserialize, Deserializer, Serialize};
3use serde_json::Value;
4use std::collections::BTreeMap;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7#[serde(rename_all = "camelCase", deny_unknown_fields)]
8pub struct WorkflowSpec {
9 pub schema_version: u32,
10 pub run_key: String,
11 #[serde(default = "strict")]
12 pub mode: String,
13 #[serde(default = "sequential")]
14 pub schedule: String,
15 #[serde(default = "main_window")]
16 pub window_id: String,
17 #[serde(default)]
18 pub inputs: BTreeMap<String, Value>,
19 #[serde(default = "deadline")]
20 pub deadline_ms: u64,
21 #[serde(default)]
22 pub defaults: WorkflowDefaults,
23 #[serde(default)]
24 pub evidence: EvidencePolicy,
25 pub steps: Vec<Step>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub goal: Option<Condition>,
28}
29fn strict() -> String {
30 "strict".into()
31}
32fn sequential() -> String {
33 "sequential".into()
34}
35fn main_window() -> String {
36 "main".into()
37}
38fn deadline() -> u64 {
39 60_000
40}
41fn step_timeout() -> u64 {
42 10_000
43}
44fn locator_timeout() -> u64 {
45 3_000
46}
47fn poll_interval() -> u64 {
48 100
49}
50fn evidence_grace() -> u64 {
51 2_000
52}
53fn max_inline() -> usize {
54 16_384
55}
56fn summary() -> String {
57 "summary".into()
58}
59fn scoped() -> String {
60 "scoped".into()
61}
62#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase", deny_unknown_fields)]
64pub struct WorkflowDefaults {
65 #[serde(default = "step_timeout")]
66 pub step_timeout_ms: u64,
67 #[serde(default = "locator_timeout")]
68 pub locator_timeout_ms: u64,
69 #[serde(default = "poll_interval")]
70 pub poll_interval_ms: u64,
71 #[serde(default = "evidence_grace")]
72 pub failure_evidence_grace_ms: u64,
73}
74impl Default for WorkflowDefaults {
75 fn default() -> Self {
76 Self {
77 step_timeout_ms: step_timeout(),
78 locator_timeout_ms: locator_timeout(),
79 poll_interval_ms: poll_interval(),
80 failure_evidence_grace_ms: evidence_grace(),
81 }
82 }
83}
84#[derive(Debug, Clone, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase", deny_unknown_fields)]
86pub struct EvidencePolicy {
87 #[serde(default = "summary")]
88 pub success: String,
89 #[serde(default = "scoped")]
90 pub failure: String,
91 #[serde(default = "max_inline")]
92 pub max_inline_bytes: usize,
93}
94impl Default for EvidencePolicy {
95 fn default() -> Self {
96 Self {
97 success: summary(),
98 failure: scoped(),
99 max_inline_bytes: max_inline(),
100 }
101 }
102}
103
104#[derive(Debug, Clone, Serialize)]
105#[serde(rename_all = "camelCase")]
106pub struct Step {
107 pub id: String,
108 #[serde(skip_serializing_if = "Option::is_none")]
109 pub window_id: Option<String>,
110 #[serde(skip_serializing_if = "Option::is_none")]
111 pub timeout_ms: Option<u64>,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 pub expect: Option<Condition>,
114 #[serde(skip_serializing_if = "Option::is_none")]
115 pub evidence: Option<EvidencePolicy>,
116 #[serde(flatten)]
117 pub op: StepOp,
118}
119impl<'de> Deserialize<'de> for Step {
122 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
123 let mut fields = serde_json::Map::<String, Value>::deserialize(deserializer)?;
124 let id = fields
125 .remove("id")
126 .ok_or_else(|| serde::de::Error::missing_field("id"))?;
127 let mut take = |name: &str| -> Result<Option<Value>, D::Error> { Ok(fields.remove(name)) };
128 let window_id = take("windowId")?
129 .map(serde_json::from_value)
130 .transpose()
131 .map_err(serde::de::Error::custom)?;
132 let timeout_ms = take("timeoutMs")?
133 .map(serde_json::from_value)
134 .transpose()
135 .map_err(serde::de::Error::custom)?;
136 let expect = take("expect")?
137 .map(serde_json::from_value)
138 .transpose()
139 .map_err(serde::de::Error::custom)?;
140 let evidence = take("evidence")?
141 .map(serde_json::from_value)
142 .transpose()
143 .map_err(serde::de::Error::custom)?;
144 Ok(Self {
145 id: serde_json::from_value(id).map_err(serde::de::Error::custom)?,
146 window_id,
147 timeout_ms,
148 expect,
149 evidence,
150 op: serde_json::from_value(Value::Object(fields)).map_err(serde::de::Error::custom)?,
151 })
152 }
153}
154#[derive(Debug, Clone, Serialize, Deserialize)]
155#[serde(tag = "op", rename_all = "camelCase", deny_unknown_fields)]
156pub enum StepOp {
157 Click {
158 target: Locator,
159 },
160 Fill {
161 target: Locator,
162 value: ValueExpr,
163 },
164 Type {
165 target: Locator,
166 value: ValueExpr,
167 },
168 Press {
169 #[serde(default, skip_serializing_if = "Option::is_none")]
170 target: Option<Locator>,
171 key: ValueExpr,
172 },
173 Wait {
174 condition: Condition,
175 },
176 Query {
177 target: Locator,
178 query: QueryKind,
179 },
180 Tool {
181 tool: String,
182 args: Value,
183 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
184 bindings: BTreeMap<String, ValueExpr>,
185 },
186}
187#[derive(Debug, Clone, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase", deny_unknown_fields)]
189pub struct Locator {
190 pub by: LocatorBy,
191 pub value: ValueExpr,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub name: Option<ValueExpr>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub scope: Option<Box<Locator>>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub entity: Option<EntityLocator>,
198}
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "camelCase")]
201pub enum LocatorBy {
202 Role,
203 Label,
204 TestId,
205 Css,
206}
207#[derive(Debug, Clone, Serialize, Deserialize)]
208#[serde(deny_unknown_fields)]
209pub struct EntityLocator {
210 pub attribute: String,
211 pub value: ValueExpr,
212}
213#[derive(Debug, Clone, Serialize, Deserialize)]
214#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
215pub enum QueryKind {
216 Value,
217 Text,
218 Attribute { name: String },
219}
220#[derive(Debug, Clone, Serialize, Deserialize)]
221#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
222pub enum Condition {
223 Element {
224 target: Locator,
225 state: ElementState,
226 },
227 ValueEquals {
228 target: Locator,
229 expected: ValueExpr,
230 },
231 TextContains {
232 target: Locator,
233 expected: ValueExpr,
234 },
235 AttributeEquals {
236 target: Locator,
237 name: String,
238 expected: ValueExpr,
239 },
240 Result {
241 #[serde(rename = "stepId")]
242 step_id: String,
243 pointer: String,
244 operator: ResultOperator,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
246 expected: Option<ValueExpr>,
247 },
248 All {
249 conditions: Vec<Condition>,
250 },
251 Any {
252 conditions: Vec<Condition>,
253 },
254}
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "camelCase")]
257pub enum ElementState {
258 Visible,
259 Hidden,
260 Attached,
261 Detached,
262 Enabled,
263 Editable,
264}
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
266#[serde(rename_all = "camelCase")]
267pub enum ResultOperator {
268 Eq,
269 Exists,
270 NonEmptyString,
271}
272impl WorkflowSpec {
273 pub fn canonical_value(&self) -> Result<Value, WorkflowError> {
274 let mut normalized = self.clone();
275 for step in &mut normalized.steps {
276 step.window_id
277 .get_or_insert_with(|| normalized.window_id.clone());
278 step.timeout_ms
279 .get_or_insert(normalized.defaults.step_timeout_ms);
280 step.evidence
281 .get_or_insert_with(|| normalized.evidence.clone());
282 }
283 let mut value = serde_json::to_value(normalized).map_err(|_| {
284 WorkflowError::new("invalid_spec", "validating", "Could not serialize workflow")
285 })?;
286 if let Some(obj) = value.as_object_mut() {
287 obj.remove("runKey");
288 }
289 Ok(value)
290 }
291}