1#![forbid(unsafe_code)]
2
3use std::fs::{OpenOptions, create_dir_all};
12use std::io::{self, BufWriter, Write};
13use std::path::PathBuf;
14use web_time::{Instant, SystemTime, UNIX_EPOCH};
15
16use ftui_core::terminal_capabilities::TerminalCapabilities;
17use ftui_render::buffer::Buffer;
18use ftui_render::cell::{Cell, CellAttrs, CellContent};
19use ftui_render::diff::BufferDiff;
20use ftui_render::grapheme_pool::GraphemePool;
21
22use crate::conformal_predictor::ConformalConfig;
23use crate::resize_coalescer::CoalescerConfig;
24use crate::terminal_writer::RuntimeDiffConfig;
25
26#[derive(Debug, Clone)]
28pub struct RenderTraceConfig {
29 pub enabled: bool,
31 pub output_path: PathBuf,
33 pub run_id: Option<String>,
35 pub seed: Option<u64>,
37 pub test_module: Option<String>,
39 pub flush_on_write: bool,
41 pub include_start_ts_ms: bool,
43}
44
45impl Default for RenderTraceConfig {
46 fn default() -> Self {
47 Self {
48 enabled: false,
49 output_path: PathBuf::from("trace.jsonl"),
50 run_id: None,
51 seed: None,
52 test_module: None,
53 flush_on_write: true,
54 include_start_ts_ms: false,
55 }
56 }
57}
58
59impl RenderTraceConfig {
60 #[must_use]
62 pub fn enabled_file(path: impl Into<PathBuf>) -> Self {
63 Self {
64 enabled: true,
65 output_path: path.into(),
66 ..Default::default()
67 }
68 }
69
70 #[must_use]
72 pub fn with_run_id(mut self, run_id: impl Into<String>) -> Self {
73 self.run_id = Some(run_id.into());
74 self
75 }
76
77 #[must_use]
79 pub fn with_seed(mut self, seed: u64) -> Self {
80 self.seed = Some(seed);
81 self
82 }
83
84 #[must_use]
86 pub fn with_test_module(mut self, test_module: impl Into<String>) -> Self {
87 self.test_module = Some(test_module.into());
88 self
89 }
90
91 #[must_use]
93 pub fn with_flush_on_write(mut self, enabled: bool) -> Self {
94 self.flush_on_write = enabled;
95 self
96 }
97
98 #[must_use]
100 pub fn with_start_ts_ms(mut self, enabled: bool) -> Self {
101 self.include_start_ts_ms = enabled;
102 self
103 }
104}
105
106#[derive(Debug, Clone)]
108pub struct RenderTraceContext<'a> {
109 pub capabilities: &'a TerminalCapabilities,
110 pub diff_config: RuntimeDiffConfig,
111 pub resize_config: CoalescerConfig,
112 pub conformal_config: Option<ConformalConfig>,
113}
114
115pub struct RenderTraceRecorder {
117 writer: BufWriter<std::fs::File>,
118 flush_on_write: bool,
119 frame_idx: u64,
120 checksum_chain: u64,
121 total_frames: u64,
122 finished: bool,
123 payload_dir: Option<PayloadDir>,
124}
125
126#[derive(Debug, Clone)]
127struct PayloadDir {
128 abs: PathBuf,
129 rel: String,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum RenderTracePayloadKind {
135 DiffRunsV1,
136 FullBufferV1,
137}
138
139impl RenderTracePayloadKind {
140 pub const fn as_str(self) -> &'static str {
141 match self {
142 Self::DiffRunsV1 => "diff_runs_v1",
143 Self::FullBufferV1 => "full_buffer_v1",
144 }
145 }
146}
147
148#[derive(Debug, Clone)]
150pub struct RenderTracePayload {
151 pub kind: RenderTracePayloadKind,
152 pub bytes: Vec<u8>,
153}
154
155#[derive(Debug, Clone)]
157pub struct RenderTracePayloadInfo {
158 pub kind: &'static str,
159 pub path: String,
160}
161
162impl RenderTraceRecorder {
163 pub fn from_config(
165 config: &RenderTraceConfig,
166 context: RenderTraceContext<'_>,
167 ) -> io::Result<Option<Self>> {
168 if !config.enabled {
169 return Ok(None);
170 }
171
172 let base_dir = config
173 .output_path
174 .parent()
175 .map(PathBuf::from)
176 .unwrap_or_else(|| PathBuf::from("."));
177 let stem = config
178 .output_path
179 .file_stem()
180 .and_then(|s| s.to_str())
181 .unwrap_or("trace");
182 let payload_dir_name = format!("{stem}_payloads");
183 let payload_dir_abs = base_dir.join(&payload_dir_name);
184 create_dir_all(&payload_dir_abs)?;
185
186 let file = OpenOptions::new()
187 .create(true)
188 .write(true)
189 .truncate(true)
190 .open(&config.output_path)?;
191 let mut recorder = Self {
192 writer: BufWriter::new(file),
193 flush_on_write: config.flush_on_write,
194 frame_idx: 0,
195 checksum_chain: 0,
196 total_frames: 0,
197 finished: false,
198 payload_dir: Some(PayloadDir {
199 abs: payload_dir_abs,
200 rel: payload_dir_name,
201 }),
202 };
203
204 let run_id = config
205 .run_id
206 .clone()
207 .unwrap_or_else(default_render_trace_run_id);
208 let env = RenderTraceEnv::new(config.test_module.clone());
209 let caps = RenderTraceCapabilities::from_caps(context.capabilities);
210 let policies = RenderTracePolicies::from_context(&context);
211 let start_ts_ms = if config.include_start_ts_ms {
212 Some(now_ms())
213 } else {
214 None
215 };
216 let header = RenderTraceHeader {
217 run_id,
218 seed: config.seed,
219 env,
220 capabilities: caps,
221 policies,
222 start_ts_ms,
223 };
224 recorder.write_jsonl(&header.to_jsonl())?;
225 Ok(Some(recorder))
226 }
227
228 pub fn write_payload(
230 &mut self,
231 payload: &RenderTracePayload,
232 ) -> io::Result<RenderTracePayloadInfo> {
233 let Some(dir) = &self.payload_dir else {
234 return Err(io::Error::other(
235 "render-trace payload directory unavailable",
236 ));
237 };
238 let file_name = format!("frame_{:06}_{}.bin", self.frame_idx, payload.kind.as_str());
239 let abs_path = dir.abs.join(&file_name);
240 let mut file = OpenOptions::new()
241 .create(true)
242 .write(true)
243 .truncate(true)
244 .open(&abs_path)?;
245 file.write_all(&payload.bytes)?;
246 if self.flush_on_write {
247 file.flush()?;
248 }
249 Ok(RenderTracePayloadInfo {
250 kind: payload.kind.as_str(),
251 path: format!("{}/{}", dir.rel, file_name),
252 })
253 }
254
255 pub fn record_frame(
257 &mut self,
258 mut frame: RenderTraceFrame<'_>,
259 buffer: &Buffer,
260 pool: &GraphemePool,
261 ) -> io::Result<()> {
262 let trace_start = Instant::now();
263 let checksum = checksum_buffer(buffer, pool);
264 let checksum_chain = fnv1a64_pair(self.checksum_chain, checksum);
265 frame.trace_us = Some(trace_start.elapsed().as_micros() as u64);
266
267 let line = frame.to_jsonl(self.frame_idx, checksum, checksum_chain);
268 self.write_jsonl(&line)?;
269
270 self.frame_idx = self.frame_idx.saturating_add(1);
271 self.checksum_chain = checksum_chain;
272 self.total_frames = self.total_frames.saturating_add(1);
273 Ok(())
274 }
275
276 pub fn finish(&mut self, elapsed_ms: Option<u64>) -> io::Result<()> {
278 if self.finished {
279 return Ok(());
280 }
281 let summary = RenderTraceSummary {
282 total_frames: self.total_frames,
283 final_checksum_chain: self.checksum_chain,
284 elapsed_ms,
285 };
286 self.write_jsonl(&summary.to_jsonl())?;
287 self.finished = true;
288 Ok(())
289 }
290
291 fn write_jsonl(&mut self, line: &str) -> io::Result<()> {
292 self.writer.write_all(line.as_bytes())?;
293 self.writer.write_all(b"\n")?;
294 if self.flush_on_write {
295 self.writer.flush()?;
296 }
297 Ok(())
298 }
299}
300
301#[derive(Debug, Clone)]
303struct RenderTraceHeader {
304 run_id: String,
305 seed: Option<u64>,
306 env: RenderTraceEnv,
307 capabilities: RenderTraceCapabilities,
308 policies: RenderTracePolicies,
309 start_ts_ms: Option<u64>,
310}
311
312impl RenderTraceHeader {
313 fn to_jsonl(&self) -> String {
314 let seed = opt_u64(self.seed);
315 let start_ts = opt_u64(self.start_ts_ms);
316 format!(
317 concat!(
318 r#"{{"event":"trace_header","schema_version":"render-trace-v1","#,
319 r#""run_id":"{}","seed":{},"env":{},"capabilities":{},"policies":{},"start_ts_ms":{}}}"#
320 ),
321 json_escape(&self.run_id),
322 seed,
323 self.env.to_json(),
324 self.capabilities.to_json(),
325 self.policies.to_json(),
326 start_ts
327 )
328 }
329}
330
331#[derive(Debug, Clone)]
333pub struct RenderTraceFrame<'a> {
334 pub cols: u16,
335 pub rows: u16,
336 pub mode: &'a str,
337 pub ui_height: u16,
338 pub ui_anchor: &'a str,
339 pub diff_strategy: &'a str,
340 pub diff_cells: usize,
341 pub diff_runs: usize,
342 pub present_bytes: u64,
343 pub render_us: Option<u64>,
344 pub present_us: Option<u64>,
345 pub payload_kind: &'a str,
346 pub payload_path: Option<&'a str>,
347 pub trace_us: Option<u64>,
348}
349
350impl RenderTraceFrame<'_> {
351 fn to_jsonl(&self, frame_idx: u64, checksum: u64, checksum_chain: u64) -> String {
352 let render_us = opt_u64(self.render_us);
353 let present_us = opt_u64(self.present_us);
354 let payload_path = opt_str(self.payload_path);
355 let trace_us = opt_u64(self.trace_us);
356 format!(
357 concat!(
358 r#"{{"event":"frame","frame_idx":{},"cols":{},"rows":{},"mode":"{}","#,
359 r#""ui_height":{},"ui_anchor":"{}","diff_strategy":"{}","diff_cells":{},"diff_runs":{},"present_bytes":{},"render_us":{},"present_us":{},"checksum":"{:016x}","checksum_chain":"{:016x}","payload_kind":"{}","payload_path":{},"trace_us":{}}}"#
360 ),
361 frame_idx,
362 self.cols,
363 self.rows,
364 json_escape(self.mode),
365 self.ui_height,
366 json_escape(self.ui_anchor),
367 json_escape(self.diff_strategy),
368 self.diff_cells,
369 self.diff_runs,
370 self.present_bytes,
371 render_us,
372 present_us,
373 checksum,
374 checksum_chain,
375 json_escape(self.payload_kind),
376 payload_path,
377 trace_us
378 )
379 }
380}
381
382#[derive(Debug, Clone)]
384struct RenderTraceSummary {
385 total_frames: u64,
386 final_checksum_chain: u64,
387 elapsed_ms: Option<u64>,
388}
389
390impl RenderTraceSummary {
391 fn to_jsonl(&self) -> String {
392 let elapsed_ms = opt_u64(self.elapsed_ms);
393 format!(
394 r#"{{"event":"trace_summary","total_frames":{},"final_checksum_chain":"{:016x}","elapsed_ms":{}}}"#,
395 self.total_frames, self.final_checksum_chain, elapsed_ms
396 )
397 }
398}
399
400#[derive(Debug, Clone)]
401struct RenderTraceEnv {
402 os: String,
403 arch: String,
404 test_module: Option<String>,
405}
406
407impl RenderTraceEnv {
408 fn new(test_module: Option<String>) -> Self {
409 Self {
410 os: std::env::consts::OS.to_string(),
411 arch: std::env::consts::ARCH.to_string(),
412 test_module,
413 }
414 }
415
416 fn to_json(&self) -> String {
417 format!(
418 r#"{{"os":"{}","arch":"{}","test_module":{}}}"#,
419 json_escape(&self.os),
420 json_escape(&self.arch),
421 opt_str(self.test_module.as_deref())
422 )
423 }
424}
425
426#[derive(Debug, Clone)]
427struct RenderTraceCapabilities {
428 profile: String,
429 true_color: bool,
430 colors_256: bool,
431 sync_output: bool,
432 osc8_hyperlinks: bool,
433 scroll_region: bool,
434 in_tmux: bool,
435 in_screen: bool,
436 in_zellij: bool,
437 kitty_keyboard: bool,
438 focus_events: bool,
439 bracketed_paste: bool,
440 mouse_sgr: bool,
441 osc52_clipboard: bool,
442}
443
444impl RenderTraceCapabilities {
445 fn from_caps(caps: &TerminalCapabilities) -> Self {
446 Self {
447 profile: caps.profile().as_str().to_string(),
448 true_color: caps.true_color,
449 colors_256: caps.colors_256,
450 sync_output: caps.sync_output,
451 osc8_hyperlinks: caps.osc8_hyperlinks,
452 scroll_region: caps.scroll_region,
453 in_tmux: caps.in_tmux,
454 in_screen: caps.in_screen,
455 in_zellij: caps.in_zellij,
456 kitty_keyboard: caps.kitty_keyboard,
457 focus_events: caps.focus_events,
458 bracketed_paste: caps.bracketed_paste,
459 mouse_sgr: caps.mouse_sgr,
460 osc52_clipboard: caps.osc52_clipboard,
461 }
462 }
463
464 fn to_json(&self) -> String {
465 format!(
466 concat!(
467 r#"{{"profile":"{}","true_color":{},"colors_256":{},"sync_output":{},"osc8_hyperlinks":{},"scroll_region":{},"in_tmux":{},"in_screen":{},"in_zellij":{},"kitty_keyboard":{},"focus_events":{},"bracketed_paste":{},"mouse_sgr":{},"osc52_clipboard":{}}}"#
468 ),
469 json_escape(&self.profile),
470 self.true_color,
471 self.colors_256,
472 self.sync_output,
473 self.osc8_hyperlinks,
474 self.scroll_region,
475 self.in_tmux,
476 self.in_screen,
477 self.in_zellij,
478 self.kitty_keyboard,
479 self.focus_events,
480 self.bracketed_paste,
481 self.mouse_sgr,
482 self.osc52_clipboard
483 )
484 }
485}
486
487#[derive(Debug, Clone)]
488struct RenderTracePolicies {
489 diff_bayesian: bool,
490 diff_dirty_rows: bool,
491 diff_dirty_spans: bool,
492 diff_guard_band: u16,
493 diff_merge_gap: u16,
494 bocpd_enabled: bool,
495 steady_delay_ms: u64,
496 burst_delay_ms: u64,
497 conformal_enabled: bool,
498 conformal_alpha: Option<f64>,
499 conformal_min_samples: Option<usize>,
500 conformal_window_size: Option<usize>,
501}
502
503impl RenderTracePolicies {
504 fn from_context(context: &RenderTraceContext) -> Self {
505 let diff = &context.diff_config;
506 let span = diff.dirty_span_config;
507 let resize = &context.resize_config;
508 let conformal = context.conformal_config.as_ref();
509 Self {
510 diff_bayesian: diff.bayesian_enabled,
511 diff_dirty_rows: diff.dirty_rows_enabled,
512 diff_dirty_spans: span.enabled,
513 diff_guard_band: span.guard_band,
514 diff_merge_gap: span.merge_gap,
515 bocpd_enabled: resize.enable_bocpd,
516 steady_delay_ms: resize.steady_delay_ms,
517 burst_delay_ms: resize.burst_delay_ms,
518 conformal_enabled: conformal.is_some(),
519 conformal_alpha: conformal.map(|c| c.alpha),
520 conformal_min_samples: conformal.map(|c| c.min_samples),
521 conformal_window_size: conformal.map(|c| c.window_size),
522 }
523 }
524
525 fn to_json(&self) -> String {
526 use std::fmt::Write as _;
527
528 let mut out = String::with_capacity(256);
529 out.push('{');
530 out.push_str("\"diff\":{");
531 let _ = write!(
532 out,
533 "\"bayesian\":{},\"dirty_rows\":{},\"dirty_spans\":{},\"guard_band\":{},\"merge_gap\":{}",
534 self.diff_bayesian,
535 self.diff_dirty_rows,
536 self.diff_dirty_spans,
537 self.diff_guard_band,
538 self.diff_merge_gap
539 );
540 out.push('}');
541 out.push(',');
542 out.push_str("\"bocpd\":{");
543 let _ = write!(
544 out,
545 "\"enabled\":{},\"steady_delay_ms\":{},\"burst_delay_ms\":{}",
546 self.bocpd_enabled, self.steady_delay_ms, self.burst_delay_ms
547 );
548 out.push('}');
549 out.push(',');
550 out.push_str("\"conformal\":{");
551 let _ = write!(
552 out,
553 "\"enabled\":{},\"alpha\":{},\"min_samples\":{},\"window_size\":{}",
554 self.conformal_enabled,
555 opt_f64(self.conformal_alpha),
556 opt_usize(self.conformal_min_samples),
557 opt_usize(self.conformal_window_size)
558 );
559 out.push('}');
560 out.push('}');
561 out
562 }
563}
564
565#[must_use]
567pub fn checksum_buffer(buffer: &Buffer, pool: &GraphemePool) -> u64 {
568 let width = buffer.width();
569 let height = buffer.height();
570
571 let mut hash = FNV_OFFSET_BASIS;
572 for y in 0..height {
573 for x in 0..width {
574 let cell = buffer.get_unchecked(x, y);
575 match cell.content {
576 CellContent::EMPTY => {
577 hash = fnv1a64_byte(hash, 0u8);
578 hash = fnv1a64_u16(hash, 0);
579 }
580 CellContent::CONTINUATION => {
581 hash = fnv1a64_byte(hash, 3u8);
582 hash = fnv1a64_u16(hash, 0);
583 }
584 content => {
585 if let Some(ch) = content.as_char() {
586 hash = fnv1a64_byte(hash, 1u8);
587 let mut buf = [0u8; 4];
588 let encoded = ch.encode_utf8(&mut buf);
589 let bytes = encoded.as_bytes();
590 let len = bytes.len().min(u16::MAX as usize) as u16;
591 hash = fnv1a64_u16(hash, len);
592 hash = fnv1a64_bytes(hash, &bytes[..len as usize]);
593 } else if let Some(gid) = content.grapheme_id() {
594 hash = fnv1a64_byte(hash, 2u8);
595 let text = pool.get(gid).unwrap_or("");
596 let bytes = text.as_bytes();
597 let len = bytes.len().min(u16::MAX as usize) as u16;
598 hash = fnv1a64_u16(hash, len);
599 hash = fnv1a64_bytes(hash, &bytes[..len as usize]);
600 } else {
601 hash = fnv1a64_byte(hash, 0u8);
602 hash = fnv1a64_u16(hash, 0);
603 }
604 }
605 }
606
607 hash = fnv1a64_u32(hash, cell.fg.0);
608 hash = fnv1a64_u32(hash, cell.bg.0);
609 let attrs = pack_attrs(cell.attrs);
610 hash = fnv1a64_u32(hash, attrs);
611 }
612 }
613 hash
614}
615
616#[must_use]
618pub fn build_full_buffer_payload(buffer: &Buffer, pool: &GraphemePool) -> RenderTracePayload {
619 let width = buffer.width();
620 let height = buffer.height();
621 let mut bytes = Vec::with_capacity(4 + (width as usize * height as usize * 16));
622 bytes.extend_from_slice(&width.to_le_bytes());
623 bytes.extend_from_slice(&height.to_le_bytes());
624 for y in 0..height {
625 for x in 0..width {
626 let cell = buffer.get_unchecked(x, y);
627 push_cell_bytes(&mut bytes, cell, pool);
628 }
629 }
630 RenderTracePayload {
631 kind: RenderTracePayloadKind::FullBufferV1,
632 bytes,
633 }
634}
635
636#[must_use]
638pub fn build_diff_runs_payload(
639 buffer: &Buffer,
640 diff: &BufferDiff,
641 pool: &GraphemePool,
642) -> RenderTracePayload {
643 let width = buffer.width();
644 let height = buffer.height();
645 let runs = diff.runs();
646 let mut bytes = Vec::with_capacity(12 + runs.len() * 24);
647 bytes.extend_from_slice(&width.to_le_bytes());
648 bytes.extend_from_slice(&height.to_le_bytes());
649 let run_count = runs.len() as u32;
650 bytes.extend_from_slice(&run_count.to_le_bytes());
651 for run in runs {
652 bytes.extend_from_slice(&run.y.to_le_bytes());
653 bytes.extend_from_slice(&run.x0.to_le_bytes());
654 bytes.extend_from_slice(&run.x1.to_le_bytes());
655 for x in run.x0..=run.x1 {
656 let cell = buffer.get_unchecked(x, run.y);
657 push_cell_bytes(&mut bytes, cell, pool);
658 }
659 }
660 RenderTracePayload {
661 kind: RenderTracePayloadKind::DiffRunsV1,
662 bytes,
663 }
664}
665
666fn pack_attrs(attrs: CellAttrs) -> u32 {
667 let flags = attrs.flags().bits() as u32;
668 let link = attrs.link_id() & 0x00FF_FFFF;
669 (flags << 24) | link
670}
671
672fn push_cell_bytes(out: &mut Vec<u8>, cell: &Cell, pool: &GraphemePool) {
673 match cell.content {
674 CellContent::EMPTY => {
675 out.push(0u8);
676 }
677 CellContent::CONTINUATION => {
678 out.push(3u8);
679 }
680 content => {
681 if let Some(ch) = content.as_char() {
682 out.push(1u8);
683 out.extend_from_slice(&(ch as u32).to_le_bytes());
684 } else if let Some(gid) = content.grapheme_id() {
685 out.push(2u8);
686 let text = pool.get(gid).unwrap_or("");
687 let bytes = text.as_bytes();
688 let len = bytes.len().min(u16::MAX as usize) as u16;
689 out.extend_from_slice(&len.to_le_bytes());
690 out.extend_from_slice(&bytes[..len as usize]);
691 } else {
692 out.push(0u8);
693 }
694 }
695 }
696 out.extend_from_slice(&cell.fg.0.to_le_bytes());
697 out.extend_from_slice(&cell.bg.0.to_le_bytes());
698 let attrs = pack_attrs(cell.attrs);
699 out.extend_from_slice(&attrs.to_le_bytes());
700}
701
702const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
703const FNV_PRIME: u64 = 0x100000001b3;
704
705fn fnv1a64_bytes(mut hash: u64, bytes: &[u8]) -> u64 {
706 let mut i = 0;
707 let len = bytes.len();
708 while i + 8 <= len {
709 hash ^= bytes[i] as u64;
710 hash = hash.wrapping_mul(FNV_PRIME);
711 hash ^= bytes[i + 1] as u64;
712 hash = hash.wrapping_mul(FNV_PRIME);
713 hash ^= bytes[i + 2] as u64;
714 hash = hash.wrapping_mul(FNV_PRIME);
715 hash ^= bytes[i + 3] as u64;
716 hash = hash.wrapping_mul(FNV_PRIME);
717 hash ^= bytes[i + 4] as u64;
718 hash = hash.wrapping_mul(FNV_PRIME);
719 hash ^= bytes[i + 5] as u64;
720 hash = hash.wrapping_mul(FNV_PRIME);
721 hash ^= bytes[i + 6] as u64;
722 hash = hash.wrapping_mul(FNV_PRIME);
723 hash ^= bytes[i + 7] as u64;
724 hash = hash.wrapping_mul(FNV_PRIME);
725 i += 8;
726 }
727 for &b in &bytes[i..] {
728 hash ^= b as u64;
729 hash = hash.wrapping_mul(FNV_PRIME);
730 }
731 hash
732}
733
734fn fnv1a64_byte(hash: u64, b: u8) -> u64 {
735 let mut hash = hash ^ (b as u64);
736 hash = hash.wrapping_mul(FNV_PRIME);
737 hash
738}
739
740fn fnv1a64_u16(hash: u64, v: u16) -> u64 {
741 fnv1a64_bytes(hash, &v.to_le_bytes())
742}
743
744fn fnv1a64_u32(hash: u64, v: u32) -> u64 {
745 fnv1a64_bytes(hash, &v.to_le_bytes())
746}
747
748fn fnv1a64_pair(prev: u64, next: u64) -> u64 {
749 let mut hash = FNV_OFFSET_BASIS;
750 hash = fnv1a64_u64(hash, prev);
751 fnv1a64_u64(hash, next)
752}
753
754fn fnv1a64_u64(hash: u64, v: u64) -> u64 {
755 fnv1a64_bytes(hash, &v.to_le_bytes())
756}
757
758fn default_render_trace_run_id() -> String {
759 format!("render-trace-{}", std::process::id())
760}
761
762fn now_ms() -> u64 {
763 SystemTime::now()
764 .duration_since(UNIX_EPOCH)
765 .map(|d| d.as_millis() as u64)
766 .unwrap_or(0)
767}
768
769fn opt_u64(v: Option<u64>) -> String {
770 v.map_or_else(|| "null".to_string(), |v| v.to_string())
771}
772
773fn opt_usize(v: Option<usize>) -> String {
774 v.map_or_else(|| "null".to_string(), |v| v.to_string())
775}
776
777fn opt_f64(v: Option<f64>) -> String {
778 v.map_or_else(|| "null".to_string(), |v| format!("{v:.6}"))
779}
780
781fn opt_str(v: Option<&str>) -> String {
782 v.map_or_else(|| "null".to_string(), |s| format!("\"{}\"", json_escape(s)))
783}
784
785fn json_escape(input: &str) -> String {
786 let mut out = String::with_capacity(input.len() + 8);
787 for ch in input.chars() {
788 match ch {
789 '"' => out.push_str("\\\""),
790 '\\' => out.push_str("\\\\"),
791 '\n' => out.push_str("\\n"),
792 '\r' => out.push_str("\\r"),
793 '\t' => out.push_str("\\t"),
794 c if c.is_control() => {
795 use std::fmt::Write as _;
796 let _ = write!(out, "\\u{:04x}", c as u32);
797 }
798 c => out.push(c),
799 }
800 }
801 out
802}
803
804#[cfg(test)]
805mod tests {
806 use super::*;
807 use ftui_render::buffer::Buffer;
808 use ftui_render::cell::Cell;
809
810 fn temp_trace_path(label: &str) -> PathBuf {
811 static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
812 let id = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
813 let mut path = std::env::temp_dir();
814 path.push(format!(
815 "ftui_render_trace_{}_{}_{}.jsonl",
816 label,
817 std::process::id(),
818 id
819 ));
820 path
821 }
822
823 #[test]
824 fn checksum_is_deterministic() {
825 let mut buffer = Buffer::new(4, 2);
826 buffer.set(0, 0, Cell::from_char('A'));
827 buffer.set(1, 0, Cell::from_char('B'));
828 let pool = GraphemePool::new();
829 let a = checksum_buffer(&buffer, &pool);
830 let b = checksum_buffer(&buffer, &pool);
831 assert_eq!(a, b);
832 }
833
834 #[test]
835 fn recorder_writes_header_frame_summary() {
836 let path = temp_trace_path("basic");
837 let config = RenderTraceConfig::enabled_file(&path);
838 let caps = TerminalCapabilities::default();
839 let context = RenderTraceContext {
840 capabilities: &caps,
841 diff_config: RuntimeDiffConfig::default(),
842 resize_config: CoalescerConfig::default(),
843 conformal_config: None,
844 };
845 let mut recorder = RenderTraceRecorder::from_config(&config, context)
846 .expect("config")
847 .expect("enabled");
848
849 let buffer = Buffer::new(2, 2);
850 let pool = GraphemePool::new();
851 let frame = RenderTraceFrame {
852 cols: 2,
853 rows: 2,
854 mode: "inline",
855 ui_height: 2,
856 ui_anchor: "bottom",
857 diff_strategy: "full",
858 diff_cells: 4,
859 diff_runs: 2,
860 present_bytes: 16,
861 render_us: None,
862 present_us: Some(10),
863 payload_kind: "none",
864 payload_path: None,
865 trace_us: Some(2),
866 };
867
868 recorder.record_frame(frame, &buffer, &pool).expect("frame");
869 recorder.finish(Some(42)).expect("finish");
870
871 let text = std::fs::read_to_string(path).expect("read");
872 assert!(text.contains("\"event\":\"trace_header\""));
873 assert!(text.contains("\"event\":\"frame\""));
874 assert!(text.contains("\"event\":\"trace_summary\""));
875 }
876
877 #[test]
880 fn json_escape_basic() {
881 assert_eq!(json_escape("hello"), "hello");
882 assert_eq!(json_escape(""), "");
883 }
884
885 #[test]
886 fn json_escape_special_chars() {
887 assert_eq!(json_escape(r#"say "hi""#), r#"say \"hi\""#);
888 assert_eq!(json_escape("back\\slash"), "back\\\\slash");
889 assert_eq!(json_escape("line\nbreak"), "line\\nbreak");
890 assert_eq!(json_escape("tab\there"), "tab\\there");
891 assert_eq!(json_escape("cr\rhere"), "cr\\rhere");
892 }
893
894 #[test]
895 fn json_escape_control_chars() {
896 let input = "a\x01b";
898 let escaped = json_escape(input);
899 assert_eq!(escaped, "a\\u0001b");
900 }
901
902 #[test]
903 fn opt_u64_some_none() {
904 assert_eq!(opt_u64(Some(42)), "42");
905 assert_eq!(opt_u64(None), "null");
906 assert_eq!(opt_u64(Some(0)), "0");
907 }
908
909 #[test]
910 fn opt_usize_some_none() {
911 assert_eq!(opt_usize(Some(100)), "100");
912 assert_eq!(opt_usize(None), "null");
913 }
914
915 #[test]
916 fn opt_f64_some_none() {
917 assert_eq!(opt_f64(None), "null");
918 let s = opt_f64(Some(0.5));
919 assert!(s.starts_with("0.5"), "got: {s}");
920 }
921
922 #[test]
923 fn opt_str_some_none() {
924 assert_eq!(opt_str(None), "null");
925 assert_eq!(opt_str(Some("test")), "\"test\"");
926 assert_eq!(opt_str(Some("with\"quote")), "\"with\\\"quote\"");
927 }
928
929 #[test]
932 fn fnv1a64_byte_deterministic() {
933 let a = fnv1a64_byte(FNV_OFFSET_BASIS, 0x42);
934 let b = fnv1a64_byte(FNV_OFFSET_BASIS, 0x42);
935 assert_eq!(a, b);
936 }
937
938 #[test]
939 fn fnv1a64_byte_differs_for_different_input() {
940 let a = fnv1a64_byte(FNV_OFFSET_BASIS, 0x01);
941 let b = fnv1a64_byte(FNV_OFFSET_BASIS, 0x02);
942 assert_ne!(a, b);
943 }
944
945 #[test]
946 fn fnv1a64_bytes_empty() {
947 let hash = fnv1a64_bytes(FNV_OFFSET_BASIS, &[]);
948 assert_eq!(hash, FNV_OFFSET_BASIS);
949 }
950
951 #[test]
952 fn fnv1a64_bytes_consistent_with_single_byte() {
953 let from_bytes = fnv1a64_bytes(FNV_OFFSET_BASIS, &[0x42]);
954 let from_byte = fnv1a64_byte(FNV_OFFSET_BASIS, 0x42);
955 assert_eq!(from_bytes, from_byte);
956 }
957
958 #[test]
959 fn fnv1a64_u16_is_le_bytes() {
960 let from_u16 = fnv1a64_u16(FNV_OFFSET_BASIS, 0x1234);
961 let from_bytes = fnv1a64_bytes(FNV_OFFSET_BASIS, &0x1234u16.to_le_bytes());
962 assert_eq!(from_u16, from_bytes);
963 }
964
965 #[test]
966 fn fnv1a64_u32_is_le_bytes() {
967 let from_u32 = fnv1a64_u32(FNV_OFFSET_BASIS, 0xDEAD_BEEF);
968 let from_bytes = fnv1a64_bytes(FNV_OFFSET_BASIS, &0xDEAD_BEEFu32.to_le_bytes());
969 assert_eq!(from_u32, from_bytes);
970 }
971
972 #[test]
973 fn fnv1a64_pair_deterministic() {
974 let a = fnv1a64_pair(123, 456);
975 let b = fnv1a64_pair(123, 456);
976 assert_eq!(a, b);
977 }
978
979 #[test]
980 fn fnv1a64_pair_differs_for_different_input() {
981 let a = fnv1a64_pair(123, 456);
982 let b = fnv1a64_pair(456, 123);
983 assert_ne!(a, b);
984 }
985
986 #[test]
987 fn fnv1a64_bytes_long_input() {
988 let data: Vec<u8> = (0..32).collect();
990 let hash = fnv1a64_bytes(FNV_OFFSET_BASIS, &data);
991 let hash2 = fnv1a64_bytes(FNV_OFFSET_BASIS, &data);
992 assert_eq!(hash, hash2);
993 let mut data2 = data.clone();
995 data2[15] = 255;
996 assert_ne!(hash, fnv1a64_bytes(FNV_OFFSET_BASIS, &data2));
997 }
998
999 #[test]
1002 fn config_default_is_disabled() {
1003 let config = RenderTraceConfig::default();
1004 assert!(!config.enabled);
1005 assert_eq!(config.output_path, PathBuf::from("trace.jsonl"));
1006 assert!(config.run_id.is_none());
1007 assert!(config.seed.is_none());
1008 assert!(config.test_module.is_none());
1009 assert!(config.flush_on_write);
1010 assert!(!config.include_start_ts_ms);
1011 }
1012
1013 #[test]
1014 fn config_enabled_file() {
1015 let config = RenderTraceConfig::enabled_file("/tmp/test.jsonl");
1016 assert!(config.enabled);
1017 assert_eq!(config.output_path, PathBuf::from("/tmp/test.jsonl"));
1018 }
1019
1020 #[test]
1021 fn config_builder_chain() {
1022 let config = RenderTraceConfig::enabled_file("/tmp/test.jsonl")
1023 .with_run_id("test-run-1")
1024 .with_seed(42)
1025 .with_test_module("my_module")
1026 .with_flush_on_write(false)
1027 .with_start_ts_ms(true);
1028
1029 assert!(config.enabled);
1030 assert_eq!(config.run_id.as_deref(), Some("test-run-1"));
1031 assert_eq!(config.seed, Some(42));
1032 assert_eq!(config.test_module.as_deref(), Some("my_module"));
1033 assert!(!config.flush_on_write);
1034 assert!(config.include_start_ts_ms);
1035 }
1036
1037 #[test]
1040 fn recorder_disabled_returns_none() {
1041 let config = RenderTraceConfig::default(); let caps = TerminalCapabilities::default();
1043 let context = RenderTraceContext {
1044 capabilities: &caps,
1045 diff_config: RuntimeDiffConfig::default(),
1046 resize_config: CoalescerConfig::default(),
1047 conformal_config: None,
1048 };
1049 let result = RenderTraceRecorder::from_config(&config, context).expect("no io error");
1050 assert!(result.is_none());
1051 }
1052
1053 #[test]
1056 fn recorder_finish_is_idempotent() {
1057 let path = temp_trace_path("idempotent");
1058 let config = RenderTraceConfig::enabled_file(&path);
1059 let caps = TerminalCapabilities::default();
1060 let context = RenderTraceContext {
1061 capabilities: &caps,
1062 diff_config: RuntimeDiffConfig::default(),
1063 resize_config: CoalescerConfig::default(),
1064 conformal_config: None,
1065 };
1066 let mut recorder = RenderTraceRecorder::from_config(&config, context)
1067 .expect("config")
1068 .expect("enabled");
1069
1070 recorder.finish(Some(10)).expect("first finish");
1071 recorder.finish(Some(20)).expect("second finish");
1072
1073 let text = std::fs::read_to_string(&path).expect("read");
1075 let summary_count = text.matches("\"event\":\"trace_summary\"").count();
1076 assert_eq!(summary_count, 1);
1077 }
1078
1079 #[test]
1082 fn checksum_1x1_buffer() {
1083 let buffer = Buffer::new(1, 1);
1084 let pool = GraphemePool::new();
1085 let hash = checksum_buffer(&buffer, &pool);
1086 let hash2 = checksum_buffer(&buffer, &pool);
1088 assert_eq!(hash, hash2);
1089 assert_ne!(hash, FNV_OFFSET_BASIS, "1x1 should differ from basis");
1090 }
1091
1092 #[test]
1093 fn checksum_differs_for_different_content() {
1094 let pool = GraphemePool::new();
1095 let mut buf_a = Buffer::new(2, 1);
1096 buf_a.set(0, 0, Cell::from_char('A'));
1097
1098 let mut buf_b = Buffer::new(2, 1);
1099 buf_b.set(0, 0, Cell::from_char('B'));
1100
1101 assert_ne!(
1102 checksum_buffer(&buf_a, &pool),
1103 checksum_buffer(&buf_b, &pool)
1104 );
1105 }
1106
1107 #[test]
1108 fn checksum_differs_for_different_dimensions() {
1109 let pool = GraphemePool::new();
1110 let buf_a = Buffer::new(2, 2);
1111 let buf_b = Buffer::new(3, 2);
1112 assert_ne!(
1114 checksum_buffer(&buf_a, &pool),
1115 checksum_buffer(&buf_b, &pool)
1116 );
1117 }
1118
1119 #[test]
1122 fn payload_kind_as_str() {
1123 assert_eq!(RenderTracePayloadKind::DiffRunsV1.as_str(), "diff_runs_v1");
1124 assert_eq!(
1125 RenderTracePayloadKind::FullBufferV1.as_str(),
1126 "full_buffer_v1"
1127 );
1128 }
1129
1130 #[test]
1133 fn build_full_buffer_payload_deterministic() {
1134 let mut buffer = Buffer::new(3, 2);
1135 buffer.set(0, 0, Cell::from_char('X'));
1136 buffer.set(1, 0, Cell::from_char('Y'));
1137 let pool = GraphemePool::new();
1138
1139 let p1 = build_full_buffer_payload(&buffer, &pool);
1140 let p2 = build_full_buffer_payload(&buffer, &pool);
1141 assert_eq!(p1.kind, RenderTracePayloadKind::FullBufferV1);
1142 assert_eq!(p1.bytes, p2.bytes);
1143 }
1144
1145 #[test]
1146 fn build_full_buffer_payload_starts_with_dimensions() {
1147 let buffer = Buffer::new(4, 3);
1148 let pool = GraphemePool::new();
1149 let payload = build_full_buffer_payload(&buffer, &pool);
1150
1151 assert!(payload.bytes.len() >= 4);
1153 let w = u16::from_le_bytes([payload.bytes[0], payload.bytes[1]]);
1154 let h = u16::from_le_bytes([payload.bytes[2], payload.bytes[3]]);
1155 assert_eq!(w, 4);
1156 assert_eq!(h, 3);
1157 }
1158
1159 #[test]
1162 fn pack_attrs_default() {
1163 let attrs = CellAttrs::default();
1164 let packed = pack_attrs(attrs);
1165 assert_eq!(packed, 0);
1167 }
1168
1169 #[test]
1172 fn frame_to_jsonl_valid_json() {
1173 let frame = RenderTraceFrame {
1174 cols: 80,
1175 rows: 24,
1176 mode: "inline",
1177 ui_height: 20,
1178 ui_anchor: "bottom",
1179 diff_strategy: "dirty_rows",
1180 diff_cells: 100,
1181 diff_runs: 5,
1182 present_bytes: 512,
1183 render_us: Some(50),
1184 present_us: Some(30),
1185 payload_kind: "full_buffer_v1",
1186 payload_path: Some("trace_payloads/frame_000000_full_buffer_v1.bin"),
1187 trace_us: Some(10),
1188 };
1189
1190 let line = frame.to_jsonl(0, 0xDEADBEEF, 0xCAFEBABE);
1191 assert!(line.starts_with('{'));
1192 assert!(line.ends_with('}'));
1193 assert!(line.contains("\"event\":\"frame\""));
1194 assert!(line.contains("\"frame_idx\":0"));
1195 assert!(line.contains("\"cols\":80"));
1196 assert!(line.contains("\"rows\":24"));
1197 assert!(line.contains("\"mode\":\"inline\""));
1198 assert!(line.contains("\"checksum\":\"00000000deadbeef\""));
1199 assert!(line.contains("\"checksum_chain\":\"00000000cafebabe\""));
1200 assert!(line.contains("\"diff_strategy\":\"dirty_rows\""));
1201 }
1202
1203 #[test]
1204 fn frame_to_jsonl_null_optionals() {
1205 let frame = RenderTraceFrame {
1206 cols: 10,
1207 rows: 5,
1208 mode: "alt",
1209 ui_height: 5,
1210 ui_anchor: "top",
1211 diff_strategy: "full",
1212 diff_cells: 50,
1213 diff_runs: 1,
1214 present_bytes: 100,
1215 render_us: None,
1216 present_us: None,
1217 payload_kind: "none",
1218 payload_path: None,
1219 trace_us: None,
1220 };
1221
1222 let line = frame.to_jsonl(1, 0, 0);
1223 assert!(line.contains("\"render_us\":null"));
1224 assert!(line.contains("\"present_us\":null"));
1225 assert!(line.contains("\"payload_path\":null"));
1226 assert!(line.contains("\"trace_us\":null"));
1227 }
1228
1229 #[test]
1230 fn summary_to_jsonl_format() {
1231 let summary = RenderTraceSummary {
1232 total_frames: 100,
1233 final_checksum_chain: 0xABCDEF0123456789,
1234 elapsed_ms: Some(5000),
1235 };
1236 let line = summary.to_jsonl();
1237 assert!(line.contains("\"event\":\"trace_summary\""));
1238 assert!(line.contains("\"total_frames\":100"));
1239 assert!(line.contains("\"final_checksum_chain\":\"abcdef0123456789\""));
1240 assert!(line.contains("\"elapsed_ms\":5000"));
1241 }
1242
1243 #[test]
1244 fn summary_to_jsonl_null_elapsed() {
1245 let summary = RenderTraceSummary {
1246 total_frames: 0,
1247 final_checksum_chain: 0,
1248 elapsed_ms: None,
1249 };
1250 let line = summary.to_jsonl();
1251 assert!(line.contains("\"elapsed_ms\":null"));
1252 }
1253
1254 #[test]
1257 fn header_to_jsonl_format() {
1258 let header = RenderTraceHeader {
1259 run_id: "test-run".to_string(),
1260 seed: Some(42),
1261 env: RenderTraceEnv {
1262 os: "linux".to_string(),
1263 arch: "x86_64".to_string(),
1264 test_module: Some("my_test".to_string()),
1265 },
1266 capabilities: RenderTraceCapabilities {
1267 profile: "kitty".to_string(),
1268 true_color: true,
1269 colors_256: true,
1270 sync_output: true,
1271 osc8_hyperlinks: false,
1272 scroll_region: true,
1273 in_tmux: false,
1274 in_screen: false,
1275 in_zellij: false,
1276 kitty_keyboard: true,
1277 focus_events: true,
1278 bracketed_paste: true,
1279 mouse_sgr: true,
1280 osc52_clipboard: false,
1281 },
1282 policies: RenderTracePolicies {
1283 diff_bayesian: true,
1284 diff_dirty_rows: true,
1285 diff_dirty_spans: false,
1286 diff_guard_band: 2,
1287 diff_merge_gap: 4,
1288 bocpd_enabled: true,
1289 steady_delay_ms: 100,
1290 burst_delay_ms: 16,
1291 conformal_enabled: false,
1292 conformal_alpha: None,
1293 conformal_min_samples: None,
1294 conformal_window_size: None,
1295 },
1296 start_ts_ms: None,
1297 };
1298
1299 let line = header.to_jsonl();
1300 assert!(line.contains("\"event\":\"trace_header\""));
1301 assert!(line.contains("\"schema_version\":\"render-trace-v1\""));
1302 assert!(line.contains("\"run_id\":\"test-run\""));
1303 assert!(line.contains("\"seed\":42"));
1304 assert!(line.contains("\"start_ts_ms\":null"));
1305 }
1306
1307 #[test]
1310 fn env_to_json_format() {
1311 let env = RenderTraceEnv {
1312 os: "linux".to_string(),
1313 arch: "x86_64".to_string(),
1314 test_module: None,
1315 };
1316 let json = env.to_json();
1317 assert!(json.contains("\"os\":\"linux\""));
1318 assert!(json.contains("\"arch\":\"x86_64\""));
1319 assert!(json.contains("\"test_module\":null"));
1320 }
1321
1322 #[test]
1323 fn env_to_json_with_test_module() {
1324 let env = RenderTraceEnv {
1325 os: "macos".to_string(),
1326 arch: "aarch64".to_string(),
1327 test_module: Some("integration".to_string()),
1328 };
1329 let json = env.to_json();
1330 assert!(json.contains("\"test_module\":\"integration\""));
1331 }
1332
1333 #[test]
1336 fn capabilities_to_json_format() {
1337 let caps = RenderTraceCapabilities {
1338 profile: "xterm".to_string(),
1339 true_color: false,
1340 colors_256: true,
1341 sync_output: false,
1342 osc8_hyperlinks: false,
1343 scroll_region: true,
1344 in_tmux: true,
1345 in_screen: false,
1346 in_zellij: false,
1347 kitty_keyboard: false,
1348 focus_events: false,
1349 bracketed_paste: true,
1350 mouse_sgr: false,
1351 osc52_clipboard: false,
1352 };
1353 let json = caps.to_json();
1354 assert!(json.contains("\"profile\":\"xterm\""));
1355 assert!(json.contains("\"true_color\":false"));
1356 assert!(json.contains("\"in_tmux\":true"));
1357 }
1358
1359 #[test]
1362 fn policies_to_json_with_conformal() {
1363 let policies = RenderTracePolicies {
1364 diff_bayesian: true,
1365 diff_dirty_rows: true,
1366 diff_dirty_spans: true,
1367 diff_guard_band: 3,
1368 diff_merge_gap: 5,
1369 bocpd_enabled: true,
1370 steady_delay_ms: 100,
1371 burst_delay_ms: 16,
1372 conformal_enabled: true,
1373 conformal_alpha: Some(0.05),
1374 conformal_min_samples: Some(10),
1375 conformal_window_size: Some(100),
1376 };
1377 let json = policies.to_json();
1378 assert!(json.contains("\"diff\":{"));
1379 assert!(json.contains("\"bocpd\":{"));
1380 assert!(json.contains("\"conformal\":{"));
1381 assert!(json.contains("\"enabled\":true"));
1382 assert!(json.contains("\"guard_band\":3"));
1383 }
1384
1385 #[test]
1386 fn policies_to_json_without_conformal() {
1387 let policies = RenderTracePolicies {
1388 diff_bayesian: false,
1389 diff_dirty_rows: false,
1390 diff_dirty_spans: false,
1391 diff_guard_band: 0,
1392 diff_merge_gap: 0,
1393 bocpd_enabled: false,
1394 steady_delay_ms: 0,
1395 burst_delay_ms: 0,
1396 conformal_enabled: false,
1397 conformal_alpha: None,
1398 conformal_min_samples: None,
1399 conformal_window_size: None,
1400 };
1401 let json = policies.to_json();
1402 assert!(json.contains("\"alpha\":null"));
1403 assert!(json.contains("\"min_samples\":null"));
1404 assert!(json.contains("\"window_size\":null"));
1405 }
1406
1407 #[test]
1410 fn write_payload_creates_file() {
1411 let path = temp_trace_path("payload");
1412 let config = RenderTraceConfig::enabled_file(&path);
1413 let caps = TerminalCapabilities::default();
1414 let context = RenderTraceContext {
1415 capabilities: &caps,
1416 diff_config: RuntimeDiffConfig::default(),
1417 resize_config: CoalescerConfig::default(),
1418 conformal_config: None,
1419 };
1420 let mut recorder = RenderTraceRecorder::from_config(&config, context)
1421 .expect("config")
1422 .expect("enabled");
1423
1424 let payload = RenderTracePayload {
1425 kind: RenderTracePayloadKind::FullBufferV1,
1426 bytes: vec![1, 2, 3, 4],
1427 };
1428 let info = recorder.write_payload(&payload).expect("write");
1429 assert_eq!(info.kind, "full_buffer_v1");
1430 assert!(info.path.contains("frame_000000"));
1431 assert!(info.path.contains("full_buffer_v1.bin"));
1432 }
1433
1434 #[test]
1437 fn record_multiple_frames_increments_index() {
1438 let path = temp_trace_path("multi");
1439 let config = RenderTraceConfig::enabled_file(&path);
1440 let caps = TerminalCapabilities::default();
1441 let context = RenderTraceContext {
1442 capabilities: &caps,
1443 diff_config: RuntimeDiffConfig::default(),
1444 resize_config: CoalescerConfig::default(),
1445 conformal_config: None,
1446 };
1447 let mut recorder = RenderTraceRecorder::from_config(&config, context)
1448 .expect("config")
1449 .expect("enabled");
1450
1451 let buffer = Buffer::new(2, 1);
1452 let pool = GraphemePool::new();
1453
1454 for _ in 0..3 {
1455 let frame = RenderTraceFrame {
1456 cols: 2,
1457 rows: 1,
1458 mode: "inline",
1459 ui_height: 1,
1460 ui_anchor: "bottom",
1461 diff_strategy: "full",
1462 diff_cells: 2,
1463 diff_runs: 1,
1464 present_bytes: 8,
1465 render_us: None,
1466 present_us: None,
1467 payload_kind: "none",
1468 payload_path: None,
1469 trace_us: None,
1470 };
1471 recorder.record_frame(frame, &buffer, &pool).expect("frame");
1472 }
1473 recorder.finish(None).expect("finish");
1474
1475 let text = std::fs::read_to_string(&path).expect("read");
1476 assert!(text.contains("\"frame_idx\":0"));
1477 assert!(text.contains("\"frame_idx\":1"));
1478 assert!(text.contains("\"frame_idx\":2"));
1479 }
1480
1481 #[test]
1484 fn recorder_header_includes_seed_and_run_id() {
1485 let path = temp_trace_path("seed");
1486 let config = RenderTraceConfig::enabled_file(&path)
1487 .with_run_id("my-test-run")
1488 .with_seed(12345)
1489 .with_test_module("test_mod");
1490 let caps = TerminalCapabilities::default();
1491 let context = RenderTraceContext {
1492 capabilities: &caps,
1493 diff_config: RuntimeDiffConfig::default(),
1494 resize_config: CoalescerConfig::default(),
1495 conformal_config: None,
1496 };
1497 let mut recorder = RenderTraceRecorder::from_config(&config, context)
1498 .expect("config")
1499 .expect("enabled");
1500 recorder.finish(None).expect("finish");
1501
1502 let text = std::fs::read_to_string(&path).expect("read");
1503 assert!(text.contains("\"run_id\":\"my-test-run\""));
1504 assert!(text.contains("\"seed\":12345"));
1505 assert!(text.contains("\"test_module\":\"test_mod\""));
1506 }
1507}