pub use serde_json;
pub use shelly;
use shelly::{ClientMessage, DynamicSlotPatch, LiveSession, ServerMessage, StreamPosition};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChaosFault {
DropEvery { every: usize },
DuplicateEvery { every: usize },
ReorderAdjacent { first_index: usize },
CorruptFirstPatchTarget,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChaosScenario {
pub id: String,
pub faults: Vec<ChaosFault>,
}
impl ChaosScenario {
pub fn new(id: impl Into<String>, faults: Vec<ChaosFault>) -> Self {
Self {
id: id.into(),
faults,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChaosTranscriptReport {
pub scenario_id: String,
pub input_messages: usize,
pub output_messages: usize,
pub dropped_frames: usize,
pub duplicated_frames: usize,
pub reordered_frames: usize,
pub corrupted_frames: usize,
pub invariant_ok: bool,
pub violation_code: Option<String>,
}
pub fn run_chaos_transcript(
scenario: &ChaosScenario,
transcript: &[ServerMessage],
) -> (Vec<ServerMessage>, ChaosTranscriptReport) {
let mut output = transcript.to_vec();
let mut dropped_frames = 0usize;
let mut duplicated_frames = 0usize;
let mut reordered_frames = 0usize;
let mut corrupted_frames = 0usize;
for fault in &scenario.faults {
match *fault {
ChaosFault::DropEvery { every } => {
let every = every.max(1);
let before = output.len();
output = output
.into_iter()
.enumerate()
.filter_map(|(idx, message)| {
if (idx + 1) % every == 0 {
None
} else {
Some(message)
}
})
.collect();
dropped_frames = dropped_frames.saturating_add(before.saturating_sub(output.len()));
}
ChaosFault::DuplicateEvery { every } => {
let every = every.max(1);
let mut duplicated = Vec::with_capacity(output.len().saturating_mul(2));
for (idx, message) in output.into_iter().enumerate() {
duplicated.push(message.clone());
if (idx + 1) % every == 0 {
duplicated.push(message);
duplicated_frames = duplicated_frames.saturating_add(1);
}
}
output = duplicated;
}
ChaosFault::ReorderAdjacent { first_index } => {
if first_index + 1 < output.len() {
output.swap(first_index, first_index + 1);
reordered_frames = reordered_frames.saturating_add(1);
}
}
ChaosFault::CorruptFirstPatchTarget => {
if let Some(
ServerMessage::Patch { target, .. } | ServerMessage::Diff { target, .. },
) = output.iter_mut().find(|message| {
matches!(
message,
ServerMessage::Patch { .. } | ServerMessage::Diff { .. }
)
}) {
target.clear();
corrupted_frames = corrupted_frames.saturating_add(1);
}
}
}
}
let invariant_result = shelly::validate_server_message_sequence(&output);
let (invariant_ok, violation_code) = match invariant_result {
Ok(()) => (true, None),
Err(violation) => (false, Some(violation.code)),
};
let report = ChaosTranscriptReport {
scenario_id: scenario.id.clone(),
input_messages: transcript.len(),
output_messages: output.len(),
dropped_frames,
duplicated_frames,
reordered_frames,
corrupted_frames,
invariant_ok,
violation_code,
};
(output, report)
}
pub fn client_event(
name: impl Into<String>,
target: Option<String>,
value: serde_json::Value,
metadata: serde_json::Map<String, serde_json::Value>,
) -> ClientMessage {
ClientMessage::Event {
event: name.into(),
target,
value,
metadata,
}
}
pub fn dispatch(session: &mut LiveSession, message: ClientMessage) -> Vec<ServerMessage> {
session.handle_client_message(message)
}
pub fn expect_single_patch(messages: &[ServerMessage]) -> (&str, &str, u64) {
match messages {
[ServerMessage::Patch {
target,
html,
revision,
}] => (target.as_str(), html.as_str(), *revision),
_ => panic!("expected exactly one patch message, got: {messages:?}"),
}
}
pub fn expect_single_diff(messages: &[ServerMessage]) -> (&str, u64, &[DynamicSlotPatch]) {
match messages {
[ServerMessage::Diff {
target,
revision,
slots,
}] => (target.as_str(), *revision, slots.as_slice()),
_ => panic!("expected exactly one diff message, got: {messages:?}"),
}
}
pub fn expect_single_stream_insert(
messages: &[ServerMessage],
) -> (&str, &str, &str, &StreamPosition) {
match messages {
[ServerMessage::StreamInsert {
target,
id,
html,
at,
}] => (target.as_str(), id.as_str(), html.as_str(), at),
_ => panic!("expected exactly one stream_insert message, got: {messages:?}"),
}
}
pub fn expect_single_stream_delete(messages: &[ServerMessage]) -> (&str, &str) {
match messages {
[ServerMessage::StreamDelete { target, id }] => (target.as_str(), id.as_str()),
_ => panic!("expected exactly one stream_delete message, got: {messages:?}"),
}
}
pub fn expect_single_error(messages: &[ServerMessage]) -> (&str, Option<&str>) {
match messages {
[ServerMessage::Error { message, code }] => (message.as_str(), code.as_deref()),
_ => panic!("expected exactly one error message, got: {messages:?}"),
}
}
#[macro_export]
macro_rules! event {
($name:expr $(,)?) => {
$crate::client_event(
$name,
None,
$crate::serde_json::Value::Null,
$crate::serde_json::Map::new(),
)
};
($name:expr, value = $value:expr $(,)?) => {
$crate::client_event($name, None, $value, $crate::serde_json::Map::new())
};
($name:expr, target = $target:expr $(,)?) => {
$crate::client_event(
$name,
Some(($target).to_string()),
$crate::serde_json::Value::Null,
$crate::serde_json::Map::new(),
)
};
($name:expr, target = $target:expr, value = $value:expr $(,)?) => {
$crate::client_event(
$name,
Some(($target).to_string()),
$value,
$crate::serde_json::Map::new(),
)
};
($name:expr, value = $value:expr, target = $target:expr $(,)?) => {
$crate::client_event(
$name,
Some(($target).to_string()),
$value,
$crate::serde_json::Map::new(),
)
};
($name:expr, target = $target:expr, value = $value:expr, metadata = $metadata:expr $(,)?) => {
$crate::client_event($name, Some(($target).to_string()), $value, $metadata)
};
($name:expr, value = $value:expr, target = $target:expr, metadata = $metadata:expr $(,)?) => {
$crate::client_event($name, Some(($target).to_string()), $value, $metadata)
};
}
#[macro_export]
macro_rules! mount_session {
($view_ty:ty $(,)?) => {{
let mut session = $crate::shelly::LiveSession::new(Box::<$view_ty>::default(), "root");
session
.mount()
.expect("mount_session! should mount live view");
session
}};
($view_ty:ty, target = $target:expr $(,)?) => {{
let mut session = $crate::shelly::LiveSession::new(Box::<$view_ty>::default(), $target);
session
.mount()
.expect("mount_session! should mount live view");
session
}};
}
#[macro_export]
macro_rules! dispatch {
($session:expr, $message:expr $(,)?) => {
$crate::dispatch(&mut $session, $message)
};
}
#[macro_export]
macro_rules! assert_patch {
($messages:expr, target = $target:expr, revision = $revision:expr $(,)?) => {{
let (actual_target, _actual_html, actual_revision) =
$crate::expect_single_patch(&($messages));
assert_eq!(actual_target, $target, "unexpected patch target");
assert_eq!(actual_revision, $revision, "unexpected patch revision");
}};
($messages:expr, target = $target:expr, revision = $revision:expr, html = $html:expr $(,)?) => {{
let (actual_target, actual_html, actual_revision) =
$crate::expect_single_patch(&($messages));
assert_eq!(actual_target, $target, "unexpected patch target");
assert_eq!(actual_revision, $revision, "unexpected patch revision");
assert_eq!(actual_html, $html, "unexpected patch html");
}};
($messages:expr, target = $target:expr, revision = $revision:expr, html_contains = $needle:expr $(,)?) => {{
let (actual_target, actual_html, actual_revision) =
$crate::expect_single_patch(&($messages));
assert_eq!(actual_target, $target, "unexpected patch target");
assert_eq!(actual_revision, $revision, "unexpected patch revision");
assert!(
actual_html.contains($needle),
"expected patch html to contain `{}`, actual html: {}",
$needle,
actual_html
);
}};
}
#[macro_export]
macro_rules! assert_diff {
($messages:expr, target = $target:expr, revision = $revision:expr, slots_len = $slots_len:expr $(,)?) => {{
let (actual_target, actual_revision, actual_slots) =
$crate::expect_single_diff(&($messages));
assert_eq!(actual_target, $target, "unexpected diff target");
assert_eq!(actual_revision, $revision, "unexpected diff revision");
assert_eq!(actual_slots.len(), $slots_len, "unexpected diff slot count");
}};
}
#[macro_export]
macro_rules! assert_stream_insert {
($messages:expr, target = $target:expr, id = $id:expr $(,)?) => {{
let (actual_target, actual_id, _actual_html, _actual_at) =
$crate::expect_single_stream_insert(&($messages));
assert_eq!(actual_target, $target, "unexpected stream target");
assert_eq!(actual_id, $id, "unexpected stream id");
}};
}
#[macro_export]
macro_rules! assert_stream_delete {
($messages:expr, target = $target:expr, id = $id:expr $(,)?) => {{
let (actual_target, actual_id) = $crate::expect_single_stream_delete(&($messages));
assert_eq!(actual_target, $target, "unexpected stream target");
assert_eq!(actual_id, $id, "unexpected stream id");
}};
}
#[macro_export]
macro_rules! assert_error_code {
($messages:expr, $code:expr $(,)?) => {{
let (_actual_message, actual_code) = $crate::expect_single_error(&($messages));
assert_eq!(actual_code, Some($code), "unexpected error code");
}};
($messages:expr, none $(,)?) => {{
let (_actual_message, actual_code) = $crate::expect_single_error(&($messages));
assert_eq!(actual_code, None, "expected no error code");
}};
}
#[cfg(test)]
mod tests {
use super::{run_chaos_transcript, ChaosFault, ChaosScenario};
use shelly::{ResumeStatus, ServerMessage};
fn transcript() -> Vec<ServerMessage> {
vec![
ServerMessage::Hello {
session_id: "sid".to_string(),
target: "root".to_string(),
revision: 0,
protocol: shelly::PROTOCOL_VERSION_V1.to_string(),
server_revision: Some(0),
resume_status: Some(ResumeStatus::Fresh),
resume_reason: None,
resume_token: Some("resume".to_string()),
resume_expires_in_ms: Some(60_000),
},
ServerMessage::Patch {
target: "root".to_string(),
html: "<p>1</p>".to_string(),
revision: 1,
},
ServerMessage::Patch {
target: "root".to_string(),
html: "<p>2</p>".to_string(),
revision: 2,
},
]
}
#[test]
fn chaos_transcript_drop_preserves_invariants_when_sequence_remains_valid() {
let scenario = ChaosScenario::new("drop-last", vec![ChaosFault::DropEvery { every: 3 }]);
let (_messages, report) = run_chaos_transcript(&scenario, &transcript());
assert_eq!(report.dropped_frames, 1);
assert!(report.invariant_ok);
assert_eq!(report.violation_code, None);
}
#[test]
fn chaos_transcript_duplicate_detects_revision_regression() {
let scenario = ChaosScenario::new(
"duplicate-patch",
vec![ChaosFault::DuplicateEvery { every: 2 }],
);
let (_messages, report) = run_chaos_transcript(&scenario, &transcript());
assert_eq!(report.duplicated_frames, 1);
assert!(!report.invariant_ok);
assert_eq!(
report.violation_code.as_deref(),
Some("non_monotonic_revision")
);
}
#[test]
fn chaos_transcript_corrupt_detects_invalid_message_shape() {
let scenario =
ChaosScenario::new("corrupt-target", vec![ChaosFault::CorruptFirstPatchTarget]);
let (_messages, report) = run_chaos_transcript(&scenario, &transcript());
assert_eq!(report.corrupted_frames, 1);
assert!(!report.invariant_ok);
assert_eq!(report.violation_code.as_deref(), Some("empty_field"));
}
}