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