1use std::io::{BufRead, Write};
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6use crate::{AccessibilityNode, AccessibilityTree, EventPhase, NodeId, UiEventHandler, UiValue};
7
8#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
9#[serde(tag = "kind", rename_all = "snake_case")]
10pub enum AutomationLocator {
11 SemanticId { id: String },
12 TestId { id: String },
13 RoleName { role: String, name: String },
14 Text { text: String },
15}
16
17#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
18pub struct AutomationBounds {
19 pub x: f64,
20 pub y: f64,
21 pub width: f64,
22 pub height: f64,
23}
24
25#[allow(clippy::struct_excessive_bools)]
26#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
27pub struct AutomationNode {
28 pub id: u64,
29 pub parent: Option<u64>,
30 pub role: String,
31 pub name: String,
32 pub description: String,
33 pub semantic_id: Option<String>,
34 pub test_id: Option<String>,
35 pub value: Option<UiValue>,
36 pub checked: Option<UiValue>,
37 pub selected: Option<bool>,
38 pub pressed: Option<bool>,
39 pub expanded: Option<bool>,
40 pub orientation: Option<String>,
41 pub placeholder: Option<String>,
42 pub key_shortcuts: Option<String>,
43 pub level: Option<usize>,
44 pub position_in_set: Option<usize>,
45 pub size_of_set: Option<usize>,
46 pub row_index: Option<usize>,
47 pub column_index: Option<usize>,
48 pub row_count: Option<usize>,
49 pub column_count: Option<usize>,
50 pub value_min: Option<f64>,
51 pub value_max: Option<f64>,
52 pub current: Option<String>,
53 pub disabled: bool,
54 pub read_only: bool,
55 pub invalid: bool,
56 pub required: bool,
57 pub bounds: Option<AutomationBounds>,
58 pub children: Vec<u64>,
59}
60
61impl From<&AccessibilityNode> for AutomationNode {
62 fn from(node: &AccessibilityNode) -> Self {
63 Self {
64 id: node.id.get(),
65 parent: node.parent.map(NodeId::get),
66 role: node.role.clone(),
67 name: node.name.clone(),
68 description: node.description.clone(),
69 semantic_id: node.semantic_id.clone(),
70 test_id: node.test_id.clone(),
71 value: node.value.clone(),
72 checked: node.checked.clone(),
73 selected: node.selected,
74 pressed: node.pressed,
75 expanded: node.expanded,
76 orientation: node.orientation.clone(),
77 placeholder: node.placeholder.clone(),
78 key_shortcuts: node.key_shortcuts.clone(),
79 level: node.level,
80 position_in_set: node.position_in_set,
81 size_of_set: node.size_of_set,
82 row_index: node.row_index,
83 column_index: node.column_index,
84 row_count: node.row_count,
85 column_count: node.column_count,
86 value_min: node.value_min,
87 value_max: node.value_max,
88 current: node.current.clone(),
89 disabled: node.disabled,
90 read_only: node.read_only,
91 invalid: node.invalid,
92 required: node.required,
93 bounds: node.geometry.map(|geometry| AutomationBounds {
94 x: geometry.visual.x,
95 y: geometry.visual.y,
96 width: geometry.visual.width,
97 height: geometry.visual.height,
98 }),
99 children: node.children.iter().copied().map(NodeId::get).collect(),
100 }
101 }
102}
103
104#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
105pub struct AutomationSnapshot {
106 pub roots: Vec<u64>,
107 pub nodes: Vec<AutomationNode>,
108}
109
110impl AutomationSnapshot {
111 #[must_use]
112 pub fn from_accessibility(tree: &AccessibilityTree) -> Self {
113 Self {
114 roots: tree.root_ids().iter().copied().map(NodeId::get).collect(),
115 nodes: tree.nodes().map(AutomationNode::from).collect(),
116 }
117 }
118}
119
120#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
121#[serde(tag = "command", rename_all = "snake_case")]
122pub enum AutomationCommand {
123 Snapshot,
124 Query {
125 locator: AutomationLocator,
126 },
127 Dispatch {
128 locator: AutomationLocator,
129 event: String,
130 #[serde(default)]
131 payload: Option<UiValue>,
132 },
133 Action {
134 id: String,
135 #[serde(default)]
136 payload: Option<UiValue>,
137 },
138 AdvanceTime {
139 millis: u64,
140 },
141}
142
143#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
144pub struct AutomationDispatchReport {
145 pub target: u64,
146 pub visited: Vec<u64>,
147 pub invoked: usize,
148 pub default_prevented: bool,
149 pub stopped: bool,
150}
151
152#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
153#[serde(tag = "result", rename_all = "snake_case")]
154pub enum AutomationResult {
155 Snapshot { snapshot: AutomationSnapshot },
156 Node { node: Box<AutomationNode> },
157 Dispatch { report: AutomationDispatchReport },
158 Action { id: String },
159 Advanced { millis: u64 },
160}
161
162#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
163pub struct AutomationRequest {
164 #[serde(default)]
165 pub id: serde_json::Value,
166 #[serde(flatten)]
167 pub command: AutomationCommand,
168}
169
170#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
171pub struct AutomationResponse {
172 pub id: serde_json::Value,
173 pub ok: bool,
174 #[serde(skip_serializing_if = "Option::is_none")]
175 pub value: Option<AutomationResult>,
176 #[serde(skip_serializing_if = "Option::is_none")]
177 pub error: Option<AutomationFailure>,
178}
179
180#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
181pub struct AutomationFailure {
182 pub code: String,
183 pub message: String,
184}
185
186#[derive(Clone, Debug, Error, Eq, PartialEq)]
187pub enum AutomationError {
188 #[error("automation locator matched no retained semantic node: {0}")]
189 NoMatch(String),
190 #[error("automation locator is ambiguous ({count} matches): {locator}")]
191 Ambiguous { locator: String, count: usize },
192 #[error("automation target node {0} is no longer retained")]
193 StaleTarget(u64),
194 #[error("automation target node {0} is disabled")]
195 Disabled(u64),
196 #[error("automation event name must be a non-empty safe identifier")]
197 InvalidEvent,
198 #[error("runtime clock does not support deterministic advance")]
199 ClockNotControllable,
200 #[error("automation command failed: {0}")]
201 Command(String),
202}
203
204impl AutomationError {
205 fn code(&self) -> &'static str {
206 match self {
207 Self::NoMatch(_) => "no_match",
208 Self::Ambiguous { .. } => "ambiguous",
209 Self::StaleTarget(_) => "stale_target",
210 Self::Disabled(_) => "disabled",
211 Self::InvalidEvent => "invalid_event",
212 Self::ClockNotControllable => "clock_not_controllable",
213 Self::Command(_) => "execution_failed",
214 }
215 }
216
217 fn failure(&self) -> AutomationFailure {
218 AutomationFailure {
219 code: self.code().to_owned(),
220 message: self.to_string(),
221 }
222 }
223}
224
225#[derive(Clone)]
226pub(crate) struct AutomationDispatchStep {
227 pub node: NodeId,
228 pub phase: EventPhase,
229 pub handler: UiEventHandler,
230 pub value: Option<UiValue>,
232}
233
234pub(crate) fn resolve_locator(
235 tree: &AccessibilityTree,
236 locator: &AutomationLocator,
237) -> Result<NodeId, AutomationError> {
238 let matches = match locator {
239 AutomationLocator::SemanticId { id } => tree
240 .find_by_semantic_id(id)
241 .into_iter()
242 .map(|node| node.id)
243 .collect::<Vec<_>>(),
244 AutomationLocator::TestId { id } => tree.find_by_test_id(id).map(|node| node.id).collect(),
245 AutomationLocator::RoleName { role, name } => tree
246 .find_by_role_and_name(role, name)
247 .map(|node| node.id)
248 .collect(),
249 AutomationLocator::Text { text } => tree
250 .nodes()
251 .filter(|node| node.name == *text)
252 .map(|node| node.id)
253 .collect(),
254 };
255 match matches.as_slice() {
256 [node] => Ok(*node),
257 [] => Err(AutomationError::NoMatch(format!("{locator:?}"))),
258 _ => Err(AutomationError::Ambiguous {
259 locator: format!("{locator:?}"),
260 count: matches.len(),
261 }),
262 }
263}
264
265pub(crate) fn dispatch_plan(
266 tree: &crate::RetainedUiTree,
267 target: NodeId,
268 event: &str,
269) -> Result<Vec<AutomationDispatchStep>, AutomationError> {
270 if event.is_empty()
271 || !event
272 .chars()
273 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | ':'))
274 {
275 return Err(AutomationError::InvalidEvent);
276 }
277 tree.node(target)
278 .ok_or_else(|| AutomationError::StaleTarget(target.get()))?;
279 let mut route = Vec::new();
280 let mut current = Some(target);
281 while let Some(node) = current {
282 route.push(node);
283 current = tree.node(node).and_then(crate::RetainedNode::parent);
284 }
285 if route.iter().any(|node| {
287 tree.node(*node)
288 .is_some_and(|node| node.attributes().get("disabled") == Some(&UiValue::Bool(true)))
289 }) {
290 return Err(AutomationError::Disabled(target.get()));
291 }
292 route.reverse();
293 let mut steps = Vec::new();
294 for node in &route {
295 append_phase(tree, *node, event, EventPhase::Capture, &mut steps)?;
296 }
297 append_phase(tree, target, event, EventPhase::Target, &mut steps)?;
298 for node in route.iter().rev() {
299 append_phase(tree, *node, event, EventPhase::Bubble, &mut steps)?;
300 }
301 Ok(steps)
302}
303
304fn append_phase(
305 tree: &crate::RetainedUiTree,
306 node: NodeId,
307 event: &str,
308 phase: EventPhase,
309 output: &mut Vec<AutomationDispatchStep>,
310) -> Result<(), AutomationError> {
311 let retained = tree
312 .node(node)
313 .ok_or_else(|| AutomationError::StaleTarget(node.get()))?;
314 output.extend(
315 retained
316 .event_handlers(event)
317 .iter()
318 .filter(|binding| binding.phase() == phase)
319 .map(|binding| AutomationDispatchStep {
320 node,
321 phase,
322 handler: binding.handler().clone(),
323 value: binding.value().cloned(),
324 }),
325 );
326 Ok(())
327}
328
329pub fn handle_automation_json_line(
335 line: &str,
336 mut handle: impl FnMut(AutomationCommand) -> Result<AutomationResult, AutomationError>,
337) -> Result<String, serde_json::Error> {
338 let request = serde_json::from_str::<AutomationRequest>(line);
339 let response = match request {
340 Ok(request) => match handle(request.command) {
341 Ok(value) => AutomationResponse {
342 id: request.id,
343 ok: true,
344 value: Some(value),
345 error: None,
346 },
347 Err(error) => AutomationResponse {
348 id: request.id,
349 ok: false,
350 value: None,
351 error: Some(error.failure()),
352 },
353 },
354 Err(error) => AutomationResponse {
355 id: serde_json::Value::Null,
356 ok: false,
357 value: None,
358 error: Some(AutomationFailure {
359 code: "invalid_request".to_owned(),
360 message: error.to_string(),
361 }),
362 },
363 };
364 serde_json::to_string(&response)
365}
366
367pub fn run_automation_json_lines(
374 reader: impl BufRead,
375 mut writer: impl Write,
376 mut handle: impl FnMut(AutomationCommand) -> Result<AutomationResult, AutomationError>,
377) -> std::io::Result<()> {
378 for line in reader.lines() {
379 let response =
380 handle_automation_json_line(&line?, &mut handle).map_err(std::io::Error::other)?;
381 writeln!(writer, "{response}")?;
382 writer.flush()?;
383 }
384 Ok(())
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 #[test]
392 fn locators_require_one_unambiguous_semantic_node() {
393 let mut retained = crate::RetainedUiTree::new();
394 retained
395 .reconcile(crate::UiNode::box_node(vec![
396 crate::UiNode::text("Save")
397 .with_key("save")
398 .with_attribute("role", UiValue::String("button".to_owned()))
399 .with_attribute("test_id", UiValue::String("save".to_owned())),
400 crate::UiNode::text("Save")
401 .with_key("save-copy")
402 .with_attribute("role", UiValue::String("button".to_owned())),
403 ]))
404 .unwrap();
405 let tree =
406 AccessibilityTree::from_retained(&retained, &crate::GeometryRegistry::new()).unwrap();
407 assert!(
408 resolve_locator(
409 &tree,
410 &AutomationLocator::TestId {
411 id: "save".to_owned()
412 }
413 )
414 .is_ok()
415 );
416 assert!(matches!(
417 resolve_locator(
418 &tree,
419 &AutomationLocator::Text {
420 text: "Save".to_owned()
421 }
422 ),
423 Err(AutomationError::Ambiguous { count: 2, .. })
424 ));
425 }
426
427 #[test]
428 fn json_lines_protocol_correlates_success_and_errors() {
429 let line = r#"{"id":"probe","command":"advance_time","millis":16}"#;
430 let response = handle_automation_json_line(line, |command| match command {
431 AutomationCommand::AdvanceTime { millis } => Ok(AutomationResult::Advanced { millis }),
432 _ => unreachable!(),
433 })
434 .unwrap();
435 let decoded: AutomationResponse = serde_json::from_str(&response).unwrap();
436 assert!(decoded.ok);
437 assert_eq!(decoded.id, serde_json::Value::String("probe".to_owned()));
438
439 let malformed = handle_automation_json_line("not-json", |_| unreachable!()).unwrap();
440 let decoded: AutomationResponse = serde_json::from_str(&malformed).unwrap();
441 assert!(!decoded.ok);
442 assert_eq!(decoded.id, serde_json::Value::Null);
443 assert_eq!(decoded.error.unwrap().code, "invalid_request");
444 }
445}