1pub use serde_json;
9pub use shelly;
10
11use shelly::{ClientMessage, DynamicSlotPatch, LiveSession, ServerMessage, StreamPosition};
12
13#[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#[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#[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
52pub 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
135pub 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
150pub fn dispatch(session: &mut LiveSession, message: ClientMessage) -> Vec<ServerMessage> {
152 session.handle_client_message(message)
153}
154
155pub 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
167pub 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
179pub 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
194pub 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
202pub 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#[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#[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#[macro_export]
277macro_rules! dispatch {
278 ($session:expr, $message:expr $(,)?) => {
279 $crate::dispatch(&mut $session, $message)
280 };
281}
282
283#[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#[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#[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#[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#[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}