Skip to main content

shelly/
replay.rs

1use crate::{ClientMessage, LiveSession, LiveView, ServerMessage, ShellyError};
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::{BTreeMap, BTreeSet};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7pub const REPLAY_TRACE_FORMAT_VERSION: &str = "shelly-replay-trace/v1";
8
9/// Capture metadata describing one replayable session trace.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct SessionReplayMetadata {
12    pub protocol: String,
13    pub session_id: String,
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub tenant_id: Option<String>,
16    pub target_id: String,
17    pub route_path: String,
18    #[serde(default)]
19    pub route_params: BTreeMap<String, String>,
20}
21
22/// One deterministic turn in a replay trace.
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24pub struct SessionReplayTraceStep {
25    pub sequence: u64,
26    pub recorded_at_unix_ms: u64,
27    pub revision_before: u64,
28    pub revision_after: u64,
29    pub client_message: ClientMessage,
30    pub server_messages: Vec<ServerMessage>,
31}
32
33/// Capture-time redaction summary embedded into trace artifacts.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct TraceRedactionSummary {
36    pub redact_server_html: bool,
37    pub redacted_text: String,
38    pub keys: Vec<String>,
39}
40
41/// Serializable replay artifact.
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43pub struct SessionReplayTrace {
44    pub format_version: String,
45    pub captured_at_unix_ms: u64,
46    pub metadata: SessionReplayMetadata,
47    pub redaction: TraceRedactionSummary,
48    pub steps: Vec<SessionReplayTraceStep>,
49}
50
51impl SessionReplayTrace {
52    pub fn from_json(raw: &str) -> Result<Self, serde_json::Error> {
53        serde_json::from_str(raw)
54    }
55
56    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
57        serde_json::to_string_pretty(self)
58    }
59}
60
61/// Redaction policy for trace capture and report sharing.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct TraceRedactionPolicy {
64    keys: BTreeSet<String>,
65    redacted_text: String,
66    redact_server_html: bool,
67}
68
69impl Default for TraceRedactionPolicy {
70    fn default() -> Self {
71        Self::developer_default()
72    }
73}
74
75impl TraceRedactionPolicy {
76    pub fn none() -> Self {
77        Self {
78            keys: BTreeSet::new(),
79            redacted_text: "<redacted>".to_string(),
80            redact_server_html: false,
81        }
82    }
83
84    pub fn developer_default() -> Self {
85        Self {
86            keys: default_sensitive_keys()
87                .into_iter()
88                .map(normalize_key)
89                .collect(),
90            redacted_text: "<redacted>".to_string(),
91            redact_server_html: false,
92        }
93    }
94
95    pub fn production_safe() -> Self {
96        let mut keys = default_sensitive_keys()
97            .into_iter()
98            .map(normalize_key)
99            .collect::<BTreeSet<_>>();
100        for extra in ["email", "phone", "ssn", "data", "html"] {
101            keys.insert(normalize_key(extra));
102        }
103        Self {
104            keys,
105            redacted_text: "<redacted>".to_string(),
106            redact_server_html: true,
107        }
108    }
109
110    pub fn with_key(mut self, key: impl Into<String>) -> Self {
111        self.keys.insert(normalize_key(key.into()));
112        self
113    }
114
115    pub fn with_redacted_text(mut self, value: impl Into<String>) -> Self {
116        self.redacted_text = value.into();
117        self
118    }
119
120    pub fn with_redact_server_html(mut self, enabled: bool) -> Self {
121        self.redact_server_html = enabled;
122        self
123    }
124
125    fn summary(&self) -> TraceRedactionSummary {
126        TraceRedactionSummary {
127            redact_server_html: self.redact_server_html,
128            redacted_text: self.redacted_text.clone(),
129            keys: self.keys.iter().cloned().collect(),
130        }
131    }
132
133    fn from_summary(summary: &TraceRedactionSummary) -> Self {
134        Self {
135            keys: summary
136                .keys
137                .iter()
138                .map(normalize_key)
139                .collect::<BTreeSet<_>>(),
140            redacted_text: summary.redacted_text.clone(),
141            redact_server_html: summary.redact_server_html,
142        }
143    }
144
145    fn should_redact(&self, key: &str) -> bool {
146        self.keys.contains(&normalize_key(key))
147    }
148
149    fn redact_option_string(&self, key: &str, value: &mut Option<String>) {
150        if self.should_redact(key) && value.is_some() {
151            *value = Some(self.redacted_text.clone());
152        }
153    }
154
155    fn redact_string(&self, key: &str, value: &mut String) {
156        if self.should_redact(key) {
157            *value = self.redacted_text.clone();
158        }
159    }
160
161    fn redact_json_value(&self, key: Option<&str>, value: &mut Value) {
162        if let Some(current_key) = key {
163            if self.should_redact(current_key) {
164                *value = Value::String(self.redacted_text.clone());
165                return;
166            }
167        }
168        match value {
169            Value::Object(map) => {
170                for (nested_key, nested_value) in map {
171                    self.redact_json_value(Some(nested_key.as_str()), nested_value);
172                }
173            }
174            Value::Array(items) => {
175                for item in items {
176                    self.redact_json_value(None, item);
177                }
178            }
179            _ => {}
180        }
181    }
182
183    fn redact_client_message(&self, mut message: ClientMessage) -> ClientMessage {
184        match &mut message {
185            ClientMessage::Connect {
186                resume_token,
187                trace_id,
188                span_id,
189                parent_span_id,
190                correlation_id,
191                request_id,
192                ..
193            } => {
194                self.redact_option_string("resume_token", resume_token);
195                self.redact_option_string("trace_id", trace_id);
196                self.redact_option_string("span_id", span_id);
197                self.redact_option_string("parent_span_id", parent_span_id);
198                self.redact_option_string("correlation_id", correlation_id);
199                self.redact_option_string("request_id", request_id);
200            }
201            ClientMessage::Event {
202                value, metadata, ..
203            } => {
204                self.redact_json_value(None, value);
205                for (key, value) in metadata {
206                    self.redact_json_value(Some(key.as_str()), value);
207                }
208            }
209            ClientMessage::UploadStart {
210                upload_id,
211                name,
212                content_type,
213                ..
214            } => {
215                self.redact_string("upload_id", upload_id);
216                self.redact_string("name", name);
217                self.redact_option_string("content_type", content_type);
218            }
219            ClientMessage::UploadChunk {
220                upload_id, data, ..
221            } => {
222                self.redact_string("upload_id", upload_id);
223                self.redact_string("data", data);
224            }
225            ClientMessage::UploadComplete { upload_id } => {
226                self.redact_string("upload_id", upload_id);
227            }
228            ClientMessage::Ping { .. }
229            | ClientMessage::PatchUrl { .. }
230            | ClientMessage::Navigate { .. } => {}
231        }
232        message
233    }
234
235    fn redact_server_message(&self, mut message: ServerMessage) -> ServerMessage {
236        match &mut message {
237            ServerMessage::Hello {
238                resume_token,
239                session_id,
240                ..
241            } => {
242                self.redact_option_string("resume_token", resume_token);
243                self.redact_string("session_id", session_id);
244            }
245            ServerMessage::Patch { html, .. } => {
246                if self.redact_server_html || self.should_redact("html") {
247                    *html = self.redacted_text.clone();
248                }
249            }
250            ServerMessage::Diff { slots, .. } => {
251                if self.redact_server_html || self.should_redact("html") {
252                    for slot in slots {
253                        slot.html = self.redacted_text.clone();
254                    }
255                }
256            }
257            ServerMessage::StreamInsert { html, .. } => {
258                if self.redact_server_html || self.should_redact("html") {
259                    *html = self.redacted_text.clone();
260                }
261            }
262            ServerMessage::UploadComplete {
263                upload_id,
264                name,
265                content_type,
266                ..
267            } => {
268                self.redact_string("upload_id", upload_id);
269                self.redact_string("name", name);
270                self.redact_option_string("content_type", content_type);
271            }
272            ServerMessage::UploadError {
273                upload_id, message, ..
274            } => {
275                self.redact_string("upload_id", upload_id);
276                self.redact_string("message", message);
277            }
278            ServerMessage::Error { message, .. } => {
279                self.redact_string("message", message);
280            }
281            ServerMessage::Pong { .. }
282            | ServerMessage::Redirect { .. }
283            | ServerMessage::PatchUrl { .. }
284            | ServerMessage::Navigate { .. }
285            | ServerMessage::StreamDelete { .. }
286            | ServerMessage::StreamBatch { .. }
287            | ServerMessage::ChartSeriesAppend { .. }
288            | ServerMessage::ChartSeriesAppendMany { .. }
289            | ServerMessage::ChartSeriesReplace { .. }
290            | ServerMessage::ChartReset { .. }
291            | ServerMessage::ChartAnnotationUpsert { .. }
292            | ServerMessage::ChartAnnotationDelete { .. }
293            | ServerMessage::ToastPush { .. }
294            | ServerMessage::ToastDismiss { .. }
295            | ServerMessage::InboxUpsert { .. }
296            | ServerMessage::InboxDelete { .. }
297            | ServerMessage::GridReplace { .. }
298            | ServerMessage::GridRowsReplace { .. }
299            | ServerMessage::InteropDispatch { .. }
300            | ServerMessage::UploadProgress { .. } => {}
301        }
302        message
303    }
304}
305
306/// In-memory recorder for building replay artifacts from live session turns.
307#[derive(Debug, Clone)]
308pub struct SessionTraceRecorder {
309    policy: TraceRedactionPolicy,
310    artifact: SessionReplayTrace,
311    next_sequence: u64,
312}
313
314impl SessionTraceRecorder {
315    pub fn new(metadata: SessionReplayMetadata, policy: TraceRedactionPolicy) -> Self {
316        Self {
317            policy: policy.clone(),
318            artifact: SessionReplayTrace {
319                format_version: REPLAY_TRACE_FORMAT_VERSION.to_string(),
320                captured_at_unix_ms: now_unix_ms(),
321                metadata,
322                redaction: policy.summary(),
323                steps: Vec::new(),
324            },
325            next_sequence: 1,
326        }
327    }
328
329    pub fn record_turn(
330        &mut self,
331        client_message: &ClientMessage,
332        server_messages: &[ServerMessage],
333        revision_before: u64,
334        revision_after: u64,
335    ) {
336        let client = self.policy.redact_client_message(client_message.clone());
337        let server = server_messages
338            .iter()
339            .cloned()
340            .map(|message| self.policy.redact_server_message(message))
341            .collect::<Vec<_>>();
342
343        self.artifact.steps.push(SessionReplayTraceStep {
344            sequence: self.next_sequence,
345            recorded_at_unix_ms: now_unix_ms(),
346            revision_before,
347            revision_after,
348            client_message: client,
349            server_messages: server,
350        });
351        self.next_sequence += 1;
352    }
353
354    pub fn artifact(&self) -> SessionReplayTrace {
355        self.artifact.clone()
356    }
357
358    pub fn into_artifact(self) -> SessionReplayTrace {
359        self.artifact
360    }
361}
362
363#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
364#[serde(rename_all = "snake_case")]
365pub enum ReplayStepStatus {
366    Match,
367    Mismatch,
368}
369
370#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
371pub struct ReplayStepResult {
372    pub sequence: u64,
373    pub status: ReplayStepStatus,
374    pub mismatch_reason: Option<String>,
375    pub expected_revision_before: u64,
376    pub expected_revision_after: u64,
377    pub actual_revision_before: u64,
378    pub actual_revision_after: u64,
379    pub client_message: ClientMessage,
380    pub expected_server_messages: Vec<ServerMessage>,
381    pub actual_server_messages: Vec<ServerMessage>,
382}
383
384#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
385pub struct ReplayReport {
386    pub format_version: String,
387    pub metadata: SessionReplayMetadata,
388    pub total_steps: usize,
389    pub matched_steps: usize,
390    pub first_mismatch_sequence: Option<u64>,
391    pub revision_monotonic: bool,
392    pub final_revision: u64,
393    pub steps: Vec<ReplayStepResult>,
394}
395
396impl ReplayReport {
397    pub fn passed(&self) -> bool {
398        self.first_mismatch_sequence.is_none()
399            && self.revision_monotonic
400            && self.matched_steps == self.total_steps
401    }
402}
403
404/// Replay one trace with a fresh session built from `view_factory`.
405pub fn replay_trace<F>(
406    trace: &SessionReplayTrace,
407    mut view_factory: F,
408) -> Result<ReplayReport, ShellyError>
409where
410    F: FnMut() -> Box<dyn LiveView>,
411{
412    let comparison_policy = TraceRedactionPolicy::from_summary(&trace.redaction);
413    let metadata = &trace.metadata;
414    let mut session = LiveSession::new_with_route_and_session_id(
415        view_factory(),
416        metadata.session_id.clone(),
417        metadata.target_id.clone(),
418        metadata.route_path.clone(),
419        metadata.route_params.clone(),
420    );
421    session.mount()?;
422
423    let mut steps = Vec::with_capacity(trace.steps.len());
424    let mut matched_steps = 0usize;
425    let mut first_mismatch_sequence = None;
426    let mut revision_monotonic = true;
427    let mut previous_server_revision = 0u64;
428
429    for expected_step in &trace.steps {
430        let actual_revision_before = session.revision();
431        let actual_messages_raw =
432            session.handle_client_message(expected_step.client_message.clone());
433        let actual_revision_after = session.revision();
434        let actual_messages = actual_messages_raw
435            .iter()
436            .cloned()
437            .map(|message| comparison_policy.redact_server_message(message))
438            .collect::<Vec<_>>();
439        let mut mismatch_reasons = Vec::new();
440
441        if expected_step.revision_before != actual_revision_before {
442            mismatch_reasons.push(format!(
443                "revision_before mismatch: expected {}, got {}",
444                expected_step.revision_before, actual_revision_before
445            ));
446        }
447        if expected_step.revision_after != actual_revision_after {
448            mismatch_reasons.push(format!(
449                "revision_after mismatch: expected {}, got {}",
450                expected_step.revision_after, actual_revision_after
451            ));
452        }
453        if expected_step.server_messages != actual_messages {
454            mismatch_reasons.push("server_messages mismatch".to_string());
455        }
456
457        if actual_revision_after < actual_revision_before {
458            revision_monotonic = false;
459            mismatch_reasons.push("session revision regressed".to_string());
460        }
461
462        match validate_server_revisions(previous_server_revision, &actual_messages_raw) {
463            Ok(next_revision) => {
464                previous_server_revision = next_revision;
465            }
466            Err(reason) => {
467                revision_monotonic = false;
468                mismatch_reasons.push(reason);
469            }
470        }
471
472        let status = if mismatch_reasons.is_empty() {
473            matched_steps += 1;
474            ReplayStepStatus::Match
475        } else {
476            if first_mismatch_sequence.is_none() {
477                first_mismatch_sequence = Some(expected_step.sequence);
478            }
479            ReplayStepStatus::Mismatch
480        };
481
482        let mismatch_reason = if mismatch_reasons.is_empty() {
483            None
484        } else {
485            Some(mismatch_reasons.join("; "))
486        };
487
488        steps.push(ReplayStepResult {
489            sequence: expected_step.sequence,
490            status,
491            mismatch_reason,
492            expected_revision_before: expected_step.revision_before,
493            expected_revision_after: expected_step.revision_after,
494            actual_revision_before,
495            actual_revision_after,
496            client_message: expected_step.client_message.clone(),
497            expected_server_messages: expected_step.server_messages.clone(),
498            actual_server_messages: actual_messages,
499        });
500    }
501
502    Ok(ReplayReport {
503        format_version: trace.format_version.clone(),
504        metadata: trace.metadata.clone(),
505        total_steps: trace.steps.len(),
506        matched_steps,
507        first_mismatch_sequence,
508        revision_monotonic,
509        final_revision: session.revision(),
510        steps,
511    })
512}
513
514#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
515pub struct TimeTravelFrame {
516    pub sequence: u64,
517    pub status: ReplayStepStatus,
518    pub client_kind: String,
519    pub client_summary: String,
520    pub expected_revision_after: u64,
521    pub actual_revision_after: u64,
522    pub expected_server_count: usize,
523    pub actual_server_count: usize,
524    pub mismatch_reason: Option<String>,
525    pub client_message: ClientMessage,
526    pub expected_server_messages: Vec<ServerMessage>,
527    pub actual_server_messages: Vec<ServerMessage>,
528}
529
530#[derive(Debug, Clone, Default)]
531pub struct TimeTravelInspector {
532    frames: Vec<TimeTravelFrame>,
533    cursor: usize,
534}
535
536impl TimeTravelInspector {
537    pub fn from_report(report: &ReplayReport) -> Self {
538        let frames = report
539            .steps
540            .iter()
541            .map(|step| TimeTravelFrame {
542                sequence: step.sequence,
543                status: step.status.clone(),
544                client_kind: client_message_kind(&step.client_message).to_string(),
545                client_summary: client_message_summary(&step.client_message),
546                expected_revision_after: step.expected_revision_after,
547                actual_revision_after: step.actual_revision_after,
548                expected_server_count: step.expected_server_messages.len(),
549                actual_server_count: step.actual_server_messages.len(),
550                mismatch_reason: step.mismatch_reason.clone(),
551                client_message: step.client_message.clone(),
552                expected_server_messages: step.expected_server_messages.clone(),
553                actual_server_messages: step.actual_server_messages.clone(),
554            })
555            .collect::<Vec<_>>();
556        Self { frames, cursor: 0 }
557    }
558
559    pub fn len(&self) -> usize {
560        self.frames.len()
561    }
562
563    pub fn is_empty(&self) -> bool {
564        self.frames.is_empty()
565    }
566
567    pub fn cursor(&self) -> usize {
568        self.cursor
569    }
570
571    pub fn frames(&self) -> &[TimeTravelFrame] {
572        &self.frames
573    }
574
575    pub fn current(&self) -> Option<&TimeTravelFrame> {
576        self.frames.get(self.cursor)
577    }
578
579    pub fn step_to(&mut self, index: usize) -> Option<&TimeTravelFrame> {
580        if index < self.frames.len() {
581            self.cursor = index;
582            self.frames.get(self.cursor)
583        } else {
584            None
585        }
586    }
587
588    #[allow(clippy::should_implement_trait)]
589    pub fn next(&mut self) -> Option<&TimeTravelFrame> {
590        if self.cursor + 1 < self.frames.len() {
591            self.cursor += 1;
592        }
593        self.frames.get(self.cursor)
594    }
595
596    pub fn previous(&mut self) -> Option<&TimeTravelFrame> {
597        if self.cursor > 0 {
598            self.cursor -= 1;
599        }
600        self.frames.get(self.cursor)
601    }
602}
603
604fn client_message_kind(message: &ClientMessage) -> &'static str {
605    match message {
606        ClientMessage::Connect { .. } => "connect",
607        ClientMessage::Event { .. } => "event",
608        ClientMessage::Ping { .. } => "ping",
609        ClientMessage::PatchUrl { .. } => "patch_url",
610        ClientMessage::Navigate { .. } => "navigate",
611        ClientMessage::UploadStart { .. } => "upload_start",
612        ClientMessage::UploadChunk { .. } => "upload_chunk",
613        ClientMessage::UploadComplete { .. } => "upload_complete",
614    }
615}
616
617fn client_message_summary(message: &ClientMessage) -> String {
618    match message {
619        ClientMessage::Event { event, target, .. } => {
620            if let Some(target) = target {
621                format!("{event} -> {target}")
622            } else {
623                event.clone()
624            }
625        }
626        ClientMessage::PatchUrl { to } => format!("patch_url {to}"),
627        ClientMessage::Navigate { to } => format!("navigate {to}"),
628        ClientMessage::Ping { .. } => "ping".to_string(),
629        ClientMessage::Connect { .. } => "connect".to_string(),
630        ClientMessage::UploadStart { upload_id, .. } => format!("upload_start {upload_id}"),
631        ClientMessage::UploadChunk { upload_id, .. } => format!("upload_chunk {upload_id}"),
632        ClientMessage::UploadComplete { upload_id } => format!("upload_complete {upload_id}"),
633    }
634}
635
636fn validate_server_revisions(
637    mut previous_revision: u64,
638    messages: &[ServerMessage],
639) -> Result<u64, String> {
640    for message in messages {
641        let current_revision = match message {
642            ServerMessage::Patch { revision, .. } => Some(*revision),
643            ServerMessage::Diff { revision, .. } => Some(*revision),
644            _ => None,
645        };
646        if let Some(current_revision) = current_revision {
647            if current_revision <= previous_revision {
648                return Err(format!(
649                    "non-monotonic server revision: previous={}, next={}",
650                    previous_revision, current_revision
651                ));
652            }
653            previous_revision = current_revision;
654        }
655    }
656    Ok(previous_revision)
657}
658
659fn normalize_key(input: impl AsRef<str>) -> String {
660    input.as_ref().trim().to_lowercase().replace('-', "_")
661}
662
663fn default_sensitive_keys() -> Vec<&'static str> {
664    vec![
665        "password",
666        "passphrase",
667        "secret",
668        "token",
669        "resume_token",
670        "csrf",
671        "authorization",
672        "cookie",
673        "api_key",
674        "access_token",
675        "refresh_token",
676        "id_token",
677    ]
678}
679
680fn now_unix_ms() -> u64 {
681    SystemTime::now()
682        .duration_since(UNIX_EPOCH)
683        .map(|duration| duration.as_millis() as u64)
684        .unwrap_or(0)
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690    use crate::{Context, Event, Html, LiveResult};
691    use serde_json::json;
692
693    #[derive(Default)]
694    struct CounterView {
695        count: i64,
696    }
697
698    impl LiveView for CounterView {
699        fn mount(&mut self, _ctx: &mut Context) -> LiveResult {
700            self.count = 0;
701            Ok(())
702        }
703
704        fn handle_event(&mut self, event: Event, _ctx: &mut Context) -> LiveResult {
705            match event.name.as_str() {
706                "inc" => self.count += 1,
707                "dec" => self.count -= 1,
708                _ => {}
709            }
710            Ok(())
711        }
712
713        fn render(&self) -> Html {
714            Html::new(format!("<p>Count: {}</p>", self.count))
715        }
716    }
717
718    fn build_trace(policy: TraceRedactionPolicy) -> SessionReplayTrace {
719        let mut session = LiveSession::new(Box::<CounterView>::default(), "root");
720        session.mount().expect("mount trace session");
721        session.enable_trace_capture(policy);
722
723        let first = ClientMessage::Event {
724            event: "inc".to_string(),
725            target: None,
726            value: json!({
727                "password": "super-secret",
728                "nested": {"token": "abc"},
729                "n": 1
730            }),
731            metadata: serde_json::Map::from_iter([(
732                "authorization".to_string(),
733                Value::String("Bearer 123".to_string()),
734            )]),
735        };
736        let second = ClientMessage::Event {
737            event: "inc".to_string(),
738            target: None,
739            value: json!({}),
740            metadata: serde_json::Map::new(),
741        };
742
743        let _ = session.handle_client_message(first);
744        let _ = session.handle_client_message(second);
745
746        session.take_trace_artifact().expect("trace artifact")
747    }
748
749    #[test]
750    fn replay_trace_reproduces_session_without_live_dependencies() {
751        let trace = build_trace(TraceRedactionPolicy::developer_default());
752        let report = replay_trace(&trace, || Box::<CounterView>::default()).expect("replay report");
753        assert!(report.passed(), "replay mismatches: {:#?}", report);
754        assert_eq!(report.total_steps, 2);
755        assert_eq!(report.final_revision, 2);
756    }
757
758    #[test]
759    fn replay_trace_round_trips_json_and_empty_reports() {
760        let metadata = SessionReplayMetadata {
761            protocol: crate::PROTOCOL_VERSION_V1.to_string(),
762            session_id: "sid-1".to_string(),
763            tenant_id: Some("tenant-a".to_string()),
764            target_id: "root".to_string(),
765            route_path: "/".to_string(),
766            route_params: BTreeMap::new(),
767        };
768        let recorder = SessionTraceRecorder::new(metadata, TraceRedactionPolicy::none());
769        let artifact = recorder.into_artifact();
770        assert_eq!(artifact.steps.len(), 0);
771        let json = artifact.to_json_pretty().expect("serialize trace");
772        let decoded = SessionReplayTrace::from_json(&json).expect("deserialize trace");
773        assert_eq!(decoded, artifact);
774
775        let report = replay_trace(&decoded, || Box::<CounterView>::default()).expect("replay");
776        assert!(report.passed());
777        assert_eq!(report.total_steps, 0);
778        assert_eq!(report.matched_steps, 0);
779
780        let mut inspector = TimeTravelInspector::from_report(&report);
781        assert!(inspector.is_empty());
782        assert_eq!(inspector.len(), 0);
783        assert!(inspector.current().is_none());
784        assert!(inspector.next().is_none());
785        assert!(inspector.previous().is_none());
786        assert!(inspector.step_to(0).is_none());
787        assert_eq!(inspector.cursor(), 0);
788        assert!(inspector.frames().is_empty());
789    }
790
791    #[test]
792    fn replay_trace_detects_mismatch_and_revision_issues() {
793        let mut trace = build_trace(TraceRedactionPolicy::developer_default());
794        trace.steps[1].revision_after = 99;
795        let report = replay_trace(&trace, || Box::<CounterView>::default()).expect("replay report");
796        assert!(!report.passed());
797        assert_eq!(report.first_mismatch_sequence, Some(2));
798    }
799
800    #[test]
801    fn validate_server_revisions_rejects_regressions() {
802        let err = validate_server_revisions(
803            3,
804            &[ServerMessage::Patch {
805                target: "root".to_string(),
806                html: "<p>regress</p>".to_string(),
807                revision: 2,
808            }],
809        )
810        .expect_err("revision regression should fail");
811        assert!(err.contains("non-monotonic server revision"));
812
813        let next = validate_server_revisions(
814            2,
815            &[ServerMessage::Diff {
816                target: "root".to_string(),
817                revision: 3,
818                slots: vec![],
819            }],
820        )
821        .expect("revision should advance");
822        assert_eq!(next, 3);
823    }
824
825    #[test]
826    fn redaction_policy_covers_connect_upload_and_server_variants() {
827        let metadata = SessionReplayMetadata {
828            protocol: crate::PROTOCOL_VERSION_V1.to_string(),
829            session_id: "sid-1".to_string(),
830            tenant_id: None,
831            target_id: "root".to_string(),
832            route_path: "/".to_string(),
833            route_params: BTreeMap::new(),
834        };
835        let policy = TraceRedactionPolicy::none()
836            .with_key("resume-token")
837            .with_key("trace_id")
838            .with_key("span_id")
839            .with_key("parent_span_id")
840            .with_key("correlation_id")
841            .with_key("request_id")
842            .with_key("upload_id")
843            .with_key("name")
844            .with_key("content_type")
845            .with_key("message")
846            .with_key("session_id")
847            .with_key("data")
848            .with_redacted_text("<mask>")
849            .with_redact_server_html(true);
850
851        let mut recorder = SessionTraceRecorder::new(metadata, policy);
852        let client = ClientMessage::Connect {
853            protocol: crate::PROTOCOL_VERSION_V1.to_string(),
854            session_id: Some("sid-1".to_string()),
855            last_revision: Some(5),
856            resume_token: Some("resume".to_string()),
857            tenant_id: Some("tenant-1".to_string()),
858            trace_id: Some("4bf92f3577b34da6a3ce929d0e0e4736".to_string()),
859            span_id: Some("00f067aa0ba902b7".to_string()),
860            parent_span_id: Some("89abcdef01234567".to_string()),
861            correlation_id: Some("corr-1".to_string()),
862            request_id: Some("req-1".to_string()),
863        };
864        let server = vec![
865            ServerMessage::Hello {
866                session_id: "sid-1".to_string(),
867                target: "root".to_string(),
868                revision: 0,
869                protocol: crate::PROTOCOL_VERSION_V1.to_string(),
870                server_revision: Some(0),
871                resume_status: Some(crate::ResumeStatus::Fresh),
872                resume_reason: None,
873                resume_token: Some("resume".to_string()),
874                resume_expires_in_ms: Some(120_000),
875            },
876            ServerMessage::Patch {
877                target: "root".to_string(),
878                html: "<p>private</p>".to_string(),
879                revision: 1,
880            },
881            ServerMessage::Diff {
882                target: "root".to_string(),
883                revision: 2,
884                slots: vec![crate::DynamicSlotPatch {
885                    index: 0,
886                    html: "<span>hidden</span>".to_string(),
887                }],
888            },
889            ServerMessage::StreamInsert {
890                target: "items".to_string(),
891                id: "item-1".to_string(),
892                html: "<li>secret</li>".to_string(),
893                at: crate::StreamPosition::Append,
894            },
895            ServerMessage::UploadComplete {
896                upload_id: "u-1".to_string(),
897                name: "avatar.png".to_string(),
898                size: 10,
899                content_type: Some("image/png".to_string()),
900            },
901            ServerMessage::UploadError {
902                upload_id: "u-1".to_string(),
903                message: "bad chunk".to_string(),
904                code: Some("upload_invalid_chunk".to_string()),
905            },
906            ServerMessage::Error {
907                message: "panic".to_string(),
908                code: Some("server_error".to_string()),
909            },
910        ];
911        recorder.record_turn(&client, &server, 0, 1);
912
913        let artifact = recorder.artifact();
914        assert_eq!(artifact.redaction.redacted_text, "<mask>");
915        assert!(artifact.redaction.redact_server_html);
916        assert_eq!(artifact.steps.len(), 1);
917        let step = &artifact.steps[0];
918        match &step.client_message {
919            ClientMessage::Connect {
920                resume_token,
921                trace_id,
922                span_id,
923                parent_span_id,
924                correlation_id,
925                request_id,
926                ..
927            } => {
928                assert_eq!(resume_token.as_deref(), Some("<mask>"));
929                assert_eq!(trace_id.as_deref(), Some("<mask>"));
930                assert_eq!(span_id.as_deref(), Some("<mask>"));
931                assert_eq!(parent_span_id.as_deref(), Some("<mask>"));
932                assert_eq!(correlation_id.as_deref(), Some("<mask>"));
933                assert_eq!(request_id.as_deref(), Some("<mask>"));
934            }
935            other => panic!("expected connect, got {other:?}"),
936        }
937
938        match &step.server_messages[0] {
939            ServerMessage::Hello {
940                session_id,
941                resume_token,
942                ..
943            } => {
944                assert_eq!(session_id, "<mask>");
945                assert_eq!(resume_token.as_deref(), Some("<mask>"));
946            }
947            other => panic!("expected hello, got {other:?}"),
948        }
949        match &step.server_messages[1] {
950            ServerMessage::Patch { html, .. } => assert_eq!(html, "<mask>"),
951            other => panic!("expected patch, got {other:?}"),
952        }
953        match &step.server_messages[2] {
954            ServerMessage::Diff { slots, .. } => assert_eq!(slots[0].html, "<mask>"),
955            other => panic!("expected diff, got {other:?}"),
956        }
957        match &step.server_messages[3] {
958            ServerMessage::StreamInsert { html, .. } => assert_eq!(html, "<mask>"),
959            other => panic!("expected stream insert, got {other:?}"),
960        }
961        match &step.server_messages[4] {
962            ServerMessage::UploadComplete {
963                upload_id,
964                name,
965                content_type,
966                ..
967            } => {
968                assert_eq!(upload_id, "<mask>");
969                assert_eq!(name, "<mask>");
970                assert_eq!(content_type.as_deref(), Some("<mask>"));
971            }
972            other => panic!("expected upload complete, got {other:?}"),
973        }
974        match &step.server_messages[5] {
975            ServerMessage::UploadError {
976                upload_id, message, ..
977            } => {
978                assert_eq!(upload_id, "<mask>");
979                assert_eq!(message, "<mask>");
980            }
981            other => panic!("expected upload error, got {other:?}"),
982        }
983        match &step.server_messages[6] {
984            ServerMessage::Error { message, .. } => assert_eq!(message, "<mask>"),
985            other => panic!("expected error, got {other:?}"),
986        }
987    }
988
989    #[test]
990    fn production_redaction_masks_sensitive_payloads() {
991        let trace = build_trace(TraceRedactionPolicy::production_safe());
992        let step = &trace.steps[0];
993        match &step.client_message {
994            ClientMessage::Event {
995                value, metadata, ..
996            } => {
997                assert_eq!(value["password"], "<redacted>");
998                assert_eq!(value["nested"]["token"], "<redacted>");
999                assert_eq!(metadata["authorization"], "<redacted>");
1000            }
1001            _ => panic!("expected event"),
1002        }
1003        match &step.server_messages[0] {
1004            ServerMessage::Patch { html, .. } => assert_eq!(html, "<redacted>"),
1005            _ => panic!("expected patch"),
1006        }
1007    }
1008
1009    #[test]
1010    fn inspector_supports_time_travel_navigation() {
1011        let trace = build_trace(TraceRedactionPolicy::developer_default());
1012        let report = replay_trace(&trace, || Box::<CounterView>::default()).expect("replay report");
1013        let mut inspector = TimeTravelInspector::from_report(&report);
1014        assert_eq!(inspector.len(), 2);
1015        assert_eq!(inspector.current().expect("current frame").sequence, 1);
1016        inspector.next();
1017        assert_eq!(inspector.current().expect("current frame").sequence, 2);
1018        inspector.previous();
1019        assert_eq!(inspector.current().expect("current frame").sequence, 1);
1020    }
1021
1022    #[test]
1023    fn inspector_frames_cover_client_kind_and_summary_variants() {
1024        let connect = ClientMessage::Connect {
1025            protocol: crate::PROTOCOL_VERSION_V1.to_string(),
1026            session_id: None,
1027            last_revision: None,
1028            resume_token: None,
1029            tenant_id: None,
1030            trace_id: None,
1031            span_id: None,
1032            parent_span_id: None,
1033            correlation_id: None,
1034            request_id: None,
1035        };
1036        let steps = vec![
1037            ReplayStepResult {
1038                sequence: 1,
1039                status: ReplayStepStatus::Match,
1040                mismatch_reason: None,
1041                expected_revision_before: 0,
1042                expected_revision_after: 0,
1043                actual_revision_before: 0,
1044                actual_revision_after: 0,
1045                client_message: connect,
1046                expected_server_messages: vec![],
1047                actual_server_messages: vec![],
1048            },
1049            ReplayStepResult {
1050                sequence: 2,
1051                status: ReplayStepStatus::Match,
1052                mismatch_reason: None,
1053                expected_revision_before: 0,
1054                expected_revision_after: 1,
1055                actual_revision_before: 0,
1056                actual_revision_after: 1,
1057                client_message: ClientMessage::Event {
1058                    event: "save".to_string(),
1059                    target: Some("form-1".to_string()),
1060                    value: json!({}),
1061                    metadata: serde_json::Map::new(),
1062                },
1063                expected_server_messages: vec![],
1064                actual_server_messages: vec![],
1065            },
1066            ReplayStepResult {
1067                sequence: 3,
1068                status: ReplayStepStatus::Match,
1069                mismatch_reason: None,
1070                expected_revision_before: 1,
1071                expected_revision_after: 1,
1072                actual_revision_before: 1,
1073                actual_revision_after: 1,
1074                client_message: ClientMessage::PatchUrl {
1075                    to: "/users".to_string(),
1076                },
1077                expected_server_messages: vec![],
1078                actual_server_messages: vec![],
1079            },
1080            ReplayStepResult {
1081                sequence: 4,
1082                status: ReplayStepStatus::Match,
1083                mismatch_reason: None,
1084                expected_revision_before: 1,
1085                expected_revision_after: 1,
1086                actual_revision_before: 1,
1087                actual_revision_after: 1,
1088                client_message: ClientMessage::Navigate {
1089                    to: "/users/1".to_string(),
1090                },
1091                expected_server_messages: vec![],
1092                actual_server_messages: vec![],
1093            },
1094            ReplayStepResult {
1095                sequence: 5,
1096                status: ReplayStepStatus::Match,
1097                mismatch_reason: None,
1098                expected_revision_before: 1,
1099                expected_revision_after: 1,
1100                actual_revision_before: 1,
1101                actual_revision_after: 1,
1102                client_message: ClientMessage::Ping {
1103                    nonce: Some("n1".to_string()),
1104                },
1105                expected_server_messages: vec![],
1106                actual_server_messages: vec![],
1107            },
1108            ReplayStepResult {
1109                sequence: 6,
1110                status: ReplayStepStatus::Match,
1111                mismatch_reason: None,
1112                expected_revision_before: 1,
1113                expected_revision_after: 1,
1114                actual_revision_before: 1,
1115                actual_revision_after: 1,
1116                client_message: ClientMessage::UploadStart {
1117                    upload_id: "u1".to_string(),
1118                    event: "uploaded".to_string(),
1119                    target: None,
1120                    name: "a.txt".to_string(),
1121                    size: 1,
1122                    content_type: None,
1123                },
1124                expected_server_messages: vec![],
1125                actual_server_messages: vec![],
1126            },
1127            ReplayStepResult {
1128                sequence: 7,
1129                status: ReplayStepStatus::Match,
1130                mismatch_reason: None,
1131                expected_revision_before: 1,
1132                expected_revision_after: 1,
1133                actual_revision_before: 1,
1134                actual_revision_after: 1,
1135                client_message: ClientMessage::UploadChunk {
1136                    upload_id: "u1".to_string(),
1137                    offset: 0,
1138                    data: "AA==".to_string(),
1139                },
1140                expected_server_messages: vec![],
1141                actual_server_messages: vec![],
1142            },
1143            ReplayStepResult {
1144                sequence: 8,
1145                status: ReplayStepStatus::Match,
1146                mismatch_reason: None,
1147                expected_revision_before: 1,
1148                expected_revision_after: 1,
1149                actual_revision_before: 1,
1150                actual_revision_after: 1,
1151                client_message: ClientMessage::UploadComplete {
1152                    upload_id: "u1".to_string(),
1153                },
1154                expected_server_messages: vec![],
1155                actual_server_messages: vec![],
1156            },
1157        ];
1158
1159        let report = ReplayReport {
1160            format_version: REPLAY_TRACE_FORMAT_VERSION.to_string(),
1161            metadata: SessionReplayMetadata {
1162                protocol: crate::PROTOCOL_VERSION_V1.to_string(),
1163                session_id: "sid".to_string(),
1164                tenant_id: None,
1165                target_id: "root".to_string(),
1166                route_path: "/".to_string(),
1167                route_params: BTreeMap::new(),
1168            },
1169            total_steps: steps.len(),
1170            matched_steps: steps.len(),
1171            first_mismatch_sequence: None,
1172            revision_monotonic: true,
1173            final_revision: 1,
1174            steps,
1175        };
1176        let inspector = TimeTravelInspector::from_report(&report);
1177        assert_eq!(inspector.frames()[0].client_kind, "connect");
1178        assert_eq!(inspector.frames()[1].client_summary, "save -> form-1");
1179        assert_eq!(inspector.frames()[2].client_summary, "patch_url /users");
1180        assert_eq!(inspector.frames()[3].client_summary, "navigate /users/1");
1181        assert_eq!(inspector.frames()[4].client_summary, "ping");
1182        assert_eq!(inspector.frames()[5].client_summary, "upload_start u1");
1183        assert_eq!(inspector.frames()[6].client_summary, "upload_chunk u1");
1184        assert_eq!(inspector.frames()[7].client_summary, "upload_complete u1");
1185    }
1186}