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    #[serde(deserialize_with = "call_id")]
153    pub call_id: String,
154    pub start_time: f64,
155    #[serde(default)]
156    pub class: String,
157    #[serde(default)]
158    pub method: String,
159    #[serde(default)]
160    pub params: Value,
161    #[serde(default)]
162    pub title: Option<String>,
163    pub page_id: Option<String>,
164    pub before_snapshot: Option<String>,
165    /// The test runner's id for the call. Trace format v10 dropped it, so
166    /// it is `None` on v10 traces.
167    pub step_id: Option<String>,
168    #[serde(default, deserialize_with = "optional_call_id")]
169    pub parent_id: Option<String>,
170}
171
172/// Optional input-coordinate / input-snapshot reference attached to an
173/// in-flight action.
174#[derive(Debug, Clone, Deserialize)]
175#[serde(rename_all = "camelCase")]
176pub struct InputEvent {
177    #[serde(deserialize_with = "call_id")]
178    pub call_id: String,
179    pub point: Option<Point>,
180    pub input_snapshot: Option<String>,
181}
182
183/// Log line emitted during an in-flight action.
184#[derive(Debug, Clone, Deserialize)]
185#[serde(rename_all = "camelCase")]
186pub struct LogEvent {
187    #[serde(deserialize_with = "call_id")]
188    pub call_id: String,
189    pub message: String,
190    pub time: f64,
191}
192
193/// Action-completion event.
194#[derive(Debug, Clone, Deserialize)]
195#[serde(rename_all = "camelCase")]
196pub struct AfterEvent {
197    #[serde(deserialize_with = "call_id")]
198    pub call_id: String,
199    pub end_time: f64,
200    #[serde(default)]
201    pub result: Option<Value>,
202    #[serde(default)]
203    pub error: Option<ActionError>,
204    pub after_snapshot: Option<String>,
205    pub point: Option<Point>,
206}
207
208/// Browser console output captured during the trace.
209#[derive(Debug, Clone, Deserialize)]
210#[serde(rename_all = "camelCase")]
211pub struct ConsoleEvent {
212    /// `"log"`, `"warn"`, `"error"`, `"info"`, `"debug"`, etc. Kept as
213    /// a string because Playwright extends this set; matching at the
214    /// call site keeps us forward-compatible.
215    ///
216    /// The driver writes this under `messageType`: `type` is the event's
217    /// own discriminator, so reading it here left the level always empty.
218    #[serde(rename = "messageType", default)]
219    pub level: String,
220    #[serde(default)]
221    pub text: String,
222    #[serde(default)]
223    pub args: Vec<Value>,
224    pub location: Option<ConsoleLocation>,
225    pub time: f64,
226    pub page_id: Option<String>,
227}
228
229#[derive(Debug, Clone, Deserialize)]
230#[serde(rename_all = "camelCase")]
231pub struct ConsoleLocation {
232    #[serde(default)]
233    pub url: String,
234    #[serde(default)]
235    pub line_number: u32,
236    #[serde(default)]
237    pub column_number: u32,
238}
239
240/// System events (dialog, download, page open/close). Mirrors the
241/// `event` chunk type in the trace.
242#[derive(Debug, Clone, Deserialize)]
243#[serde(rename_all = "camelCase")]
244pub struct SystemEvent {
245    #[serde(default)]
246    pub class: String,
247    #[serde(default)]
248    pub method: String,
249    #[serde(default)]
250    pub params: Value,
251    pub time: f64,
252    pub page_id: Option<String>,
253}
254
255/// Per-frame DOM snapshot. Includes the full DOM payload, which can be
256/// sizeable; callers iterating on snapshots for many frames should expect
257/// the per-event size to dominate the overall trace memory budget.
258///
259/// A snapshot belongs to the action whose `call_id` it names, at the
260/// moment its `phase` says. Trace v9 writes the phase; v8 named each
261/// snapshot (`before@<call>`) and the phase is read back from that name,
262/// so the link is the same on both formats.
263#[derive(Debug, Clone)]
264pub struct FrameSnapshotEvent {
265    pub call_id: String,
266    /// The moment of the action this snapshot captured.
267    pub phase: ActionPhase,
268    /// The v8 snapshot name that the action's own events refer to. `None`
269    /// on v9, which stopped naming snapshots.
270    pub snapshot_name: Option<String>,
271    pub page_id: String,
272    pub frame_id: String,
273    pub frame_url: String,
274    pub doctype: String,
275    /// The serialized DOM, in the trace viewer's nested-array encoding.
276    pub html: Value,
277    pub viewport: Option<Viewport>,
278    pub timestamp: f64,
279    pub wall_time: f64,
280    pub collection_time: f64,
281    pub is_main_frame: bool,
282    pub resource_overrides: Vec<ResourceOverride>,
283}
284
285/// The `snapshot` payload as written, before the phase is settled.
286#[derive(Deserialize)]
287#[serde(rename_all = "camelCase")]
288pub struct FrameSnapshotWire {
289    #[serde(deserialize_with = "call_id")]
290    call_id: String,
291    #[serde(default)]
292    phase: Option<ActionPhase>,
293    #[serde(default)]
294    snapshot_name: Option<String>,
295    page_id: String,
296    frame_id: String,
297    #[serde(default)]
298    frame_url: String,
299    #[serde(default)]
300    doctype: String,
301    #[serde(default)]
302    html: Value,
303    viewport: Option<Viewport>,
304    timestamp: f64,
305    #[serde(default)]
306    wall_time: f64,
307    #[serde(default)]
308    collection_time: f64,
309    #[serde(default)]
310    is_main_frame: bool,
311    #[serde(default)]
312    resource_overrides: Vec<ResourceOverride>,
313}
314
315impl From<FrameSnapshotWire> for FrameSnapshotEvent {
316    fn from(wire: FrameSnapshotWire) -> Self {
317        let phase = wire
318            .phase
319            .or_else(|| wire.snapshot_name.as_deref().map(ActionPhase::from_v8_name))
320            .unwrap_or(ActionPhase::Other);
321        Self {
322            call_id: wire.call_id,
323            phase,
324            snapshot_name: wire.snapshot_name,
325            page_id: wire.page_id,
326            frame_id: wire.frame_id,
327            frame_url: wire.frame_url,
328            doctype: wire.doctype,
329            html: wire.html,
330            viewport: wire.viewport,
331            timestamp: wire.timestamp,
332            wall_time: wire.wall_time,
333            collection_time: wire.collection_time,
334            is_main_frame: wire.is_main_frame,
335            resource_overrides: wire.resource_overrides,
336        }
337    }
338}
339
340/// The moment of an action a snapshot captured.
341#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
342#[serde(rename_all = "lowercase")]
343pub enum ActionPhase {
344    /// Just before the action ran.
345    Before,
346    /// At the input, e.g. the click point.
347    Action,
348    /// After the action completed.
349    After,
350    /// A phase this parser does not know.
351    #[serde(other)]
352    Other,
353}
354
355impl ActionPhase {
356    /// The phase a v8 snapshot name encodes: `before@<call>`,
357    /// `input@<call>`, or `after@<call>`.
358    fn from_v8_name(name: &str) -> Self {
359        match name.split('@').next() {
360            Some("before") => Self::Before,
361            Some("input") => Self::Action,
362            Some("after") => Self::After,
363            _ => Self::Other,
364        }
365    }
366}
367
368#[derive(Debug, Clone, Copy, Deserialize)]
369pub struct Viewport {
370    pub width: u32,
371    pub height: u32,
372}
373
374/// External resource reference used by a snapshot. Either a blob in the
375/// archive or an internal reference identifier the trace viewer reassembles.
376#[derive(Debug, Clone, Deserialize)]
377#[serde(rename_all = "camelCase")]
378pub struct ResourceOverride {
379    pub url: String,
380    /// Path of the blob inside the archive, ready to open there. Trace v8
381    /// wrote the entry name relative to `resources/`; v9 writes the whole
382    /// path. Both are normalized to the path.
383    #[serde(default, alias = "sha1", deserialize_with = "resource_path")]
384    pub file: Option<String>,
385    /// Ordinal of the earlier snapshot whose copy of this resource still
386    /// applies, for a stylesheet the page mutated and then left alone.
387    #[serde(rename = "ref", default)]
388    pub reference: Option<u64>,
389}
390
391/// Single screencast frame stored as a JPEG in the archive.
392#[derive(Debug, Clone, Deserialize)]
393#[serde(rename_all = "camelCase")]
394pub struct ScreencastFrameEvent {
395    pub page_id: String,
396    /// Path of the JPEG inside the archive, ready to open there. Trace v9
397    /// keeps frames under `screencast/`; v8 kept them under `resources/`
398    /// and wrote only the entry name, which is normalized to the path.
399    #[serde(alias = "sha1", deserialize_with = "required_resource_path")]
400    pub file: String,
401    pub width: u32,
402    pub height: u32,
403    pub timestamp: f64,
404}
405
406/// The archive directory every trace v8 blob reference was relative to.
407/// Trace v9 writes whole paths, under this directory or others such as
408/// `screencast/`.
409const RESOURCES_PREFIX: &str = "resources/";
410
411/// Normalize a blob reference to its path inside the archive: a v9 path
412/// passes through, a v8 entry name gets its directory back.
413fn to_resource_path(mut raw: String) -> String {
414    if !raw.contains('/') {
415        raw.insert_str(0, RESOURCES_PREFIX);
416    }
417    raw
418}
419
420/// `deserialize_with` for an optional blob reference; see [`to_resource_path`].
421pub(crate) fn resource_path<'de, D>(
422    deserializer: D,
423) -> std::result::Result<Option<String>, D::Error>
424where
425    D: serde::Deserializer<'de>,
426{
427    Ok(Option::<String>::deserialize(deserializer)?.map(to_resource_path))
428}
429
430/// `deserialize_with` for a required blob reference; see [`to_resource_path`].
431pub(crate) fn required_resource_path<'de, D>(
432    deserializer: D,
433) -> std::result::Result<String, D::Error>
434where
435    D: serde::Deserializer<'de>,
436{
437    Ok(to_resource_path(String::deserialize(deserializer)?))
438}
439
440/// A call id as the trace writes it. Trace v10 records the id the client
441/// sent; the JS and Python clients send a string, but a client that sends
442/// an integer gets a number back, and that should pair its events rather
443/// than turn every one into `Unknown`.
444#[derive(Deserialize)]
445#[serde(untagged)]
446enum CallIdWire {
447    Text(String),
448    Number(u64),
449}
450
451impl From<CallIdWire> for String {
452    fn from(id: CallIdWire) -> Self {
453        match id {
454            CallIdWire::Text(id) => id,
455            CallIdWire::Number(id) => id.to_string(),
456        }
457    }
458}
459
460/// `deserialize_with` for a call id; see [`CallIdWire`].
461pub(crate) fn call_id<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
462where
463    D: serde::Deserializer<'de>,
464{
465    Ok(CallIdWire::deserialize(deserializer)?.into())
466}
467
468/// `deserialize_with` for an optional call id, such as a parent's.
469pub(crate) fn optional_call_id<'de, D>(
470    deserializer: D,
471) -> std::result::Result<Option<String>, D::Error>
472where
473    D: serde::Deserializer<'de>,
474{
475    Ok(Option::<CallIdWire>::deserialize(deserializer)?.map(String::from))
476}
477
478/// Failure payload attached to an [`AfterEvent`].
479#[derive(Debug, Clone, Deserialize)]
480#[serde(rename_all = "camelCase")]
481pub struct ActionError {
482    #[serde(default)]
483    pub name: String,
484    #[serde(default)]
485    pub message: String,
486}
487
488/// 2D coordinates for input events and click targets. Used in
489/// [`InputEvent::point`] and [`AfterEvent::point`].
490#[derive(Debug, Clone, Copy, Deserialize)]
491pub struct Point {
492    pub x: f64,
493    pub y: f64,
494}