Skip to main content

gpui_rhai/
automation.rs

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    /// The handler's own payload value, when it was declared with one.
231    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    // A disabled ancestor disables the target as well.
286    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
329/// Decode one request, invoke the foreground handler, and encode one response.
330///
331/// # Errors
332///
333/// Returns a JSON serialization error if the response cannot be encoded.
334pub 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
367/// Serve newline-delimited JSON requests synchronously through a Host handler.
368///
369/// # Errors
370///
371/// Returns reader/writer I/O errors or response serialization errors wrapped as
372/// [`std::io::Error`].
373pub 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}