Skip to main content

devicerail_protocol/
ui.rs

1use std::{collections::HashSet, fmt};
2
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6use crate::{ActionCall, AssetRef};
7
8const fn default_true() -> bool {
9    true
10}
11
12const fn is_true(value: &bool) -> bool {
13    *value
14}
15
16/// Current canonical UI Tree payload version.
17pub const UI_SNAPSHOT_FORMAT_VERSION: u16 = 1;
18
19/// Media type used by Evidence objects containing a canonical UI Tree.
20pub const UI_SNAPSHOT_MEDIA_TYPE: &str = "application/vnd.devicerail.ui-tree+json;version=1";
21
22/// Hard limits shared by schema validation, Evidence loading, and Drivers.
23pub const MAX_UI_SNAPSHOT_NODES: usize = 10_000;
24/// Leaves headroom for the JSON-RPC response envelope under the 1 MiB frame cap.
25pub const MAX_UI_SNAPSHOT_BYTES: u64 = 768 * 1_024;
26pub const MAX_UI_IDENTIFIER_LENGTH: usize = 4_096;
27pub const MAX_UI_ROLE_LENGTH: usize = 256;
28pub const MAX_UI_TEXT_LENGTH: usize = 65_536;
29pub const MAX_ELEMENT_VALUE_LENGTH: usize = 65_536;
30
31pub const FIND_ELEMENT_ACTION: &str = "findElement";
32pub const TAP_ELEMENT_ACTION: &str = "tapElement";
33pub const CLEAR_ELEMENT_ACTION: &str = "clearElement";
34pub const SET_ELEMENT_VALUE_ACTION: &str = "setElementValue";
35pub const WAIT_FOR_ELEMENT_ACTION: &str = "waitForElement";
36pub const SEMANTIC_ACTION_NAMES: [&str; 5] = [
37    FIND_ELEMENT_ACTION,
38    TAP_ELEMENT_ACTION,
39    CLEAR_ELEMENT_ACTION,
40    SET_ELEMENT_VALUE_ACTION,
41    WAIT_FOR_ELEMENT_ACTION,
42];
43
44pub fn is_semantic_action_name(name: &str) -> bool {
45    matches!(
46        name,
47        FIND_ELEMENT_ACTION
48            | TAP_ELEMENT_ACTION
49            | CLEAR_ELEMENT_ACTION
50            | SET_ELEMENT_VALUE_ACTION
51            | WAIT_FOR_ELEMENT_ACTION
52    )
53}
54
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub enum UiContractError {
57    EmptyField(&'static str),
58    FieldTooLong(&'static str),
59    InvalidBounds(String),
60    InvalidContextForCss,
61    InvalidSemanticExecutionContext,
62    EmptySelector,
63    UnsupportedSnapshotFormat(u16),
64    EmptySnapshot,
65    TooManyNodes(usize),
66    SnapshotTooLarge(usize),
67    DuplicateStableNodeId(String),
68    InvalidRootOrder,
69    MissingOrLateParent(String),
70    InvalidPreorder(String),
71    InvalidNodeCount(u32),
72    InvalidByteLength(u64),
73    InvalidSnapshotMediaType(String),
74    InvalidWaitResult,
75}
76
77impl fmt::Display for UiContractError {
78    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
79        match self {
80            Self::EmptyField(field) => write!(formatter, "{field} must not be empty"),
81            Self::FieldTooLong(field) => write!(formatter, "{field} exceeds its wire limit"),
82            Self::InvalidBounds(node) => write!(formatter, "node {node} has invalid bounds"),
83            Self::InvalidContextForCss => {
84                write!(formatter, "css selectors cannot target a native context")
85            }
86            Self::InvalidSemanticExecutionContext => {
87                write!(
88                    formatter,
89                    "semantic execution mode and context kind disagree"
90                )
91            }
92            Self::EmptySelector => write!(formatter, "element selector has no matching fields"),
93            Self::UnsupportedSnapshotFormat(version) => {
94                write!(
95                    formatter,
96                    "unsupported UI Snapshot format version {version}"
97                )
98            }
99            Self::EmptySnapshot => write!(formatter, "UI Snapshot must contain a root node"),
100            Self::TooManyNodes(count) => write!(formatter, "UI Snapshot has {count} nodes"),
101            Self::SnapshotTooLarge(bytes) => write!(formatter, "UI Snapshot is {bytes} bytes"),
102            Self::DuplicateStableNodeId(id) => write!(formatter, "duplicate stable node id {id}"),
103            Self::InvalidRootOrder => write!(formatter, "rootNodeIds do not match preorder roots"),
104            Self::MissingOrLateParent(id) => {
105                write!(formatter, "node {id} references a missing or later parent")
106            }
107            Self::InvalidPreorder(id) => write!(formatter, "node {id} breaks preorder traversal"),
108            Self::InvalidNodeCount(count) => write!(formatter, "invalid UI node count {count}"),
109            Self::InvalidByteLength(bytes) => write!(formatter, "invalid UI byte length {bytes}"),
110            Self::InvalidSnapshotMediaType(media_type) => {
111                write!(formatter, "invalid UI Snapshot media type {media_type}")
112            }
113            Self::InvalidWaitResult => write!(formatter, "wait result contradicts its condition"),
114        }
115    }
116}
117
118impl std::error::Error for UiContractError {}
119
120fn validate_required(value: &str, field: &'static str, max: usize) -> Result<(), UiContractError> {
121    if value.trim().is_empty() {
122        return Err(UiContractError::EmptyField(field));
123    }
124    if value.chars().count() > max {
125        return Err(UiContractError::FieldTooLong(field));
126    }
127    Ok(())
128}
129
130fn validate_optional(
131    value: Option<&str>,
132    field: &'static str,
133    max: usize,
134) -> Result<(), UiContractError> {
135    if value.is_some_and(|value| value.chars().count() > max) {
136        return Err(UiContractError::FieldTooLong(field));
137    }
138    Ok(())
139}
140
141#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
142#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
143#[serde(rename_all = "camelCase")]
144pub enum UiContextKind {
145    Native,
146    Web,
147}
148
149/// Full identity of one native accessibility or web-document context.
150/// `documentEpoch` is required for both channels and changes after reconnect,
151/// navigation, or any replacement that invalidates prior node references.
152#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
153#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
154#[serde(rename_all = "camelCase", deny_unknown_fields)]
155pub struct UiContextRef {
156    pub context_kind: UiContextKind,
157    #[cfg_attr(feature = "schema", schemars(length(min = 1, max = 4_096)))]
158    pub context_id: String,
159    #[cfg_attr(feature = "schema", schemars(length(min = 1, max = 4_096)))]
160    pub document_epoch: String,
161}
162
163impl UiContextRef {
164    pub fn validate(&self) -> Result<(), UiContractError> {
165        validate_required(&self.context_id, "contextId", MAX_UI_IDENTIFIER_LENGTH)?;
166        validate_required(
167            &self.document_epoch,
168            "documentEpoch",
169            MAX_UI_IDENTIFIER_LENGTH,
170        )
171    }
172}
173
174/// Selects a current context without pretending to own its document epoch.
175#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
176#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
177#[serde(rename_all = "camelCase", deny_unknown_fields)]
178pub struct UiContextSelector {
179    pub context_kind: UiContextKind,
180    #[cfg_attr(feature = "schema", schemars(length(min = 1, max = 4_096)))]
181    pub context_id: Option<String>,
182}
183
184impl UiContextSelector {
185    pub fn validate(&self) -> Result<(), UiContractError> {
186        if let Some(context_id) = &self.context_id {
187            validate_required(context_id, "contextId", MAX_UI_IDENTIFIER_LENGTH)?;
188        }
189        Ok(())
190    }
191}
192
193#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
194#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
195#[serde(rename_all = "camelCase", deny_unknown_fields)]
196pub struct UiRect {
197    pub x: f64,
198    pub y: f64,
199    #[cfg_attr(feature = "schema", schemars(range(min = 0.0)))]
200    pub width: f64,
201    #[cfg_attr(feature = "schema", schemars(range(min = 0.0)))]
202    pub height: f64,
203}
204
205impl UiRect {
206    pub const fn is_valid(self) -> bool {
207        self.x.is_finite()
208            && self.y.is_finite()
209            && self.width.is_finite()
210            && self.height.is_finite()
211            && self.width >= 0.0
212            && self.height >= 0.0
213    }
214}
215
216/// One node in the normalized preorder list. Unknown platform values remain
217/// `null`; Drivers must not manufacture optimistic enabled/hittable states.
218#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
219#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
220#[serde(rename_all = "camelCase", deny_unknown_fields)]
221pub struct UiNode {
222    #[cfg_attr(feature = "schema", schemars(length(min = 1, max = 4_096)))]
223    pub stable_node_id: String,
224    #[cfg_attr(feature = "schema", schemars(length(min = 1, max = 4_096)))]
225    pub parent_stable_node_id: Option<String>,
226    #[cfg_attr(feature = "schema", schemars(length(min = 1, max = 256)))]
227    pub role: String,
228    #[cfg_attr(feature = "schema", schemars(length(max = 65_536)))]
229    pub name: Option<String>,
230    #[cfg_attr(feature = "schema", schemars(length(max = 65_536)))]
231    pub value: Option<String>,
232    #[cfg_attr(feature = "schema", schemars(length(max = 4_096)))]
233    pub identifier: Option<String>,
234    #[cfg_attr(feature = "schema", schemars(length(max = 65_536)))]
235    pub text: Option<String>,
236    pub bounds: Option<UiRect>,
237    pub enabled: Option<bool>,
238    pub hittable: Option<bool>,
239}
240
241impl UiNode {
242    pub fn validate(&self) -> Result<(), UiContractError> {
243        validate_required(
244            &self.stable_node_id,
245            "stableNodeId",
246            MAX_UI_IDENTIFIER_LENGTH,
247        )?;
248        if let Some(parent) = &self.parent_stable_node_id {
249            validate_required(parent, "parentStableNodeId", MAX_UI_IDENTIFIER_LENGTH)?;
250        }
251        validate_required(&self.role, "role", MAX_UI_ROLE_LENGTH)?;
252        validate_optional(self.name.as_deref(), "name", MAX_UI_TEXT_LENGTH)?;
253        validate_optional(self.value.as_deref(), "value", MAX_UI_TEXT_LENGTH)?;
254        validate_optional(
255            self.identifier.as_deref(),
256            "identifier",
257            MAX_UI_IDENTIFIER_LENGTH,
258        )?;
259        validate_optional(self.text.as_deref(), "text", MAX_UI_TEXT_LENGTH)?;
260        if self.bounds.is_some_and(|bounds| !bounds.is_valid()) {
261            return Err(UiContractError::InvalidBounds(self.stable_node_id.clone()));
262        }
263        Ok(())
264    }
265}
266
267/// Canonical UI Tree. `nodes` is preorder and every parent must precede its
268/// contiguous descendants. The serialized payload is bounded to 768 KiB.
269#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
270#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
271#[serde(rename_all = "camelCase", deny_unknown_fields)]
272pub struct UiSnapshot {
273    #[cfg_attr(feature = "schema", schemars(range(min = 1_u16, max = 1_u16)))]
274    pub format_version: u16,
275    pub observation_id: Uuid,
276    pub context: UiContextRef,
277    #[cfg_attr(feature = "schema", schemars(length(min = 1, max = 10_000)))]
278    pub root_stable_node_ids: Vec<String>,
279    #[cfg_attr(feature = "schema", schemars(length(min = 1, max = 10_000)))]
280    pub nodes: Vec<UiNode>,
281}
282
283impl UiSnapshot {
284    pub fn validate(&self) -> Result<(), UiContractError> {
285        if self.format_version != UI_SNAPSHOT_FORMAT_VERSION {
286            return Err(UiContractError::UnsupportedSnapshotFormat(
287                self.format_version,
288            ));
289        }
290        self.context.validate()?;
291        if self.nodes.is_empty() || self.root_stable_node_ids.is_empty() {
292            return Err(UiContractError::EmptySnapshot);
293        }
294        if self.nodes.len() > MAX_UI_SNAPSHOT_NODES {
295            return Err(UiContractError::TooManyNodes(self.nodes.len()));
296        }
297
298        let mut seen = HashSet::with_capacity(self.nodes.len());
299        let mut stack: Vec<&str> = Vec::new();
300        let mut actual_roots = Vec::new();
301        for node in &self.nodes {
302            node.validate()?;
303            if !seen.insert(node.stable_node_id.as_str()) {
304                return Err(UiContractError::DuplicateStableNodeId(
305                    node.stable_node_id.clone(),
306                ));
307            }
308            match node.parent_stable_node_id.as_deref() {
309                None => {
310                    actual_roots.push(node.stable_node_id.as_str());
311                    stack.clear();
312                }
313                Some(parent) => {
314                    if !seen.contains(parent) {
315                        return Err(UiContractError::MissingOrLateParent(
316                            node.stable_node_id.clone(),
317                        ));
318                    }
319                    let Some(parent_index) = stack.iter().rposition(|id| *id == parent) else {
320                        return Err(UiContractError::InvalidPreorder(
321                            node.stable_node_id.clone(),
322                        ));
323                    };
324                    stack.truncate(parent_index + 1);
325                }
326            }
327            stack.push(node.stable_node_id.as_str());
328        }
329
330        if actual_roots
331            != self
332                .root_stable_node_ids
333                .iter()
334                .map(String::as_str)
335                .collect::<Vec<_>>()
336        {
337            return Err(UiContractError::InvalidRootOrder);
338        }
339        Ok(())
340    }
341
342    pub fn validate_against(
343        &self,
344        observation_id: Uuid,
345        reference: &UiSnapshotRef,
346    ) -> Result<(), UiContractError> {
347        self.validate()?;
348        reference.validate()?;
349        if self.observation_id != observation_id
350            || self.format_version != reference.format_version
351            || self.context != reference.context
352            || self.nodes.len() != reference.node_count as usize
353        {
354            return Err(UiContractError::InvalidNodeCount(reference.node_count));
355        }
356        Ok(())
357    }
358}
359
360/// Small Observation-side reference to a UI Tree Evidence object.
361#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
362#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
363#[serde(rename_all = "camelCase", deny_unknown_fields)]
364pub struct UiSnapshotRef {
365    #[cfg_attr(feature = "schema", schemars(range(min = 1_u16, max = 1_u16)))]
366    pub format_version: u16,
367    pub context: UiContextRef,
368    #[cfg_attr(feature = "schema", schemars(range(min = 1_u32, max = 10_000_u32)))]
369    pub node_count: u32,
370    #[serde(
371        serialize_with = "crate::wire_integer::serialize_js_safe_u64",
372        deserialize_with = "crate::wire_integer::deserialize_js_safe_u64"
373    )]
374    #[cfg_attr(feature = "schema", schemars(range(min = 1_u64, max = 786_432_u64)))]
375    pub byte_length: u64,
376    pub evidence: AssetRef,
377}
378
379impl UiSnapshotRef {
380    pub fn validate(&self) -> Result<(), UiContractError> {
381        if self.format_version != UI_SNAPSHOT_FORMAT_VERSION {
382            return Err(UiContractError::UnsupportedSnapshotFormat(
383                self.format_version,
384            ));
385        }
386        self.context.validate()?;
387        if self.node_count == 0 || self.node_count as usize > MAX_UI_SNAPSHOT_NODES {
388            return Err(UiContractError::InvalidNodeCount(self.node_count));
389        }
390        if self.byte_length == 0 || self.byte_length > MAX_UI_SNAPSHOT_BYTES {
391            return Err(UiContractError::InvalidByteLength(self.byte_length));
392        }
393        if self.evidence.media_type != UI_SNAPSHOT_MEDIA_TYPE {
394            return Err(UiContractError::InvalidSnapshotMediaType(
395                self.evidence.media_type.clone(),
396            ));
397        }
398        Ok(())
399    }
400}
401
402#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
403#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
404#[serde(rename_all = "camelCase")]
405pub enum UiSnapshotOmissionReason {
406    DriverUnsupported,
407    Policy,
408    ProtectedAction,
409}
410
411/// Durable reference to one node in one observed UI Tree.
412#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
413#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
414#[serde(rename_all = "camelCase", deny_unknown_fields)]
415pub struct UiNodeRef {
416    pub observation_id: Uuid,
417    pub context: UiContextRef,
418    #[cfg_attr(feature = "schema", schemars(length(min = 1, max = 4_096)))]
419    pub stable_node_id: String,
420}
421
422impl UiNodeRef {
423    pub fn validate(&self) -> Result<(), UiContractError> {
424        self.context.validate()?;
425        validate_required(
426            &self.stable_node_id,
427            "stableNodeId",
428            MAX_UI_IDENTIFIER_LENGTH,
429        )
430    }
431}
432
433#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
434#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
435#[serde(rename_all = "camelCase")]
436pub enum TextMatchMode {
437    #[default]
438    Exact,
439    Contains,
440}
441
442#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
443#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
444#[serde(rename_all = "camelCase", deny_unknown_fields)]
445pub struct TextMatch {
446    #[cfg_attr(feature = "schema", schemars(length(min = 1, max = 65_536)))]
447    pub value: String,
448    #[serde(default)]
449    pub mode: TextMatchMode,
450    #[serde(default = "default_true", skip_serializing_if = "is_true")]
451    pub case_sensitive: bool,
452}
453
454/// Cross-channel selector. Native contexts use accessibility fields; web
455/// contexts may additionally use CSS. Context selection never carries a stale
456/// document epoch; resolved node references always carry the full context.
457#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
458#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
459#[serde(rename_all = "camelCase", deny_unknown_fields)]
460pub struct ElementSelector {
461    pub context: Option<UiContextSelector>,
462    #[cfg_attr(feature = "schema", schemars(length(min = 1, max = 256)))]
463    pub role: Option<String>,
464    #[cfg_attr(feature = "schema", schemars(length(min = 1, max = 65_536)))]
465    pub name: Option<String>,
466    #[cfg_attr(feature = "schema", schemars(length(max = 65_536)))]
467    pub value: Option<String>,
468    #[cfg_attr(feature = "schema", schemars(length(min = 1, max = 4_096)))]
469    pub identifier: Option<String>,
470    pub text: Option<TextMatch>,
471    #[cfg_attr(feature = "schema", schemars(length(min = 1, max = 65_536)))]
472    pub css: Option<String>,
473}
474
475impl ElementSelector {
476    pub fn is_empty(&self) -> bool {
477        self.role.is_none()
478            && self.name.is_none()
479            && self.value.is_none()
480            && self.identifier.is_none()
481            && self.text.is_none()
482            && self.css.is_none()
483    }
484
485    pub fn validate(&self) -> Result<(), UiContractError> {
486        if self.is_empty() {
487            return Err(UiContractError::EmptySelector);
488        }
489        if let Some(context) = &self.context {
490            context.validate()?;
491            if self.css.is_some() && context.context_kind == UiContextKind::Native {
492                return Err(UiContractError::InvalidContextForCss);
493            }
494        }
495        if let Some(role) = &self.role {
496            validate_required(role, "role", MAX_UI_ROLE_LENGTH)?;
497        }
498        if let Some(name) = &self.name {
499            validate_required(name, "name", MAX_UI_TEXT_LENGTH)?;
500        }
501        validate_optional(self.value.as_deref(), "value", MAX_UI_TEXT_LENGTH)?;
502        if let Some(identifier) = &self.identifier {
503            validate_required(identifier, "identifier", MAX_UI_IDENTIFIER_LENGTH)?;
504        }
505        if let Some(text) = &self.text {
506            validate_required(&text.value, "text.value", MAX_UI_TEXT_LENGTH)?;
507        }
508        if let Some(css) = &self.css {
509            validate_required(css, "css", MAX_UI_TEXT_LENGTH)?;
510            if self.role.is_some()
511                || self.name.is_some()
512                || self.value.is_some()
513                || self.identifier.is_some()
514                || self.text.is_some()
515            {
516                return Err(UiContractError::InvalidContextForCss);
517            }
518            if !matches!(
519                self.context.as_ref().map(|context| context.context_kind),
520                Some(UiContextKind::Web)
521            ) {
522                return Err(UiContractError::InvalidContextForCss);
523            }
524        }
525        Ok(())
526    }
527}
528
529#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
530#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
531#[serde(
532    tag = "kind",
533    rename_all = "camelCase",
534    rename_all_fields = "camelCase",
535    deny_unknown_fields
536)]
537pub enum ElementTarget {
538    Selector { selector: ElementSelector },
539    Node { node: UiNodeRef },
540}
541
542impl ElementTarget {
543    pub fn validate(&self) -> Result<(), UiContractError> {
544        match self {
545            Self::Selector { selector } => selector.validate(),
546            Self::Node { node } => node.validate(),
547        }
548    }
549}
550
551#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
552#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
553#[serde(rename_all = "camelCase")]
554pub enum CoordinateFallbackReason {
555    SemanticInteractionUnavailable,
556    PlatformLimitation,
557}
558
559#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
560#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
561#[serde(
562    tag = "mode",
563    rename_all = "camelCase",
564    rename_all_fields = "camelCase",
565    deny_unknown_fields
566)]
567pub enum ActionExecution {
568    NativeSemantic {
569        context: UiContextRef,
570    },
571    WebSemantic {
572        context: UiContextRef,
573    },
574    CoordinateFallback {
575        context: UiContextRef,
576        fallback_reason: CoordinateFallbackReason,
577    },
578}
579
580impl ActionExecution {
581    pub fn validate(&self) -> Result<(), UiContractError> {
582        match self {
583            Self::NativeSemantic { context } if context.context_kind == UiContextKind::Native => {
584                context.validate()
585            }
586            Self::WebSemantic { context } if context.context_kind == UiContextKind::Web => {
587                context.validate()
588            }
589            Self::CoordinateFallback { context, .. } => context.validate(),
590            Self::NativeSemantic { .. } | Self::WebSemantic { .. } => {
591                Err(UiContractError::InvalidSemanticExecutionContext)
592            }
593        }
594    }
595}
596
597#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
598#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
599#[serde(rename_all = "camelCase", deny_unknown_fields)]
600pub struct FindElementArguments {
601    pub selector: ElementSelector,
602}
603
604impl FindElementArguments {
605    pub fn validate(&self) -> Result<(), UiContractError> {
606        self.selector.validate()
607    }
608
609    pub fn into_action_call(self, id: Uuid) -> Result<ActionCall, serde_json::Error> {
610        semantic_action_call(id, FIND_ELEMENT_ACTION, self)
611    }
612}
613
614#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
615#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
616#[serde(rename_all = "camelCase", deny_unknown_fields)]
617pub struct FindElementResult {
618    pub element: UiNodeRef,
619}
620
621#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
622#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
623#[serde(rename_all = "camelCase", deny_unknown_fields)]
624pub struct TapElementArguments {
625    pub target: ElementTarget,
626}
627
628impl TapElementArguments {
629    pub fn validate(&self) -> Result<(), UiContractError> {
630        self.target.validate()
631    }
632
633    pub fn into_action_call(self, id: Uuid) -> Result<ActionCall, serde_json::Error> {
634        semantic_action_call(id, TAP_ELEMENT_ACTION, self)
635    }
636}
637
638#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
639#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
640#[serde(rename_all = "camelCase", deny_unknown_fields)]
641pub struct ClearElementArguments {
642    pub target: ElementTarget,
643}
644
645impl ClearElementArguments {
646    pub fn validate(&self) -> Result<(), UiContractError> {
647        self.target.validate()
648    }
649
650    pub fn into_action_call(self, id: Uuid) -> Result<ActionCall, serde_json::Error> {
651        semantic_action_call(id, CLEAR_ELEMENT_ACTION, self)
652    }
653}
654
655#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
656#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
657#[serde(rename_all = "camelCase", deny_unknown_fields)]
658pub struct SetElementValueArguments {
659    pub target: ElementTarget,
660    #[cfg_attr(feature = "schema", schemars(length(max = 65_536)))]
661    pub value: String,
662}
663
664impl SetElementValueArguments {
665    pub fn validate(&self) -> Result<(), UiContractError> {
666        self.target.validate()?;
667        validate_optional(Some(&self.value), "value", MAX_ELEMENT_VALUE_LENGTH)
668    }
669
670    pub fn into_action_call(self, id: Uuid) -> Result<ActionCall, serde_json::Error> {
671        semantic_action_call(id, SET_ELEMENT_VALUE_ACTION, self)
672    }
673}
674
675#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
676#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
677#[serde(rename_all = "camelCase")]
678pub enum WaitForElementCondition {
679    #[default]
680    Present,
681    Visible,
682    Enabled,
683    Absent,
684}
685
686#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
687#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
688#[serde(rename_all = "camelCase", deny_unknown_fields)]
689pub struct WaitForElementArguments {
690    pub selector: ElementSelector,
691    #[serde(default)]
692    pub condition: WaitForElementCondition,
693}
694
695impl WaitForElementArguments {
696    pub fn validate(&self) -> Result<(), UiContractError> {
697        self.selector.validate()
698    }
699
700    pub fn into_action_call(self, id: Uuid) -> Result<ActionCall, serde_json::Error> {
701        semantic_action_call(id, WAIT_FOR_ELEMENT_ACTION, self)
702    }
703}
704
705#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
706#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
707#[serde(rename_all = "camelCase", deny_unknown_fields)]
708pub struct ElementActionOutput {
709    pub element: UiNodeRef,
710}
711
712pub type TapElementResult = ElementActionOutput;
713pub type ClearElementResult = ElementActionOutput;
714pub type SetElementValueResult = ElementActionOutput;
715
716#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
717#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
718#[serde(rename_all = "camelCase", deny_unknown_fields)]
719pub struct WaitForElementResult {
720    pub matched: bool,
721    pub condition: WaitForElementCondition,
722    pub element: Option<UiNodeRef>,
723}
724
725impl WaitForElementResult {
726    pub fn validate(&self) -> Result<(), UiContractError> {
727        let valid = matches!(
728            (self.matched, self.condition, self.element.is_some()),
729            (false, _, false)
730                | (true, WaitForElementCondition::Absent, false)
731                | (true, WaitForElementCondition::Present, true)
732                | (true, WaitForElementCondition::Visible, true)
733                | (true, WaitForElementCondition::Enabled, true)
734        );
735        if !valid {
736            return Err(UiContractError::InvalidWaitResult);
737        }
738        if let Some(element) = &self.element {
739            element.validate()?;
740        }
741        Ok(())
742    }
743}
744
745fn semantic_action_call<T: Serialize>(
746    id: Uuid,
747    name: &'static str,
748    arguments: T,
749) -> Result<ActionCall, serde_json::Error> {
750    Ok(ActionCall {
751        id,
752        name: name.to_owned(),
753        arguments: serde_json::to_value(arguments)?,
754    })
755}
756
757#[cfg(test)]
758mod tests {
759    use serde_json::json;
760    use uuid::Uuid;
761
762    use super::*;
763
764    fn context(kind: UiContextKind) -> UiContextRef {
765        UiContextRef {
766            context_kind: kind,
767            context_id: match kind {
768                UiContextKind::Native => "NATIVE_APP",
769                UiContextKind::Web => "WEBVIEW_1",
770            }
771            .to_owned(),
772            document_epoch: "epoch-1".to_owned(),
773        }
774    }
775
776    fn node_ref(kind: UiContextKind) -> UiNodeRef {
777        UiNodeRef {
778            observation_id: Uuid::nil(),
779            context: context(kind),
780            stable_node_id: "button-7".to_owned(),
781        }
782    }
783
784    #[test]
785    fn element_targets_use_full_context_and_stable_node_id() {
786        assert_eq!(
787            serde_json::to_value(ElementTarget::Node {
788                node: node_ref(UiContextKind::Web),
789            })
790            .expect("node target"),
791            json!({
792                "kind": "node",
793                "node": {
794                    "observationId": Uuid::nil(),
795                    "context": {
796                        "contextKind": "web",
797                        "contextId": "WEBVIEW_1",
798                        "documentEpoch": "epoch-1"
799                    },
800                    "stableNodeId": "button-7"
801                }
802            })
803        );
804    }
805
806    #[test]
807    fn selectors_reject_empty_and_native_css() {
808        assert_eq!(
809            ElementSelector::default().validate(),
810            Err(UiContractError::EmptySelector)
811        );
812        let selector = ElementSelector {
813            context: Some(UiContextSelector {
814                context_kind: UiContextKind::Native,
815                context_id: None,
816            }),
817            css: Some("button".to_owned()),
818            ..ElementSelector::default()
819        };
820        assert_eq!(
821            selector.validate(),
822            Err(UiContractError::InvalidContextForCss)
823        );
824    }
825
826    #[test]
827    fn snapshot_requires_unique_normalized_preorder_nodes() {
828        let snapshot = UiSnapshot {
829            format_version: UI_SNAPSHOT_FORMAT_VERSION,
830            observation_id: Uuid::nil(),
831            context: context(UiContextKind::Native),
832            root_stable_node_ids: vec!["root".to_owned()],
833            nodes: vec![
834                UiNode {
835                    stable_node_id: "root".to_owned(),
836                    parent_stable_node_id: None,
837                    role: "application".to_owned(),
838                    name: None,
839                    value: None,
840                    identifier: None,
841                    text: None,
842                    bounds: None,
843                    enabled: Some(true),
844                    hittable: None,
845                },
846                UiNode {
847                    stable_node_id: "button".to_owned(),
848                    parent_stable_node_id: Some("root".to_owned()),
849                    role: "button".to_owned(),
850                    name: Some("Search".to_owned()),
851                    value: None,
852                    identifier: Some("search-button".to_owned()),
853                    text: Some("Search".to_owned()),
854                    bounds: Some(UiRect {
855                        x: 1.0,
856                        y: 2.0,
857                        width: 3.0,
858                        height: 4.0,
859                    }),
860                    enabled: Some(true),
861                    hittable: Some(true),
862                },
863            ],
864        };
865        snapshot.validate().expect("valid snapshot");
866
867        let mut duplicate = snapshot.clone();
868        duplicate.nodes[1].stable_node_id = "root".to_owned();
869        assert!(matches!(
870            duplicate.validate(),
871            Err(UiContractError::DuplicateStableNodeId(_))
872        ));
873    }
874
875    #[test]
876    fn wait_absent_success_has_no_element() {
877        let result = WaitForElementResult {
878            matched: true,
879            condition: WaitForElementCondition::Absent,
880            element: None,
881        };
882        result.validate().expect("absent success");
883        let invalid = WaitForElementResult {
884            condition: WaitForElementCondition::Visible,
885            ..result
886        };
887        assert_eq!(invalid.validate(), Err(UiContractError::InvalidWaitResult));
888    }
889
890    #[test]
891    fn semantic_action_helpers_lock_names_without_action_timeouts() {
892        let arguments = FindElementArguments {
893            selector: ElementSelector {
894                role: Some("button".to_owned()),
895                ..ElementSelector::default()
896            },
897        };
898        arguments.validate().expect("valid arguments");
899        let call = arguments
900            .into_action_call(Uuid::nil())
901            .expect("build action call");
902        assert_eq!(call.name, FIND_ELEMENT_ACTION);
903        assert_eq!(
904            call.arguments,
905            json!({ "selector": { "context": null, "role": "button", "name": null, "value": null, "identifier": null, "text": null, "css": null } })
906        );
907        assert!(call.arguments.get("timeoutMs").is_none());
908    }
909
910    #[test]
911    fn semantic_execution_mode_must_match_context_kind() {
912        assert_eq!(
913            ActionExecution::NativeSemantic {
914                context: context(UiContextKind::Web),
915            }
916            .validate(),
917            Err(UiContractError::InvalidSemanticExecutionContext)
918        );
919    }
920}