Skip to main content

shelly_test/
lib.rs

1//! Test helpers for Shelly LiveView apps.
2//!
3//! This crate provides:
4//! - small constructors for protocol events
5//! - assertion helpers for common `ServerMessage` shapes
6//! - macros to keep tests concise
7
8pub use serde_json;
9pub use shelly;
10
11use shelly::{ClientMessage, DynamicSlotPatch, LiveSession, ServerMessage, StreamPosition};
12
13/// Deterministic server-transcript fault used by the chaos harness.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum ChaosFault {
16    DropEvery { every: usize },
17    DuplicateEvery { every: usize },
18    ReorderAdjacent { first_index: usize },
19    CorruptFirstPatchTarget,
20}
21
22/// Deterministic fault scenario for server transcript verification.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ChaosScenario {
25    pub id: String,
26    pub faults: Vec<ChaosFault>,
27}
28
29impl ChaosScenario {
30    pub fn new(id: impl Into<String>, faults: Vec<ChaosFault>) -> Self {
31        Self {
32            id: id.into(),
33            faults,
34        }
35    }
36}
37
38/// Result of applying a deterministic chaos scenario to a server transcript.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct ChaosTranscriptReport {
41    pub scenario_id: String,
42    pub input_messages: usize,
43    pub output_messages: usize,
44    pub dropped_frames: usize,
45    pub duplicated_frames: usize,
46    pub reordered_frames: usize,
47    pub corrupted_frames: usize,
48    pub invariant_ok: bool,
49    pub violation_code: Option<String>,
50}
51
52/// Apply deterministic transcript faults and validate server message invariants.
53pub fn run_chaos_transcript(
54    scenario: &ChaosScenario,
55    transcript: &[ServerMessage],
56) -> (Vec<ServerMessage>, ChaosTranscriptReport) {
57    let mut output = transcript.to_vec();
58    let mut dropped_frames = 0usize;
59    let mut duplicated_frames = 0usize;
60    let mut reordered_frames = 0usize;
61    let mut corrupted_frames = 0usize;
62
63    for fault in &scenario.faults {
64        match *fault {
65            ChaosFault::DropEvery { every } => {
66                let every = every.max(1);
67                let before = output.len();
68                output = output
69                    .into_iter()
70                    .enumerate()
71                    .filter_map(|(idx, message)| {
72                        if (idx + 1) % every == 0 {
73                            None
74                        } else {
75                            Some(message)
76                        }
77                    })
78                    .collect();
79                dropped_frames = dropped_frames.saturating_add(before.saturating_sub(output.len()));
80            }
81            ChaosFault::DuplicateEvery { every } => {
82                let every = every.max(1);
83                let mut duplicated = Vec::with_capacity(output.len().saturating_mul(2));
84                for (idx, message) in output.into_iter().enumerate() {
85                    duplicated.push(message.clone());
86                    if (idx + 1) % every == 0 {
87                        duplicated.push(message);
88                        duplicated_frames = duplicated_frames.saturating_add(1);
89                    }
90                }
91                output = duplicated;
92            }
93            ChaosFault::ReorderAdjacent { first_index } => {
94                if first_index + 1 < output.len() {
95                    output.swap(first_index, first_index + 1);
96                    reordered_frames = reordered_frames.saturating_add(1);
97                }
98            }
99            ChaosFault::CorruptFirstPatchTarget => {
100                if let Some(
101                    ServerMessage::Patch { target, .. } | ServerMessage::Diff { target, .. },
102                ) = output.iter_mut().find(|message| {
103                    matches!(
104                        message,
105                        ServerMessage::Patch { .. } | ServerMessage::Diff { .. }
106                    )
107                }) {
108                    target.clear();
109                    corrupted_frames = corrupted_frames.saturating_add(1);
110                }
111            }
112        }
113    }
114
115    let invariant_result = shelly::validate_server_message_sequence(&output);
116    let (invariant_ok, violation_code) = match invariant_result {
117        Ok(()) => (true, None),
118        Err(violation) => (false, Some(violation.code)),
119    };
120
121    let report = ChaosTranscriptReport {
122        scenario_id: scenario.id.clone(),
123        input_messages: transcript.len(),
124        output_messages: output.len(),
125        dropped_frames,
126        duplicated_frames,
127        reordered_frames,
128        corrupted_frames,
129        invariant_ok,
130        violation_code,
131    };
132    (output, report)
133}
134
135/// Build a `ClientMessage::Event` with explicit fields.
136pub fn client_event(
137    name: impl Into<String>,
138    target: Option<String>,
139    value: serde_json::Value,
140    metadata: serde_json::Map<String, serde_json::Value>,
141) -> ClientMessage {
142    ClientMessage::Event {
143        event: name.into(),
144        target,
145        value,
146        metadata,
147    }
148}
149
150/// Dispatch one client message into a live session.
151pub fn dispatch(session: &mut LiveSession, message: ClientMessage) -> Vec<ServerMessage> {
152    session.handle_client_message(message)
153}
154
155/// Expect exactly one `patch` message.
156pub fn expect_single_patch(messages: &[ServerMessage]) -> (&str, &str, u64) {
157    match messages {
158        [ServerMessage::Patch {
159            target,
160            html,
161            revision,
162        }] => (target.as_str(), html.as_str(), *revision),
163        _ => panic!("expected exactly one patch message, got: {messages:?}"),
164    }
165}
166
167/// Expect exactly one `diff` message.
168pub fn expect_single_diff(messages: &[ServerMessage]) -> (&str, u64, &[DynamicSlotPatch]) {
169    match messages {
170        [ServerMessage::Diff {
171            target,
172            revision,
173            slots,
174        }] => (target.as_str(), *revision, slots.as_slice()),
175        _ => panic!("expected exactly one diff message, got: {messages:?}"),
176    }
177}
178
179/// Expect exactly one `stream_insert` message.
180pub fn expect_single_stream_insert(
181    messages: &[ServerMessage],
182) -> (&str, &str, &str, &StreamPosition) {
183    match messages {
184        [ServerMessage::StreamInsert {
185            target,
186            id,
187            html,
188            at,
189        }] => (target.as_str(), id.as_str(), html.as_str(), at),
190        _ => panic!("expected exactly one stream_insert message, got: {messages:?}"),
191    }
192}
193
194/// Expect exactly one `stream_delete` message.
195pub fn expect_single_stream_delete(messages: &[ServerMessage]) -> (&str, &str) {
196    match messages {
197        [ServerMessage::StreamDelete { target, id }] => (target.as_str(), id.as_str()),
198        _ => panic!("expected exactly one stream_delete message, got: {messages:?}"),
199    }
200}
201
202/// Expect exactly one `error` message.
203pub fn expect_single_error(messages: &[ServerMessage]) -> (&str, Option<&str>) {
204    match messages {
205        [ServerMessage::Error { message, code }] => (message.as_str(), code.as_deref()),
206        _ => panic!("expected exactly one error message, got: {messages:?}"),
207    }
208}
209
210/// Build a `ClientMessage::Event`.
211#[macro_export]
212macro_rules! event {
213    ($name:expr $(,)?) => {
214        $crate::client_event(
215            $name,
216            None,
217            $crate::serde_json::Value::Null,
218            $crate::serde_json::Map::new(),
219        )
220    };
221    ($name:expr, value = $value:expr $(,)?) => {
222        $crate::client_event($name, None, $value, $crate::serde_json::Map::new())
223    };
224    ($name:expr, target = $target:expr $(,)?) => {
225        $crate::client_event(
226            $name,
227            Some(($target).to_string()),
228            $crate::serde_json::Value::Null,
229            $crate::serde_json::Map::new(),
230        )
231    };
232    ($name:expr, target = $target:expr, value = $value:expr $(,)?) => {
233        $crate::client_event(
234            $name,
235            Some(($target).to_string()),
236            $value,
237            $crate::serde_json::Map::new(),
238        )
239    };
240    ($name:expr, value = $value:expr, target = $target:expr $(,)?) => {
241        $crate::client_event(
242            $name,
243            Some(($target).to_string()),
244            $value,
245            $crate::serde_json::Map::new(),
246        )
247    };
248    ($name:expr, target = $target:expr, value = $value:expr, metadata = $metadata:expr $(,)?) => {
249        $crate::client_event($name, Some(($target).to_string()), $value, $metadata)
250    };
251    ($name:expr, value = $value:expr, target = $target:expr, metadata = $metadata:expr $(,)?) => {
252        $crate::client_event($name, Some(($target).to_string()), $value, $metadata)
253    };
254}
255
256/// Mount a fresh live session from a `Default` live view.
257#[macro_export]
258macro_rules! mount_session {
259    ($view_ty:ty $(,)?) => {{
260        let mut session = $crate::shelly::LiveSession::new(Box::<$view_ty>::default(), "root");
261        session
262            .mount()
263            .expect("mount_session! should mount live view");
264        session
265    }};
266    ($view_ty:ty, target = $target:expr $(,)?) => {{
267        let mut session = $crate::shelly::LiveSession::new(Box::<$view_ty>::default(), $target);
268        session
269            .mount()
270            .expect("mount_session! should mount live view");
271        session
272    }};
273}
274
275/// Dispatch one message into the session.
276#[macro_export]
277macro_rules! dispatch {
278    ($session:expr, $message:expr $(,)?) => {
279        $crate::dispatch(&mut $session, $message)
280    };
281}
282
283/// Assert one patch message with exact target/revision.
284#[macro_export]
285macro_rules! assert_patch {
286    ($messages:expr, target = $target:expr, revision = $revision:expr $(,)?) => {{
287        let (actual_target, _actual_html, actual_revision) =
288            $crate::expect_single_patch(&($messages));
289        assert_eq!(actual_target, $target, "unexpected patch target");
290        assert_eq!(actual_revision, $revision, "unexpected patch revision");
291    }};
292    ($messages:expr, target = $target:expr, revision = $revision:expr, html = $html:expr $(,)?) => {{
293        let (actual_target, actual_html, actual_revision) =
294            $crate::expect_single_patch(&($messages));
295        assert_eq!(actual_target, $target, "unexpected patch target");
296        assert_eq!(actual_revision, $revision, "unexpected patch revision");
297        assert_eq!(actual_html, $html, "unexpected patch html");
298    }};
299    ($messages:expr, target = $target:expr, revision = $revision:expr, html_contains = $needle:expr $(,)?) => {{
300        let (actual_target, actual_html, actual_revision) =
301            $crate::expect_single_patch(&($messages));
302        assert_eq!(actual_target, $target, "unexpected patch target");
303        assert_eq!(actual_revision, $revision, "unexpected patch revision");
304        assert!(
305            actual_html.contains($needle),
306            "expected patch html to contain `{}`, actual html: {}",
307            $needle,
308            actual_html
309        );
310    }};
311}
312
313/// Assert one diff message.
314#[macro_export]
315macro_rules! assert_diff {
316    ($messages:expr, target = $target:expr, revision = $revision:expr, slots_len = $slots_len:expr $(,)?) => {{
317        let (actual_target, actual_revision, actual_slots) =
318            $crate::expect_single_diff(&($messages));
319        assert_eq!(actual_target, $target, "unexpected diff target");
320        assert_eq!(actual_revision, $revision, "unexpected diff revision");
321        assert_eq!(actual_slots.len(), $slots_len, "unexpected diff slot count");
322    }};
323}
324
325/// Assert one stream-insert message.
326#[macro_export]
327macro_rules! assert_stream_insert {
328    ($messages:expr, target = $target:expr, id = $id:expr $(,)?) => {{
329        let (actual_target, actual_id, _actual_html, _actual_at) =
330            $crate::expect_single_stream_insert(&($messages));
331        assert_eq!(actual_target, $target, "unexpected stream target");
332        assert_eq!(actual_id, $id, "unexpected stream id");
333    }};
334}
335
336/// Assert one stream-delete message.
337#[macro_export]
338macro_rules! assert_stream_delete {
339    ($messages:expr, target = $target:expr, id = $id:expr $(,)?) => {{
340        let (actual_target, actual_id) = $crate::expect_single_stream_delete(&($messages));
341        assert_eq!(actual_target, $target, "unexpected stream target");
342        assert_eq!(actual_id, $id, "unexpected stream id");
343    }};
344}
345
346/// Assert one error code.
347#[macro_export]
348macro_rules! assert_error_code {
349    ($messages:expr, $code:expr $(,)?) => {{
350        let (_actual_message, actual_code) = $crate::expect_single_error(&($messages));
351        assert_eq!(actual_code, Some($code), "unexpected error code");
352    }};
353    ($messages:expr, none $(,)?) => {{
354        let (_actual_message, actual_code) = $crate::expect_single_error(&($messages));
355        assert_eq!(actual_code, None, "expected no error code");
356    }};
357}
358
359#[cfg(test)]
360mod tests {
361    use super::{run_chaos_transcript, ChaosFault, ChaosScenario};
362    use shelly::{ResumeStatus, ServerMessage};
363
364    fn transcript() -> Vec<ServerMessage> {
365        vec![
366            ServerMessage::Hello {
367                session_id: "sid".to_string(),
368                target: "root".to_string(),
369                revision: 0,
370                protocol: shelly::PROTOCOL_VERSION_V1.to_string(),
371                server_revision: Some(0),
372                resume_status: Some(ResumeStatus::Fresh),
373                resume_reason: None,
374                resume_token: Some("resume".to_string()),
375                resume_expires_in_ms: Some(60_000),
376            },
377            ServerMessage::Patch {
378                target: "root".to_string(),
379                html: "<p>1</p>".to_string(),
380                revision: 1,
381            },
382            ServerMessage::Patch {
383                target: "root".to_string(),
384                html: "<p>2</p>".to_string(),
385                revision: 2,
386            },
387        ]
388    }
389
390    #[test]
391    fn chaos_transcript_drop_preserves_invariants_when_sequence_remains_valid() {
392        let scenario = ChaosScenario::new("drop-last", vec![ChaosFault::DropEvery { every: 3 }]);
393        let (_messages, report) = run_chaos_transcript(&scenario, &transcript());
394
395        assert_eq!(report.dropped_frames, 1);
396        assert!(report.invariant_ok);
397        assert_eq!(report.violation_code, None);
398    }
399
400    #[test]
401    fn chaos_transcript_duplicate_detects_revision_regression() {
402        let scenario = ChaosScenario::new(
403            "duplicate-patch",
404            vec![ChaosFault::DuplicateEvery { every: 2 }],
405        );
406        let (_messages, report) = run_chaos_transcript(&scenario, &transcript());
407
408        assert_eq!(report.duplicated_frames, 1);
409        assert!(!report.invariant_ok);
410        assert_eq!(
411            report.violation_code.as_deref(),
412            Some("non_monotonic_revision")
413        );
414    }
415
416    #[test]
417    fn chaos_transcript_corrupt_detects_invalid_message_shape() {
418        let scenario =
419            ChaosScenario::new("corrupt-target", vec![ChaosFault::CorruptFirstPatchTarget]);
420        let (_messages, report) = run_chaos_transcript(&scenario, &transcript());
421
422        assert_eq!(report.corrupted_frames, 1);
423        assert!(!report.invariant_ok);
424        assert_eq!(report.violation_code.as_deref(), Some("empty_field"));
425    }
426}