Skip to main content

playwright_rs_trace/
trace.rs

1//! [`TraceReader`] — open a Playwright trace zip and stream its
2//! contents lazily.
3
4use crate::action::{Action, ActionStream};
5use crate::error::{Result, TraceError};
6use crate::event::{ContextOptions, RawEvent, TraceEvent};
7use crate::jsonl::JsonLines;
8use crate::network::NetworkEntry;
9use std::io::{BufRead, BufReader, Read, Seek};
10use std::path::Path;
11use zip::ZipArchive;
12use zip::result::ZipError;
13
14const TRACE_ENTRY: &str = "trace.trace";
15const NETWORK_ENTRY: &str = "trace.network";
16/// Trace formats this parser reads. v9 gave frame snapshots a `phase`
17/// and wrote blob references as whole archive paths; all formats parse,
18/// and a reference from any opens through [`TraceReader::blob`]. v10
19/// dropped `stepId` and takes call ids from the client (`abcd@7`, where
20/// v9 wrote `call@7`), which changes no field this parser types.
21const SUPPORTED_VERSIONS: std::ops::RangeInclusive<u32> = 8..=10;
22const RESOURCE_SNAPSHOT_KIND: &str = "resource-snapshot";
23
24/// Streaming reader over a Playwright trace zip.
25///
26/// Opens the archive and parses the first event (`context-options`)
27/// eagerly so the trace's metadata is available without consuming the
28/// rest of the stream. Subsequent calls to
29/// [`raw_events`](Self::raw_events), [`events`](Self::events), or
30/// [`actions`](Self::actions) iterate the remaining events lazily;
31/// each call extracts a fresh JSONL stream from the archive, so the
32/// reader can be iterated multiple times.
33pub struct TraceReader<R: Read + Seek> {
34    zip: ZipArchive<R>,
35    context: ContextOptions,
36}
37
38impl<R: Read + Seek> TraceReader<R> {
39    /// Open a trace from any `Read + Seek` source. For the typical
40    /// file-on-disk case prefer [`crate::open`].
41    ///
42    /// Every entry's local header is checked against the central directory
43    /// before anything is parsed. The check covers the archive as it stands
44    /// at open; later reads from the same source are not checked again.
45    ///
46    /// # Errors
47    ///
48    /// - [`TraceError::Zip`] when the source is not a readable zip archive.
49    /// - [`TraceError::InconsistentArchive`] when its headers disagree, or
50    ///   its entries repeat a name or overlap.
51    /// - [`TraceError::MissingEntry`] when it has no `trace.trace`, or that
52    ///   entry holds no events.
53    /// - [`TraceError::Json`] or [`TraceError::MalformedAction`] when the
54    ///   first event is not valid `context-options` JSON.
55    /// - [`TraceError::UnsupportedVersion`] for a trace format outside 8 to 10.
56    /// - [`TraceError::Io`] when reading the source fails.
57    pub fn open(reader: R) -> Result<Self> {
58        let mut zip = ZipArchive::new(reader)?;
59        zip.validate_header_consistency()
60            .map_err(consistency_error)?;
61        let context = parse_context(&mut zip)?;
62        if !SUPPORTED_VERSIONS.contains(&context.version) {
63            return Err(TraceError::UnsupportedVersion {
64                found: context.version,
65                supported: SUPPORTED_VERSIONS,
66            });
67        }
68        Ok(Self { zip, context })
69    }
70
71    /// The `context-options` metadata from the trace's first event.
72    pub fn context(&self) -> &ContextOptions {
73        &self.context
74    }
75
76    /// Lossless stream of every JSONL event in `trace.trace`. Yields a
77    /// [`RawEvent`] per line; callers can dispatch on
78    /// [`RawEvent::kind`](crate::RawEvent::kind) to handle event types
79    /// the typed enum doesn't model.
80    ///
81    /// The first event (`context-options`) is **included** in the
82    /// stream; if you only need it, [`context`](Self::context) is
83    /// already cached.
84    pub fn raw_events(&mut self) -> Result<impl Iterator<Item = Result<RawEvent>>> {
85        let entry = self.zip.by_name(TRACE_ENTRY)?;
86        let lines = JsonLines::new(BufReader::new(entry));
87        Ok(lines.map(|res| res.map(RawEvent::new)))
88    }
89
90    /// Typed stream of events. Wraps [`raw_events`](Self::raw_events)
91    /// and routes each [`RawEvent`] through
92    /// [`RawEvent::into_typed`](crate::RawEvent::into_typed). Unknown
93    /// or unmodelled kinds surface as [`TraceEvent::Unknown`].
94    pub fn events(&mut self) -> Result<impl Iterator<Item = Result<TraceEvent>>> {
95        Ok(self
96            .raw_events()?
97            .map(|res| res.map(|raw| raw.into_typed())))
98    }
99
100    /// Reassembled action stream — `before` + optional `input` + zero-
101    /// or-more `log` + `after` events sharing a `call_id` are merged
102    /// into one [`Action`].
103    ///
104    /// Actions are yielded in `after`-arrival order, **not** strictly
105    /// in `start_time` order — concurrent calls can interleave.
106    /// Callers wanting chronological order should collect into a
107    /// `Vec` and sort by [`Action::start_time`](crate::Action::start_time).
108    ///
109    /// Truncated actions (no matching `after` event, e.g. a trace cut
110    /// short by a crash) are emitted at end-of-stream with
111    /// `end_time = None` rather than discarded.
112    pub fn actions(&mut self) -> Result<impl Iterator<Item = Result<Action>>> {
113        Ok(ActionStream::new(self.events()?))
114    }
115
116    /// The bytes of a blob the trace refers to: a screencast frame, a
117    /// response body, a snapshot resource. `path` is the archive path an
118    /// event carries in its `file` field.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`TraceError::Zip`] when the archive holds no such entry.
123    pub fn blob(&mut self, path: &str) -> Result<Vec<u8>> {
124        let mut entry = self.zip.by_name(path)?;
125        let mut bytes = Vec::new();
126        entry.read_to_end(&mut bytes)?;
127        Ok(bytes)
128    }
129
130    /// Streaming iterator over [`NetworkEntry`] records from
131    /// `trace.network`. Yields zero items when the trace recorded no
132    /// requests (the entry is present but empty).
133    ///
134    /// HAR fields not modelled on [`NetworkEntry`] are preserved on
135    /// [`NetworkEntry::raw_snapshot`].
136    pub fn network(&mut self) -> Result<impl Iterator<Item = Result<NetworkEntry>>> {
137        let entry = self.zip.by_name(NETWORK_ENTRY)?;
138        let lines = JsonLines::new(BufReader::new(entry));
139        Ok(lines.map(|res| {
140            let mut map = res?;
141            // Check the discriminator before deserialising the
142            // payload — otherwise serde rejects an unexpected kind
143            // with a confusing "missing field `snapshot`" message.
144            let kind = map
145                .get("type")
146                .and_then(|v| v.as_str())
147                .unwrap_or("")
148                .to_string();
149            if kind != RESOURCE_SNAPSHOT_KIND {
150                return Err(TraceError::MalformedAction {
151                    call_id: String::new(),
152                    reason: format!(
153                        "trace.network: expected `{RESOURCE_SNAPSHOT_KIND}` event, got `{kind}`",
154                    ),
155                });
156            }
157            let snapshot = map
158                .remove("snapshot")
159                .ok_or_else(|| TraceError::MalformedAction {
160                    call_id: String::new(),
161                    reason: "trace.network: resource-snapshot missing `snapshot` payload".into(),
162                })?;
163            NetworkEntry::from_snapshot(snapshot)
164                .map_err(|source| TraceError::Json { line: 0, source })
165        }))
166    }
167}
168
169/// An I/O failure during the consistency pass says nothing about the
170/// archive's headers, so it surfaces like any other failed zip read.
171fn consistency_error(error: ZipError) -> TraceError {
172    match error {
173        ZipError::Io(_) => TraceError::Zip(error),
174        other => TraceError::InconsistentArchive(other),
175    }
176}
177
178fn parse_context<R: Read + Seek>(zip: &mut ZipArchive<R>) -> Result<ContextOptions> {
179    let entry = zip
180        .by_name(TRACE_ENTRY)
181        .map_err(|_| TraceError::MissingEntry(TRACE_ENTRY))?;
182    let mut reader = BufReader::new(entry);
183    let mut line = String::new();
184    let mut line_no = 0;
185
186    loop {
187        line.clear();
188        line_no += 1;
189        let n = reader.read_line(&mut line)?;
190        if n == 0 {
191            return Err(TraceError::MissingEntry(TRACE_ENTRY));
192        }
193        let trimmed = line.trim_end_matches(['\n', '\r']);
194        if trimmed.trim().is_empty() {
195            continue;
196        }
197
198        let value: serde_json::Value =
199            serde_json::from_str(trimmed).map_err(|source| TraceError::Json {
200                line: line_no,
201                source,
202            })?;
203
204        let kind = value.get("type").and_then(|v| v.as_str()).unwrap_or("");
205        if kind != "context-options" {
206            return Err(TraceError::MalformedAction {
207                call_id: String::new(),
208                reason: format!("expected first event to be `context-options`, got `{kind}`"),
209            });
210        }
211
212        return serde_json::from_value::<ContextOptions>(value).map_err(|source| {
213            TraceError::Json {
214                line: line_no,
215                source,
216            }
217        });
218    }
219}
220
221/// Convenience wrapper for [`TraceReader::open`] over a file on disk.
222pub fn open<P: AsRef<Path>>(path: P) -> Result<TraceReader<std::fs::File>> {
223    let file = std::fs::File::open(path)?;
224    TraceReader::open(file)
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn an_io_failure_during_the_consistency_pass_is_not_a_rejection() {
233        let error = consistency_error(ZipError::Io(std::io::Error::other("disk gone")));
234        assert!(
235            matches!(error, TraceError::Zip(ZipError::Io(_))),
236            "got {error:?}"
237        );
238    }
239
240    #[test]
241    fn a_header_mismatch_is_a_rejection() {
242        let error = consistency_error(ZipError::InvalidArchive("mismatch".into()));
243        assert!(
244            matches!(
245                error,
246                TraceError::InconsistentArchive(ZipError::InvalidArchive(_))
247            ),
248            "got {error:?}"
249        );
250    }
251}