Skip to main content

playwright_rs/protocol/
resource_timing.rs

1//! Resource timing for HTTP requests, shared by the browser-side
2//! [`Request::timing`](crate::protocol::Request::timing) and the API-side
3//! `APIResponse::timing`.
4//!
5//! Split into its own module so the pure parse/merge logic sits in mutation
6//! scope (`.cargo/mutants.toml`); the protocol objects it serves are only
7//! testable against a live driver, which mutation testing excludes.
8
9/// Resource timing information for an HTTP request.
10///
11/// All time values are in milliseconds relative to the navigation start.
12/// A value of `-1` indicates the timing phase was not reached.
13///
14/// See: <https://playwright.dev/docs/api/class-request#request-timing>
15#[derive(Debug, Clone)]
16#[non_exhaustive]
17pub struct ResourceTiming {
18    /// Request start time in milliseconds since epoch.
19    pub start_time: f64,
20    /// Time immediately before the browser starts the domain name lookup
21    /// for the resource. The value is given in milliseconds relative to
22    /// `startTime`, -1 if not available.
23    pub domain_lookup_start: f64,
24    /// Time immediately after the browser starts the domain name lookup
25    /// for the resource. The value is given in milliseconds relative to
26    /// `startTime`, -1 if not available.
27    pub domain_lookup_end: f64,
28    /// Time immediately before the user agent starts establishing the
29    /// connection to the server to retrieve the resource.
30    pub connect_start: f64,
31    /// Time immediately after the browser starts the handshake process
32    /// to secure the current connection.
33    pub secure_connection_start: f64,
34    /// Time immediately after the browser finishes establishing the connection
35    /// to the server to retrieve the resource.
36    pub connect_end: f64,
37    /// Time immediately before the browser starts requesting the resource from
38    /// the server, cache, or local resource.
39    pub request_start: f64,
40    /// Time immediately after the browser starts requesting the resource from
41    /// the server, cache, or local resource.
42    pub response_start: f64,
43    /// Time immediately after the browser receives the last byte of the resource
44    /// or immediately before the transport connection is closed, whichever comes first.
45    pub response_end: f64,
46}
47
48impl ResourceTiming {
49    /// Folds the separately-reported response-end time into a timing object.
50    ///
51    /// The driver builds the timing before the body has finished arriving, so
52    /// `responseEnd` is absent there and the real value comes alongside as
53    /// `responseEndTiming`. Both the browser-side and API-side paths merge it,
54    /// through here, so a `ResourceTiming` means the same thing whichever
55    /// origin produced it.
56    pub(crate) fn merge_response_end(
57        timing: &mut serde_json::Value,
58        response_end_timing: Option<f64>,
59    ) {
60        if let (Some(end), Some(obj)) = (response_end_timing, timing.as_object_mut())
61            && let Some(n) = serde_json::Number::from_f64(end)
62        {
63            obj.insert("responseEnd".to_string(), serde_json::Value::Number(n));
64        }
65    }
66
67    /// Parses the protocol's `ResourceTiming` shape.
68    ///
69    /// Every phase is optional on the wire and absent means "not reached",
70    /// which Playwright represents as `-1` rather than a missing value. Shared
71    /// by [`Request::timing`] and `APIResponse::timing` so the two cannot
72    /// disagree about that defaulting. Callers that also have a
73    /// `responseEndTiming` should run [`Self::merge_response_end`] first.
74    ///
75    /// Returns `None` if the value is not a timing object at all.
76    pub(crate) fn from_protocol(value: &serde_json::Value) -> Option<Self> {
77        use serde::Deserialize;
78
79        #[derive(Deserialize)]
80        #[serde(rename_all = "camelCase")]
81        struct RawTiming {
82            start_time: Option<f64>,
83            domain_lookup_start: Option<f64>,
84            domain_lookup_end: Option<f64>,
85            connect_start: Option<f64>,
86            connect_end: Option<f64>,
87            secure_connection_start: Option<f64>,
88            request_start: Option<f64>,
89            response_start: Option<f64>,
90            response_end: Option<f64>,
91        }
92
93        let raw: RawTiming = serde_json::from_value(value.clone()).ok()?;
94
95        Some(Self {
96            start_time: raw.start_time.unwrap_or(-1.0),
97            domain_lookup_start: raw.domain_lookup_start.unwrap_or(-1.0),
98            domain_lookup_end: raw.domain_lookup_end.unwrap_or(-1.0),
99            connect_start: raw.connect_start.unwrap_or(-1.0),
100            connect_end: raw.connect_end.unwrap_or(-1.0),
101            secure_connection_start: raw.secure_connection_start.unwrap_or(-1.0),
102            request_start: raw.request_start.unwrap_or(-1.0),
103            response_start: raw.response_start.unwrap_or(-1.0),
104            response_end: raw.response_end.unwrap_or(-1.0),
105        })
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::ResourceTiming;
112    use serde_json::json;
113
114    #[test]
115    fn absent_phases_become_minus_one() {
116        // The driver omits phases that were never reached; Playwright's
117        // contract is that those read as -1, not as a missing value.
118        let timing = ResourceTiming::from_protocol(&json!({ "startTime": 1000.0 }))
119            .expect("an object with only startTime is still a timing");
120
121        assert_eq!(timing.start_time, 1000.0);
122        assert_eq!(timing.domain_lookup_start, -1.0);
123        assert_eq!(timing.domain_lookup_end, -1.0);
124        assert_eq!(timing.connect_start, -1.0);
125        assert_eq!(timing.connect_end, -1.0);
126        assert_eq!(timing.secure_connection_start, -1.0);
127        assert_eq!(timing.request_start, -1.0);
128        assert_eq!(timing.response_start, -1.0);
129        assert_eq!(timing.response_end, -1.0);
130    }
131
132    #[test]
133    fn every_phase_is_read_from_its_own_wire_name() {
134        // Pins the camelCase mapping: a swapped or misspelled rename would
135        // otherwise silently read as -1 and look like "phase not reached".
136        let timing = ResourceTiming::from_protocol(&json!({
137            "startTime": 1.0,
138            "domainLookupStart": 2.0,
139            "domainLookupEnd": 3.0,
140            "connectStart": 4.0,
141            "connectEnd": 5.0,
142            "secureConnectionStart": 6.0,
143            "requestStart": 7.0,
144            "responseStart": 8.0,
145            "responseEnd": 9.0,
146        }))
147        .expect("full timing parses");
148
149        assert_eq!(timing.start_time, 1.0);
150        assert_eq!(timing.domain_lookup_start, 2.0);
151        assert_eq!(timing.domain_lookup_end, 3.0);
152        assert_eq!(timing.connect_start, 4.0);
153        assert_eq!(timing.connect_end, 5.0);
154        assert_eq!(timing.secure_connection_start, 6.0);
155        assert_eq!(timing.request_start, 7.0);
156        assert_eq!(timing.response_start, 8.0);
157        assert_eq!(timing.response_end, 9.0);
158    }
159
160    #[test]
161    fn an_empty_timing_defaults_every_phase_including_start() {
162        // The other absent-phase test always supplies startTime, so this is
163        // the only place start_time's own default is exercised.
164        let timing = ResourceTiming::from_protocol(&json!({})).expect("empty object parses");
165        assert_eq!(timing.start_time, -1.0);
166    }
167
168    #[test]
169    fn a_non_object_is_not_a_timing() {
170        assert!(ResourceTiming::from_protocol(&json!("nope")).is_none());
171        assert!(ResourceTiming::from_protocol(&json!(null)).is_none());
172    }
173}
174
175#[cfg(test)]
176mod merge_tests {
177    use super::ResourceTiming;
178    use serde_json::json;
179
180    #[test]
181    fn response_end_is_folded_into_the_timing() {
182        // The driver reports the end separately because the timing object is
183        // built before the body finishes. Both origins fold it back in here,
184        // so a ResourceTiming means the same thing whichever produced it.
185        let mut timing = json!({ "startTime": 100.0, "requestStart": 5.0 });
186        ResourceTiming::merge_response_end(&mut timing, Some(42.0));
187
188        let parsed = ResourceTiming::from_protocol(&timing).expect("parses");
189        assert_eq!(parsed.response_end, 42.0);
190    }
191
192    #[test]
193    fn without_a_reported_end_the_phase_stays_unreached() {
194        let mut timing = json!({ "startTime": 100.0 });
195        ResourceTiming::merge_response_end(&mut timing, None);
196
197        let parsed = ResourceTiming::from_protocol(&timing).expect("parses");
198        assert_eq!(parsed.response_end, -1.0);
199    }
200
201    #[test]
202    fn a_non_object_timing_is_left_alone() {
203        let mut timing = json!("not a timing");
204        ResourceTiming::merge_response_end(&mut timing, Some(42.0));
205        assert_eq!(timing, json!("not a timing"));
206    }
207}