Skip to main content

devicerail_protocol/
methods.rs

1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2
3use crate::{
4    ActionDefinition, ActionResult, DeviceId, DeviceInfo, EventSequence, HelloResult, MediaFrame,
5    MediaStreamId, MediaStreamInfo, MediaStreamKind, Observation, PeerInfo, SessionExport,
6    SessionId, SessionInfo, SessionOutcome, TestEvent, UiSnapshot, Verdict,
7};
8
9/// Largest page accepted by the snapshot-style `events.list` RPC.
10///
11/// Omitting `limit` preserves the pre-pagination response shape and behavior.
12/// Clients that need bounded recovery should provide a limit and advance
13/// `afterSequence` to the last returned event until a short or empty page is
14/// observed.
15pub const MAX_EVENTS_LIST_PAGE_SIZE: u32 = 1_000;
16
17/// Result returned by `system.describe`.
18#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
19#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
20#[serde(rename_all = "camelCase", deny_unknown_fields)]
21pub struct SystemDescribeResult {
22    pub connection: HelloResult,
23    pub client: PeerInfo,
24    pub device_id: Option<DeviceId>,
25    pub active_session_id: Option<SessionId>,
26}
27
28/// Result returned by `device.connect`.
29pub type DeviceConnectResult = DeviceInfo;
30
31/// Result returned by `device.disconnect`.
32#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
34#[serde(rename_all = "camelCase", deny_unknown_fields)]
35pub struct DeviceDisconnectResult {
36    pub disconnected: bool,
37}
38
39/// Result returned by `device.capabilities`.
40pub type DeviceCapabilitiesResult = Vec<ActionDefinition>;
41
42/// Result returned by `device.observe`.
43pub type DeviceObserveResult = Observation;
44
45/// Result returned by `device.execute`.
46pub type DeviceExecuteResult = ActionResult;
47
48/// Parameters accepted by `ui.snapshot.get`.
49///
50/// The active Session owns the Observation lookup; callers cannot name a
51/// different Session or provide an arbitrary Evidence reference.
52#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
53#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
54#[serde(rename_all = "camelCase", deny_unknown_fields)]
55pub struct UiSnapshotGetParams {
56    pub observation_id: uuid::Uuid,
57}
58
59/// Result returned by `ui.snapshot.get`.
60pub type UiSnapshotGetResult = UiSnapshot;
61
62/// Parameters accepted by `verdict.record`.
63///
64/// DeviceRail persists this caller-supplied Verdict; it does not infer or
65/// upgrade the verdict status.
66#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
67#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
68#[serde(rename_all = "camelCase", deny_unknown_fields)]
69pub struct VerdictRecordParams {
70    pub verdict: Verdict,
71}
72
73/// Result returned by `verdict.record` after the durable event append.
74#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
75#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
76#[serde(rename_all = "camelCase", deny_unknown_fields)]
77pub struct VerdictRecordResult {
78    pub event: TestEvent,
79}
80
81/// Parameters accepted by `media.stream.start`.
82///
83/// The caller chooses only a lifetime-unique stream identifier and the logical
84/// stream kind. The daemon assigns the media type from its selected, leased
85/// device producer; viewport metadata may be absent. This control method never
86/// accepts frame bytes, Evidence references, or filesystem paths.
87#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
88#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
89#[serde(rename_all = "camelCase", deny_unknown_fields)]
90pub struct MediaStreamStartParams {
91    pub stream_id: MediaStreamId,
92    pub kind: MediaStreamKind,
93}
94
95/// Result returned by `media.stream.start`.
96#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
97#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
98#[serde(rename_all = "camelCase", deny_unknown_fields)]
99pub struct MediaStreamStartResult {
100    pub stream: MediaStreamInfo,
101}
102
103/// Parameters accepted by `media.stream.capture`.
104#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
105#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
106#[serde(rename_all = "camelCase", deny_unknown_fields)]
107pub struct MediaStreamCaptureParams {
108    pub stream_id: MediaStreamId,
109    /// Caller-declared one-based frame index. Retrying the same accepted index
110    /// is idempotent; advancing it is the caller's acknowledgement boundary.
111    pub frame_index: EventSequence,
112    #[serde(
113        default,
114        serialize_with = "crate::wire_integer::serialize_optional_js_safe_u64",
115        deserialize_with = "crate::wire_integer::deserialize_optional_js_safe_u64",
116        skip_serializing_if = "Option::is_none"
117    )]
118    #[cfg_attr(
119        feature = "schema",
120        schemars(range(min = 0_u64, max = 9_007_199_254_740_991_u64))
121    )]
122    pub duration_ms: Option<u64>,
123}
124
125/// Result returned by `media.stream.capture`.
126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
127#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
128#[serde(rename_all = "camelCase", deny_unknown_fields)]
129pub struct MediaStreamCaptureResult {
130    pub frame: MediaFrame,
131}
132
133/// Parameters accepted by `media.stream.end`.
134#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
135#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
136#[serde(rename_all = "camelCase", deny_unknown_fields)]
137pub struct MediaStreamEndParams {
138    pub stream_id: MediaStreamId,
139}
140
141/// Result returned by `media.stream.end`.
142#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
143#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
144#[serde(rename_all = "camelCase", deny_unknown_fields)]
145pub struct MediaStreamEndResult {
146    pub stream_id: MediaStreamId,
147    #[serde(
148        serialize_with = "crate::wire_integer::serialize_js_safe_u64",
149        deserialize_with = "crate::wire_integer::deserialize_js_safe_u64"
150    )]
151    #[cfg_attr(
152        feature = "schema",
153        schemars(range(min = 0_u64, max = 9_007_199_254_740_991_u64))
154    )]
155    pub frame_count: u64,
156}
157
158/// Result returned by `session.start`.
159pub type SessionStartResult = SessionInfo;
160
161/// Result returned by `session.current`.
162#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
163#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
164#[serde(rename_all = "camelCase", deny_unknown_fields)]
165pub struct SessionCurrentResult {
166    pub session_id: SessionId,
167}
168
169/// Optional parameters accepted by `session.end`.
170#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
171#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
172#[serde(rename_all = "camelCase", deny_unknown_fields)]
173pub struct SessionEndParams {
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub outcome: Option<SessionOutcome>,
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub reason: Option<String>,
178}
179
180/// Result returned by `session.end`.
181pub type SessionEndResult = SessionInfo;
182
183/// Result returned by `sessions.list`.
184pub type SessionsListResult = Vec<SessionInfo>;
185
186/// Optional session selector accepted by `events.clear`.
187#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
188#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
189#[serde(rename_all = "camelCase", deny_unknown_fields)]
190pub struct SessionTargetParams {
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub session_id: Option<SessionId>,
193}
194
195/// Optional parameters accepted by `session.export`.
196///
197/// Omitting both cursor fields preserves the original complete-export
198/// behavior. Supplying `limit` requests a bounded page after
199/// `afterSequence`; `afterSequence` without `limit` is invalid at dispatch.
200#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
201#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
202#[serde(rename_all = "camelCase", deny_unknown_fields)]
203pub struct SessionExportParams {
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub session_id: Option<SessionId>,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub after_sequence: Option<EventSequence>,
208    #[serde(
209        default,
210        skip_serializing_if = "Option::is_none",
211        serialize_with = "serialize_optional_event_page_limit",
212        deserialize_with = "deserialize_optional_event_page_limit"
213    )]
214    #[cfg_attr(feature = "schema", schemars(range(min = 1_u32, max = 1_000_u32)))]
215    pub limit: Option<u32>,
216}
217
218/// Result returned by `session.export`.
219///
220/// `nextAfterSequence` is absent for a legacy complete export and for the
221/// final page. A paged response includes it only when another page remains.
222#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
223#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
224#[serde(rename_all = "camelCase", deny_unknown_fields)]
225pub struct SessionExportResult {
226    pub session: SessionInfo,
227    pub events: Vec<TestEvent>,
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub next_after_sequence: Option<EventSequence>,
230}
231
232impl From<SessionExport> for SessionExportResult {
233    fn from(export: SessionExport) -> Self {
234        Self {
235            session: export.session,
236            events: export.events,
237            next_after_sequence: None,
238        }
239    }
240}
241
242/// Optional parameters accepted by `events.list`.
243#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
244#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
245#[serde(rename_all = "camelCase", deny_unknown_fields)]
246pub struct EventsListParams {
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub session_id: Option<SessionId>,
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub after_sequence: Option<EventSequence>,
251    #[serde(
252        default,
253        skip_serializing_if = "Option::is_none",
254        serialize_with = "serialize_optional_event_page_limit",
255        deserialize_with = "deserialize_optional_event_page_limit"
256    )]
257    #[cfg_attr(feature = "schema", schemars(range(min = 1_u32, max = 1_000_u32)))]
258    pub limit: Option<u32>,
259}
260
261/// Result returned by `events.list`.
262pub type EventsListResult = Vec<TestEvent>;
263
264fn serialize_optional_event_page_limit<S>(
265    value: &Option<u32>,
266    serializer: S,
267) -> Result<S::Ok, S::Error>
268where
269    S: Serializer,
270{
271    match value {
272        Some(value) if (1..=MAX_EVENTS_LIST_PAGE_SIZE).contains(value) => {
273            serializer.serialize_some(value)
274        }
275        Some(_) => Err(serde::ser::Error::custom(format!(
276            "event page limit must be between 1 and {MAX_EVENTS_LIST_PAGE_SIZE}"
277        ))),
278        None => serializer.serialize_none(),
279    }
280}
281
282fn deserialize_optional_event_page_limit<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
283where
284    D: Deserializer<'de>,
285{
286    let value = Option::<u32>::deserialize(deserializer)?;
287    match value {
288        Some(value) if !(1..=MAX_EVENTS_LIST_PAGE_SIZE).contains(&value) => {
289            Err(serde::de::Error::custom(format!(
290                "event page limit must be between 1 and {MAX_EVENTS_LIST_PAGE_SIZE}"
291            )))
292        }
293        value => Ok(value),
294    }
295}
296
297/// Result returned by `events.clear`.
298#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
299#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
300#[serde(rename_all = "camelCase", deny_unknown_fields)]
301pub struct EventsClearResult {
302    pub deleted: bool,
303    pub session_id: SessionId,
304}
305
306#[cfg(test)]
307mod tests {
308    use serde_json::json;
309    use uuid::Uuid;
310
311    use super::{
312        EventsClearResult, EventsListParams, MAX_EVENTS_LIST_PAGE_SIZE, MediaStreamCaptureParams,
313        MediaStreamEndParams, MediaStreamEndResult, MediaStreamStartParams, SessionEndParams,
314        SessionExportParams, SessionExportResult, SessionTargetParams,
315    };
316    use crate::{EventSequence, MediaStreamId, MediaStreamKind, SessionId, SessionOutcome};
317
318    #[test]
319    fn optional_method_params_are_strict_and_use_camel_case() {
320        let session_id = SessionId::from(Uuid::nil());
321        let end: SessionEndParams = serde_json::from_value(json!({
322            "outcome": "cancelled",
323            "reason": "stopped by client"
324        }))
325        .expect("session.end params");
326        assert_eq!(end.outcome, Some(SessionOutcome::Cancelled));
327        assert_eq!(
328            serde_json::to_value(SessionTargetParams {
329                session_id: Some(session_id.clone()),
330            })
331            .expect("session target params"),
332            json!({ "sessionId": Uuid::nil() })
333        );
334        assert_eq!(
335            serde_json::to_value(SessionExportParams {
336                session_id: Some(session_id.clone()),
337                after_sequence: EventSequence::new(4),
338                limit: Some(25),
339            })
340            .expect("session.export params"),
341            json!({ "sessionId": Uuid::nil(), "afterSequence": 4, "limit": 25 })
342        );
343        assert_eq!(
344            serde_json::to_value(SessionExportParams {
345                session_id: Some(session_id.clone()),
346                after_sequence: None,
347                limit: None,
348            })
349            .expect("legacy session.export params"),
350            json!({ "sessionId": Uuid::nil() })
351        );
352        assert_eq!(
353            serde_json::to_value(EventsListParams {
354                session_id: Some(session_id.clone()),
355                after_sequence: EventSequence::new(4),
356                limit: Some(25),
357            })
358            .expect("events.list params"),
359            json!({ "sessionId": Uuid::nil(), "afterSequence": 4, "limit": 25 })
360        );
361
362        assert!(serde_json::from_value::<SessionEndParams>(json!({ "unexpected": true })).is_err());
363        assert!(
364            serde_json::from_value::<SessionTargetParams>(json!({ "session_id": Uuid::nil() }))
365                .is_err()
366        );
367        assert!(serde_json::from_value::<EventsListParams>(json!({ "afterSequence": 0 })).is_err());
368        assert!(serde_json::from_value::<EventsListParams>(json!({ "limit": 0 })).is_err());
369        assert!(serde_json::from_value::<SessionExportParams>(json!({ "limit": 0 })).is_err());
370        assert!(
371            serde_json::from_value::<EventsListParams>(
372                json!({ "limit": MAX_EVENTS_LIST_PAGE_SIZE + 1 })
373            )
374            .is_err()
375        );
376        assert!(
377            serde_json::from_value::<SessionExportParams>(
378                json!({ "limit": MAX_EVENTS_LIST_PAGE_SIZE + 1 })
379            )
380            .is_err()
381        );
382
383        let stream_id = MediaStreamId::from(Uuid::nil());
384        assert_eq!(
385            serde_json::to_value(MediaStreamStartParams {
386                stream_id: stream_id.clone(),
387                kind: MediaStreamKind::Screenshot,
388            })
389            .expect("media.stream.start params"),
390            json!({ "streamId": Uuid::nil(), "kind": "screenshot" })
391        );
392        assert_eq!(
393            serde_json::to_value(MediaStreamCaptureParams {
394                stream_id: stream_id.clone(),
395                frame_index: EventSequence::FIRST,
396                duration_ms: Some(crate::MAX_SAFE_INTEGER),
397            })
398            .expect("media.stream.capture params"),
399            json!({
400                "streamId": Uuid::nil(),
401                "frameIndex": 1,
402                "durationMs": crate::MAX_SAFE_INTEGER
403            })
404        );
405        assert_eq!(
406            serde_json::to_value(MediaStreamEndParams {
407                stream_id: stream_id.clone(),
408            })
409            .expect("media.stream.end params"),
410            json!({ "streamId": Uuid::nil() })
411        );
412        assert!(
413            serde_json::from_value::<MediaStreamCaptureParams>(json!({
414                "streamId": Uuid::nil(),
415                "frameIndex": 1,
416                "durationMs": crate::MAX_SAFE_INTEGER + 1
417            }))
418            .is_err()
419        );
420        assert!(
421            serde_json::from_value::<MediaStreamStartParams>(json!({
422                "streamId": Uuid::nil(),
423                "kind": "screenshot",
424                "mediaType": "image/png"
425            }))
426            .is_err()
427        );
428    }
429
430    #[test]
431    fn method_results_are_strict_and_use_camel_case() {
432        let result = EventsClearResult {
433            deleted: true,
434            session_id: SessionId::from(Uuid::nil()),
435        };
436        assert_eq!(
437            serde_json::to_value(result).expect("events.clear result"),
438            json!({ "deleted": true, "sessionId": Uuid::nil() })
439        );
440        assert!(
441            serde_json::from_value::<EventsClearResult>(json!({
442                "deleted": true,
443                "sessionId": Uuid::nil(),
444                "unexpected": true
445            }))
446            .is_err()
447        );
448
449        let export = SessionExportResult {
450            session: crate::SessionInfo {
451                id: SessionId::from(Uuid::nil()),
452                state: crate::SessionState::Ended,
453                started_at_ms: 1,
454                ended_at_ms: Some(2),
455                event_count: EventSequence::new(2).expect("event count"),
456                last_sequence: EventSequence::new(2).expect("last sequence"),
457            },
458            events: Vec::new(),
459            next_after_sequence: None,
460        };
461        let value = serde_json::to_value(export).expect("legacy session.export result");
462        let object = value.as_object().expect("export object");
463        assert_eq!(object.len(), 2);
464        assert!(object.contains_key("session"));
465        assert!(object.contains_key("events"));
466        assert!(!object.contains_key("nextAfterSequence"));
467
468        let stream_id = MediaStreamId::from(Uuid::nil());
469        assert_eq!(
470            serde_json::to_value(MediaStreamEndResult {
471                stream_id: stream_id.clone(),
472                frame_count: 0,
473            })
474            .expect("zero-frame media.stream.end result"),
475            json!({ "streamId": Uuid::nil(), "frameCount": 0 })
476        );
477        assert!(
478            serde_json::to_value(MediaStreamEndResult {
479                stream_id,
480                frame_count: crate::MAX_SAFE_INTEGER + 1,
481            })
482            .is_err()
483        );
484    }
485}