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