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
16pub const UI_SNAPSHOT_FORMAT_VERSION: u16 = 1;
18
19pub const UI_SNAPSHOT_MEDIA_TYPE: &str = "application/vnd.devicerail.ui-tree+json;version=1";
21
22pub const MAX_UI_SNAPSHOT_NODES: usize = 10_000;
24pub 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#[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#[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#[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#[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#[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#[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#[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}