Skip to main content

ferridriver_test/
tracing.rs

1//! Trace recording in Playwright-compatible format.
2//!
3//! Produces a ZIP file containing:
4//! - `test.trace` — JSONL: one JSON event per line
5//! - `resources/` — screenshots and attachments keyed by SHA1
6//!
7//! Compatible with `npx playwright show-trace trace.zip`.
8//!
9//! Trace modes (matching Playwright):
10//! - `Off` — no tracing
11//! - `On` — always record
12//! - `RetainOnFailure` — record but only keep if test fails
13//! - `OnFirstRetry` — record only on first retry attempt
14//!
15//! Performance: zero cost when `Off`. When enabled, event construction is
16//! allocation-light (`Cow<'static, str>` for fixed strings, pre-sized Vec),
17//! ZIP uses `Stored` compression (no deflate CPU), and `serde_json::to_writer`
18//! streams directly into the ZIP — no intermediate String allocations.
19//! The worker offloads the file write to `spawn_blocking` so it never blocks
20//! the async runtime.
21
22use std::borrow::Cow;
23use std::io::Write;
24use std::path::Path;
25use std::time::{SystemTime, UNIX_EPOCH};
26
27use serde::Serialize;
28
29pub use ferridriver_config::test::TraceMode;
30
31use crate::model::TestStep;
32
33/// A single trace event (Playwright format v8).
34///
35/// Uses `Cow<'static, str>` for fields that are almost always static literals,
36/// avoiding heap allocation for the common case.
37#[derive(Serialize)]
38#[serde(tag = "type")]
39enum TraceEvent<'a> {
40  #[serde(rename = "context-options")]
41  ContextOptions {
42    #[serde(rename = "browserName")]
43    browser_name: &'static str,
44    platform: &'static str,
45    #[serde(rename = "wallTime")]
46    wall_time: u64,
47    #[serde(rename = "sdkLanguage")]
48    sdk_language: &'static str,
49  },
50  #[serde(rename = "before")]
51  Before {
52    #[serde(rename = "callId")]
53    call_id: Cow<'a, str>,
54    #[serde(rename = "startTime")]
55    start_time: u64,
56    class: &'static str,
57    method: Cow<'a, str>,
58    title: Cow<'a, str>,
59    #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
60    parent_id: Option<Cow<'a, str>>,
61  },
62  #[serde(rename = "after")]
63  After {
64    #[serde(rename = "callId")]
65    call_id: Cow<'a, str>,
66    #[serde(rename = "endTime")]
67    end_time: u64,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    error: Option<&'a str>,
70  },
71}
72
73/// Count total events needed for a step tree (2 per step: before + after).
74fn count_events(steps: &[TestStep]) -> usize {
75  steps.iter().map(|s| 2 + count_events(&s.steps)).sum()
76}
77
78/// Records trace events for a single test.
79pub struct TraceRecorder<'a> {
80  events: Vec<TraceEvent<'a>>,
81  call_counter: u32,
82  wall_time: u64,
83}
84
85impl<'a> TraceRecorder<'a> {
86  /// Create a new recorder pre-sized for the given step count.
87  #[must_use]
88  pub fn for_steps(steps: &[TestStep]) -> Self {
89    let capacity = 1 + count_events(steps); // +1 for context-options
90    let wall_time = SystemTime::now()
91      .duration_since(UNIX_EPOCH)
92      .unwrap_or_default()
93      .as_millis() as u64;
94
95    let mut events = Vec::with_capacity(capacity);
96    events.push(TraceEvent::ContextOptions {
97      browser_name: "chromium",
98      platform: std::env::consts::OS,
99      wall_time,
100      sdk_language: "rust",
101    });
102
103    Self {
104      events,
105      call_counter: 0,
106      wall_time,
107    }
108  }
109
110  /// Record a test step as before/after events (borrows step data, no cloning).
111  pub fn record_step(&mut self, step: &'a TestStep, parent_id: Option<Cow<'a, str>>) {
112    self.call_counter += 1;
113    let call_id: Cow<'a, str> = Cow::Owned(format!("s{}", self.call_counter));
114
115    self.events.push(TraceEvent::Before {
116      call_id: call_id.clone(),
117      start_time: self.wall_time.saturating_sub(step.duration.as_millis() as u64),
118      class: "Test",
119      method: Cow::Owned(step.category.to_string()),
120      title: Cow::Borrowed(&step.title),
121      parent_id,
122    });
123
124    // Record nested steps.
125    for child in &step.steps {
126      self.record_step(child, Some(call_id.clone()));
127    }
128
129    self.events.push(TraceEvent::After {
130      call_id,
131      end_time: self.wall_time,
132      error: step.error.as_deref(),
133    });
134  }
135
136  /// Record all steps from a test's outcome.
137  pub fn record_steps(&mut self, steps: &'a [TestStep]) {
138    for step in steps {
139      self.record_step(step, None);
140    }
141  }
142
143  /// Serialize all events into an in-memory JSONL+ZIP buffer.
144  ///
145  /// Uses `Stored` compression (no deflate CPU) and `serde_json::to_writer`
146  /// streaming directly into the ZIP — no intermediate String per event.
147  /// Returns owned bytes suitable for `spawn_blocking` file write.
148  ///
149  /// # Errors
150  ///
151  /// Returns an error if serialization fails (should never happen).
152  pub fn into_zip_bytes(self) -> Result<Vec<u8>, String> {
153    let mut buf = Vec::with_capacity(256 + self.events.len() * 128);
154    let cursor = std::io::Cursor::new(&mut buf);
155    let mut zip = zip::ZipWriter::new(cursor);
156
157    let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
158
159    zip
160      .start_file("test.trace", options)
161      .map_err(|e| format!("zip start_file: {e}"))?;
162
163    for event in &self.events {
164      serde_json::to_writer(&mut zip, event).map_err(|e| format!("serialize trace event: {e}"))?;
165      zip.write_all(b"\n").map_err(|e| format!("write newline: {e}"))?;
166    }
167
168    zip.finish().map_err(|e| format!("zip finish: {e}"))?;
169    Ok(buf)
170  }
171}
172
173/// Write pre-serialized ZIP bytes to a file. Designed for `spawn_blocking`.
174///
175/// # Errors
176///
177/// Returns an error if file I/O fails.
178pub fn write_trace_file(path: &Path, data: &[u8]) -> ferridriver::error::Result<()> {
179  if let Some(parent) = path.parent() {
180    std::fs::create_dir_all(parent)?;
181  }
182  std::fs::write(path, data)?;
183  Ok(())
184}