Skip to main content

playwright_rs_trace/
event.rs

1//! Trace event types.
2//!
3//! `RawEvent` is the lossless representation — every JSONL line in
4//! `trace.trace` deserialises into one. `TraceEvent` is the typed
5//! convenience layer; unknown / unmodelled kinds fall back to
6//! `TraceEvent::Unknown(RawEvent)` so nothing is silently dropped.
7
8use serde::Deserialize;
9use serde_json::{Map, Value};
10
11/// A single event from the trace, preserved as the underlying JSON
12/// object. Forward-compat escape hatch for callers who need to dispatch
13/// on event kinds the parser doesn't model yet.
14#[derive(Debug, Clone)]
15pub struct RawEvent {
16    raw: Map<String, Value>,
17}
18
19impl RawEvent {
20    pub(crate) fn new(raw: Map<String, Value>) -> Self {
21        Self { raw }
22    }
23
24    /// Returns the value of the `"type"` field, or `None` if the event
25    /// is malformed (`type` absent or non-string). The streaming
26    /// iterators in [`crate::TraceReader`] filter out malformed events
27    /// before they reach the user, so handlers iterating on
28    /// [`TraceReader::raw_events`](crate::TraceReader::raw_events) can
29    /// generally `expect` this.
30    pub fn kind(&self) -> Option<&str> {
31        self.raw.get("type").and_then(|v| v.as_str())
32    }
33
34    /// The full underlying JSON object, including the `"type"` field.
35    pub fn as_value(&self) -> &Map<String, Value> {
36        &self.raw
37    }
38
39    /// Take ownership of the underlying JSON.
40    pub fn into_value(self) -> Value {
41        Value::Object(self.raw)
42    }
43
44    /// Materialise the typed enum. Always succeeds — recognised kinds
45    /// become typed variants; anything else (including known kinds
46    /// whose schema we fail to deserialize) becomes
47    /// [`TraceEvent::Unknown`].
48    pub fn into_typed(self) -> TraceEvent {
49        // Try to deserialize as the tagged enum. If it fails (unknown
50        // tag, or a known tag with unexpected payload shape), preserve
51        // the raw payload as `Unknown` rather than discarding it.
52        // Borrowing deserialization: only the strings a variant keeps are
53        // copied, and the map is handed back intact on a miss.
54        let value = Value::Object(self.raw);
55        match TypedEnum::deserialize(&value) {
56            Ok(t) => t.into(),
57            Err(_) => match value {
58                Value::Object(raw) => TraceEvent::Unknown(RawEvent { raw }),
59                _ => unreachable!("the value was built from an object above"),
60            },
61        }
62    }
63}
64
65/// Strongly-typed variants for the event kinds this version of the
66/// parser models. Unknown / unmodelled kinds surface as
67/// [`TraceEvent::Unknown`] to preserve the underlying JSON.
68#[derive(Debug, Clone)]
69pub enum TraceEvent {
70    ContextOptions(ContextOptions),
71    Before(BeforeEvent),
72    Input(InputEvent),
73    Log(LogEvent),
74    After(AfterEvent),
75    Console(ConsoleEvent),
76    Event(SystemEvent),
77    FrameSnapshot(FrameSnapshotEvent),
78    ScreencastFrame(ScreencastFrameEvent),
79    /// Catch-all preserving the raw payload. Carries [`RawEvent`] so
80    /// users keep full access to the JSON for kinds we don't model.
81    Unknown(RawEvent),
82}
83
84// Internal enum used purely for serde-driven dispatch on the `type`
85// field. Public callers always see `TraceEvent`.
86#[derive(Deserialize)]
87#[serde(tag = "type", rename_all = "kebab-case")]
88enum TypedEnum {
89    ContextOptions(ContextOptions),
90    Before(BeforeEvent),
91    Input(InputEvent),
92    Log(LogEvent),
93    After(AfterEvent),
94    Console(ConsoleEvent),
95    Event(SystemEvent),
96    /// The payload sits under a `snapshot` key.
97    FrameSnapshot {
98        snapshot: FrameSnapshotWire,
99    },
100    ScreencastFrame(ScreencastFrameEvent),
101}
102
103impl From<TypedEnum> for TraceEvent {
104    fn from(t: TypedEnum) -> Self {
105        match t {
106            TypedEnum::ContextOptions(c) => TraceEvent::ContextOptions(c),
107            TypedEnum::Before(b) => TraceEvent::Before(b),
108            TypedEnum::Input(i) => TraceEvent::Input(i),
109            TypedEnum::Log(l) => TraceEvent::Log(l),
110            TypedEnum::After(a) => TraceEvent::After(a),
111            TypedEnum::Console(c) => TraceEvent::Console(c),
112            TypedEnum::Event(e) => TraceEvent::Event(e),
113            TypedEnum::FrameSnapshot { snapshot } => TraceEvent::FrameSnapshot(snapshot.into()),
114            TypedEnum::ScreencastFrame(s) => TraceEvent::ScreencastFrame(s),
115        }
116    }
117}
118
119/// Per-context metadata — appears once per trace as the first event
120/// in `trace.trace`.
121#[derive(Debug, Clone, Deserialize)]
122#[serde(rename_all = "camelCase")]
123pub struct ContextOptions {
124    pub version: u32,
125    #[serde(default)]
126    pub browser_name: String,
127    #[serde(default)]
128    pub playwright_version: String,
129    #[serde(default)]
130    pub platform: String,
131    #[serde(default)]
132    pub sdk_language: String,
133    #[serde(default)]
134    pub test_id_attribute_name: String,
135    #[serde(default)]
136    pub wall_time: f64,
137    #[serde(default)]
138    pub monotonic_time: f64,
139    #[serde(default)]
140    pub context_id: String,
141    /// Original `options` blob, kept as raw JSON since its shape varies
142    /// with browser type and Playwright version.
143    #[serde(default)]
144    pub options: Value,
145}
146
147/// Action-start event. Pairs with a matching [`AfterEvent`] sharing
148/// `call_id`.
149#[derive(Debug, Clone, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct BeforeEvent {
152    pub call_id: String,
153    pub start_time: f64,
154    #[serde(default)]
155    pub class: String,
156    #[serde(default)]
157    pub method: String,
158    #[serde(default)]
159    pub params: Value,
160    #[serde(default)]
161    pub title: Option<String>,
162    pub page_id: Option<String>,
163    pub before_snapshot: Option<String>,
164    pub step_id: Option<String>,
165    pub parent_id: Option<String>,
166}
167
168/// Optional input-coordinate / input-snapshot reference attached to an
169/// in-flight action.
170#[derive(Debug, Clone, Deserialize)]
171#[serde(rename_all = "camelCase")]
172pub struct InputEvent {
173    pub call_id: String,
174    pub point: Option<Point>,
175    pub input_snapshot: Option<String>,
176}
177
178/// Log line emitted during an in-flight action.
179#[derive(Debug, Clone, Deserialize)]
180#[serde(rename_all = "camelCase")]
181pub struct LogEvent {
182    pub call_id: String,
183    pub message: String,
184    pub time: f64,
185}
186
187/// Action-completion event.
188#[derive(Debug, Clone, Deserialize)]
189#[serde(rename_all = "camelCase")]
190pub struct AfterEvent {
191    pub call_id: String,
192    pub end_time: f64,
193    #[serde(default)]
194    pub result: Option<Value>,
195    #[serde(default)]
196    pub error: Option<ActionError>,
197    pub after_snapshot: Option<String>,
198    pub point: Option<Point>,
199}
200
201/// Browser console output captured during the trace.
202#[derive(Debug, Clone, Deserialize)]
203#[serde(rename_all = "camelCase")]
204pub struct ConsoleEvent {
205    /// `"log"`, `"warn"`, `"error"`, `"info"`, `"debug"`, etc. Kept as
206    /// a string because Playwright extends this set; matching at the
207    /// call site keeps us forward-compatible.
208    ///
209    /// The driver writes this under `messageType`: `type` is the event's
210    /// own discriminator, so reading it here left the level always empty.
211    #[serde(rename = "messageType", default)]
212    pub level: String,
213    #[serde(default)]
214    pub text: String,
215    #[serde(default)]
216    pub args: Vec<Value>,
217    pub location: Option<ConsoleLocation>,
218    pub time: f64,
219    pub page_id: Option<String>,
220}
221
222#[derive(Debug, Clone, Deserialize)]
223#[serde(rename_all = "camelCase")]
224pub struct ConsoleLocation {
225    #[serde(default)]
226    pub url: String,
227    #[serde(default)]
228    pub line_number: u32,
229    #[serde(default)]
230    pub column_number: u32,
231}
232
233/// System events (dialog, download, page open/close). Mirrors the
234/// `event` chunk type in the trace.
235#[derive(Debug, Clone, Deserialize)]
236#[serde(rename_all = "camelCase")]
237pub struct SystemEvent {
238    #[serde(default)]
239    pub class: String,
240    #[serde(default)]
241    pub method: String,
242    #[serde(default)]
243    pub params: Value,
244    pub time: f64,
245    pub page_id: Option<String>,
246}
247
248/// Per-frame DOM snapshot. Includes the full DOM payload, which can be
249/// sizeable; callers iterating on snapshots for many frames should expect
250/// the per-event size to dominate the overall trace memory budget.
251///
252/// A snapshot belongs to the action whose `call_id` it names, at the
253/// moment its `phase` says. Trace v9 writes the phase; v8 named each
254/// snapshot (`before@<call>`) and the phase is read back from that name,
255/// so the link is the same on both formats.
256#[derive(Debug, Clone)]
257pub struct FrameSnapshotEvent {
258    pub call_id: String,
259    /// The moment of the action this snapshot captured.
260    pub phase: ActionPhase,
261    /// The v8 snapshot name that the action's own events refer to. `None`
262    /// on v9, which stopped naming snapshots.
263    pub snapshot_name: Option<String>,
264    pub page_id: String,
265    pub frame_id: String,
266    pub frame_url: String,
267    pub doctype: String,
268    /// The serialized DOM, in the trace viewer's nested-array encoding.
269    pub html: Value,
270    pub viewport: Option<Viewport>,
271    pub timestamp: f64,
272    pub wall_time: f64,
273    pub collection_time: f64,
274    pub is_main_frame: bool,
275    pub resource_overrides: Vec<ResourceOverride>,
276}
277
278/// The `snapshot` payload as written, before the phase is settled.
279#[derive(Deserialize)]
280#[serde(rename_all = "camelCase")]
281pub struct FrameSnapshotWire {
282    call_id: String,
283    #[serde(default)]
284    phase: Option<ActionPhase>,
285    #[serde(default)]
286    snapshot_name: Option<String>,
287    page_id: String,
288    frame_id: String,
289    #[serde(default)]
290    frame_url: String,
291    #[serde(default)]
292    doctype: String,
293    #[serde(default)]
294    html: Value,
295    viewport: Option<Viewport>,
296    timestamp: f64,
297    #[serde(default)]
298    wall_time: f64,
299    #[serde(default)]
300    collection_time: f64,
301    #[serde(default)]
302    is_main_frame: bool,
303    #[serde(default)]
304    resource_overrides: Vec<ResourceOverride>,
305}
306
307impl From<FrameSnapshotWire> for FrameSnapshotEvent {
308    fn from(wire: FrameSnapshotWire) -> Self {
309        let phase = wire
310            .phase
311            .or_else(|| wire.snapshot_name.as_deref().map(ActionPhase::from_v8_name))
312            .unwrap_or(ActionPhase::Other);
313        Self {
314            call_id: wire.call_id,
315            phase,
316            snapshot_name: wire.snapshot_name,
317            page_id: wire.page_id,
318            frame_id: wire.frame_id,
319            frame_url: wire.frame_url,
320            doctype: wire.doctype,
321            html: wire.html,
322            viewport: wire.viewport,
323            timestamp: wire.timestamp,
324            wall_time: wire.wall_time,
325            collection_time: wire.collection_time,
326            is_main_frame: wire.is_main_frame,
327            resource_overrides: wire.resource_overrides,
328        }
329    }
330}
331
332/// The moment of an action a snapshot captured.
333#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
334#[serde(rename_all = "lowercase")]
335pub enum ActionPhase {
336    /// Just before the action ran.
337    Before,
338    /// At the input, e.g. the click point.
339    Action,
340    /// After the action completed.
341    After,
342    /// A phase this parser does not know.
343    #[serde(other)]
344    Other,
345}
346
347impl ActionPhase {
348    /// The phase a v8 snapshot name encodes: `before@<call>`,
349    /// `input@<call>`, or `after@<call>`.
350    fn from_v8_name(name: &str) -> Self {
351        match name.split('@').next() {
352            Some("before") => Self::Before,
353            Some("input") => Self::Action,
354            Some("after") => Self::After,
355            _ => Self::Other,
356        }
357    }
358}
359
360#[derive(Debug, Clone, Copy, Deserialize)]
361pub struct Viewport {
362    pub width: u32,
363    pub height: u32,
364}
365
366/// External resource reference used by a snapshot. Either a blob in the
367/// archive or an internal reference identifier the trace viewer reassembles.
368#[derive(Debug, Clone, Deserialize)]
369#[serde(rename_all = "camelCase")]
370pub struct ResourceOverride {
371    pub url: String,
372    /// Path of the blob inside the archive, ready to open there. Trace v8
373    /// wrote the entry name relative to `resources/`; v9 writes the whole
374    /// path. Both are normalized to the path.
375    #[serde(default, alias = "sha1", deserialize_with = "resource_path")]
376    pub file: Option<String>,
377    /// Ordinal of the earlier snapshot whose copy of this resource still
378    /// applies, for a stylesheet the page mutated and then left alone.
379    #[serde(rename = "ref", default)]
380    pub reference: Option<u64>,
381}
382
383/// Single screencast frame stored as a JPEG in the archive.
384#[derive(Debug, Clone, Deserialize)]
385#[serde(rename_all = "camelCase")]
386pub struct ScreencastFrameEvent {
387    pub page_id: String,
388    /// Path of the JPEG inside the archive, ready to open there. Trace v9
389    /// keeps frames under `screencast/`; v8 kept them under `resources/`
390    /// and wrote only the entry name, which is normalized to the path.
391    #[serde(alias = "sha1", deserialize_with = "required_resource_path")]
392    pub file: String,
393    pub width: u32,
394    pub height: u32,
395    pub timestamp: f64,
396}
397
398/// The archive directory every trace v8 blob reference was relative to.
399/// Trace v9 writes whole paths, under this directory or others such as
400/// `screencast/`.
401const RESOURCES_PREFIX: &str = "resources/";
402
403/// Normalize a blob reference to its path inside the archive: a v9 path
404/// passes through, a v8 entry name gets its directory back.
405fn to_resource_path(mut raw: String) -> String {
406    if !raw.contains('/') {
407        raw.insert_str(0, RESOURCES_PREFIX);
408    }
409    raw
410}
411
412/// `deserialize_with` for an optional blob reference; see [`to_resource_path`].
413pub(crate) fn resource_path<'de, D>(
414    deserializer: D,
415) -> std::result::Result<Option<String>, D::Error>
416where
417    D: serde::Deserializer<'de>,
418{
419    Ok(Option::<String>::deserialize(deserializer)?.map(to_resource_path))
420}
421
422/// `deserialize_with` for a required blob reference; see [`to_resource_path`].
423pub(crate) fn required_resource_path<'de, D>(
424    deserializer: D,
425) -> std::result::Result<String, D::Error>
426where
427    D: serde::Deserializer<'de>,
428{
429    Ok(to_resource_path(String::deserialize(deserializer)?))
430}
431
432/// Failure payload attached to an [`AfterEvent`].
433#[derive(Debug, Clone, Deserialize)]
434#[serde(rename_all = "camelCase")]
435pub struct ActionError {
436    #[serde(default)]
437    pub name: String,
438    #[serde(default)]
439    pub message: String,
440}
441
442/// 2D coordinates for input events and click targets. Used in
443/// [`InputEvent::point`] and [`AfterEvent::point`].
444#[derive(Debug, Clone, Copy, Deserialize)]
445pub struct Point {
446    pub x: f64,
447    pub y: f64,
448}