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;
12
13const TRACE_ENTRY: &str = "trace.trace";
14const NETWORK_ENTRY: &str = "trace.network";
15/// Trace formats this parser reads. v9 gave frame snapshots a `phase`
16/// and wrote blob references as whole archive paths; all formats parse,
17/// and a reference from any opens through [`TraceReader::blob`]. v10
18/// dropped `stepId` and takes call ids from the client (`abcd@7`, where
19/// v9 wrote `call@7`), which changes no field this parser types.
20const SUPPORTED_VERSIONS: std::ops::RangeInclusive<u32> = 8..=10;
21const RESOURCE_SNAPSHOT_KIND: &str = "resource-snapshot";
22
23/// Streaming reader over a Playwright trace zip.
24///
25/// Opens the archive and parses the first event (`context-options`)
26/// eagerly so the trace's metadata is available without consuming the
27/// rest of the stream. Subsequent calls to
28/// [`raw_events`](Self::raw_events), [`events`](Self::events), or
29/// [`actions`](Self::actions) iterate the remaining events lazily;
30/// each call extracts a fresh JSONL stream from the archive, so the
31/// reader can be iterated multiple times.
32pub struct TraceReader<R: Read + Seek> {
33    zip: ZipArchive<R>,
34    context: ContextOptions,
35}
36
37impl<R: Read + Seek> TraceReader<R> {
38    /// Open a trace from any `Read + Seek` source. For the typical
39    /// file-on-disk case prefer [`crate::open`].
40    pub fn open(reader: R) -> Result<Self> {
41        let mut zip = ZipArchive::new(reader)?;
42        let context = parse_context(&mut zip)?;
43        if !SUPPORTED_VERSIONS.contains(&context.version) {
44            return Err(TraceError::UnsupportedVersion {
45                found: context.version,
46                supported: SUPPORTED_VERSIONS,
47            });
48        }
49        Ok(Self { zip, context })
50    }
51
52    /// The `context-options` metadata from the trace's first event.
53    pub fn context(&self) -> &ContextOptions {
54        &self.context
55    }
56
57    /// Lossless stream of every JSONL event in `trace.trace`. Yields a
58    /// [`RawEvent`] per line; callers can dispatch on
59    /// [`RawEvent::kind`](crate::RawEvent::kind) to handle event types
60    /// the typed enum doesn't model.
61    ///
62    /// The first event (`context-options`) is **included** in the
63    /// stream; if you only need it, [`context`](Self::context) is
64    /// already cached.
65    pub fn raw_events(&mut self) -> Result<impl Iterator<Item = Result<RawEvent>>> {
66        let entry = self.zip.by_name(TRACE_ENTRY)?;
67        let lines = JsonLines::new(BufReader::new(entry));
68        Ok(lines.map(|res| res.map(RawEvent::new)))
69    }
70
71    /// Typed stream of events. Wraps [`raw_events`](Self::raw_events)
72    /// and routes each [`RawEvent`] through
73    /// [`RawEvent::into_typed`](crate::RawEvent::into_typed). Unknown
74    /// or unmodelled kinds surface as [`TraceEvent::Unknown`].
75    pub fn events(&mut self) -> Result<impl Iterator<Item = Result<TraceEvent>>> {
76        Ok(self
77            .raw_events()?
78            .map(|res| res.map(|raw| raw.into_typed())))
79    }
80
81    /// Reassembled action stream — `before` + optional `input` + zero-
82    /// or-more `log` + `after` events sharing a `call_id` are merged
83    /// into one [`Action`].
84    ///
85    /// Actions are yielded in `after`-arrival order, **not** strictly
86    /// in `start_time` order — concurrent calls can interleave.
87    /// Callers wanting chronological order should collect into a
88    /// `Vec` and sort by [`Action::start_time`](crate::Action::start_time).
89    ///
90    /// Truncated actions (no matching `after` event, e.g. a trace cut
91    /// short by a crash) are emitted at end-of-stream with
92    /// `end_time = None` rather than discarded.
93    pub fn actions(&mut self) -> Result<impl Iterator<Item = Result<Action>>> {
94        Ok(ActionStream::new(self.events()?))
95    }
96
97    /// The bytes of a blob the trace refers to: a screencast frame, a
98    /// response body, a snapshot resource. `path` is the archive path an
99    /// event carries in its `file` field.
100    ///
101    /// # Errors
102    ///
103    /// Returns [`TraceError::Zip`] when the archive holds no such entry.
104    pub fn blob(&mut self, path: &str) -> Result<Vec<u8>> {
105        let mut entry = self.zip.by_name(path)?;
106        let mut bytes = Vec::new();
107        entry.read_to_end(&mut bytes)?;
108        Ok(bytes)
109    }
110
111    /// Streaming iterator over [`NetworkEntry`] records from
112    /// `trace.network`. Yields zero items when the trace recorded no
113    /// requests (the entry is present but empty).
114    ///
115    /// HAR fields not modelled on [`NetworkEntry`] are preserved on
116    /// [`NetworkEntry::raw_snapshot`].
117    pub fn network(&mut self) -> Result<impl Iterator<Item = Result<NetworkEntry>>> {
118        let entry = self.zip.by_name(NETWORK_ENTRY)?;
119        let lines = JsonLines::new(BufReader::new(entry));
120        Ok(lines.map(|res| {
121            let mut map = res?;
122            // Check the discriminator before deserialising the
123            // payload — otherwise serde rejects an unexpected kind
124            // with a confusing "missing field `snapshot`" message.
125            let kind = map
126                .get("type")
127                .and_then(|v| v.as_str())
128                .unwrap_or("")
129                .to_string();
130            if kind != RESOURCE_SNAPSHOT_KIND {
131                return Err(TraceError::MalformedAction {
132                    call_id: String::new(),
133                    reason: format!(
134                        "trace.network: expected `{RESOURCE_SNAPSHOT_KIND}` event, got `{kind}`",
135                    ),
136                });
137            }
138            let snapshot = map
139                .remove("snapshot")
140                .ok_or_else(|| TraceError::MalformedAction {
141                    call_id: String::new(),
142                    reason: "trace.network: resource-snapshot missing `snapshot` payload".into(),
143                })?;
144            NetworkEntry::from_snapshot(snapshot)
145                .map_err(|source| TraceError::Json { line: 0, source })
146        }))
147    }
148}
149
150fn parse_context<R: Read + Seek>(zip: &mut ZipArchive<R>) -> Result<ContextOptions> {
151    let entry = zip
152        .by_name(TRACE_ENTRY)
153        .map_err(|_| TraceError::MissingEntry(TRACE_ENTRY))?;
154    let mut reader = BufReader::new(entry);
155    let mut line = String::new();
156    let mut line_no = 0;
157
158    loop {
159        line.clear();
160        line_no += 1;
161        let n = reader.read_line(&mut line)?;
162        if n == 0 {
163            return Err(TraceError::MissingEntry(TRACE_ENTRY));
164        }
165        let trimmed = line.trim_end_matches(['\n', '\r']);
166        if trimmed.trim().is_empty() {
167            continue;
168        }
169
170        let value: serde_json::Value =
171            serde_json::from_str(trimmed).map_err(|source| TraceError::Json {
172                line: line_no,
173                source,
174            })?;
175
176        let kind = value.get("type").and_then(|v| v.as_str()).unwrap_or("");
177        if kind != "context-options" {
178            return Err(TraceError::MalformedAction {
179                call_id: String::new(),
180                reason: format!("expected first event to be `context-options`, got `{kind}`"),
181            });
182        }
183
184        return serde_json::from_value::<ContextOptions>(value).map_err(|source| {
185            TraceError::Json {
186                line: line_no,
187                source,
188            }
189        });
190    }
191}
192
193/// Convenience wrapper for [`TraceReader::open`] over a file on disk.
194pub fn open<P: AsRef<Path>>(path: P) -> Result<TraceReader<std::fs::File>> {
195    let file = std::fs::File::open(path)?;
196    TraceReader::open(file)
197}