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#[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#[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#[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#[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#[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#[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
404pub 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}