Skip to main content

glass/browser/session/
emulation.rs

1//! Browser emulation controls via CDP.
2//!
3//! This module exposes page PDF generation and environment overrides
4//! (geolocation, timezone) through the [`BrowserSession`](super::BrowserSession)
5//! extension methods.
6
7use super::*;
8
9/// Options for page PDF generation via CDP `Page.printToPDF`.
10///
11/// All fields are optional; omitted fields use the browser default.
12/// Use [`PdfOptions::letter`] for a US Letter preset with background printing.
13#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
14pub struct PdfOptions {
15    /// Paper width in inches.
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub paper_width: Option<f64>,
18    /// Paper height in inches.
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub paper_height: Option<f64>,
21    /// Whether to print background graphics.
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub print_background: Option<bool>,
24    /// Scale factor (1.0 = 100%).
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub scale: Option<f64>,
27    /// Whether to display header and footer.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub display_header_footer: Option<bool>,
30    /// Top margin in inches.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub margin_top: Option<f64>,
33    /// Bottom margin in inches.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub margin_bottom: Option<f64>,
36    /// Left margin in inches.
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub margin_left: Option<f64>,
39    /// Right margin in inches.
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub margin_right: Option<f64>,
42}
43
44impl PdfOptions {
45    /// US Letter (8.5×11″) with background printing enabled.
46    pub fn letter() -> Self {
47        Self {
48            paper_width: Some(8.5),
49            paper_height: Some(11.0),
50            print_background: Some(true),
51            ..Default::default()
52        }
53    }
54}
55
56/// Geolocation coordinates for CDP emulation.
57///
58/// Pass to [`BrowserSession::set_geolocation`] to override the
59/// browser's reported position.
60#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
61pub struct GeoLocation {
62    /// Latitude in decimal degrees (-90 to 90).
63    pub latitude: f64,
64    /// Longitude in decimal degrees (-180 to 180).
65    pub longitude: f64,
66    /// Accuracy radius in meters. Defaults to 100 when omitted.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub accuracy: Option<f64>,
69}
70
71/// Named or explicit network emulation settings for one session.
72#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
73pub struct NetworkConditions {
74    pub offline: bool,
75    pub latency_ms: f64,
76    pub download_throughput_bytes: f64,
77    pub upload_throughput_bytes: f64,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub connection_type: Option<String>,
80}
81
82impl NetworkConditions {
83    pub fn preset(name: &str) -> BrowserResult<Self> {
84        match name {
85            "slow-3g" => Ok(Self {
86                offline: false,
87                latency_ms: 400.0,
88                download_throughput_bytes: 50_000.0,
89                upload_throughput_bytes: 50_000.0,
90                connection_type: Some("cellular3g".to_string()),
91            }),
92            "fast-3g" => Ok(Self {
93                offline: false,
94                latency_ms: 100.0,
95                download_throughput_bytes: 1_500_000.0,
96                upload_throughput_bytes: 750_000.0,
97                connection_type: Some("cellular3g".to_string()),
98            }),
99            "offline" => Ok(Self {
100                offline: true,
101                latency_ms: 0.0,
102                download_throughput_bytes: 0.0,
103                upload_throughput_bytes: 0.0,
104                connection_type: Some("none".to_string()),
105            }),
106            _ => Err("network preset must be slow-3g, fast-3g, or offline".into()),
107        }
108    }
109}
110
111impl BrowserSession {
112    /// Apply session-scoped network throttling. Call with `None` to reset.
113    pub async fn set_network_conditions(
114        &self,
115        conditions: Option<&NetworkConditions>,
116    ) -> BrowserResult<()> {
117        self.cdp
118            .with_current_route(async {
119                let params = if let Some(conditions) = conditions {
120                    serde_json::json!({
121                        "offline": conditions.offline,
122                        "latency": conditions.latency_ms,
123                        "downloadThroughput": conditions.download_throughput_bytes,
124                        "uploadThroughput": conditions.upload_throughput_bytes,
125                        "connectionType": conditions.connection_type.clone().unwrap_or_else(|| "none".to_string())
126                    })
127                } else {
128                    serde_json::json!({
129                        "offline": false,
130                        "latency": 0,
131                        "downloadThroughput": -1,
132                        "uploadThroughput": -1,
133                        "connectionType": "none"
134                    })
135                };
136                self.cdp.send("Network.emulateNetworkConditions", Some(params)).await?;
137                Ok(())
138            })
139            .await
140    }
141
142    /// Apply a session-scoped CPU throttling multiplier. `None` resets to 1.
143    pub async fn set_cpu_throttling(&self, rate: Option<f64>) -> BrowserResult<()> {
144        let rate = rate.unwrap_or(1.0);
145        if !rate.is_finite() || rate <= 0.0 || rate > 20.0 {
146            return Err("CPU throttling rate must be in (0, 20]".into());
147        }
148        self.cdp
149            .with_current_route(async {
150                self.cdp
151                    .send(
152                        "Emulation.setCPUThrottlingRate",
153                        Some(serde_json::json!({"rate": rate})),
154                    )
155                    .await?;
156                Ok(())
157            })
158            .await
159    }
160
161    /// Override the user agent and optional Accept-Language for this session.
162    /// Passing `None` restores Chrome's default user agent.
163    pub async fn set_user_agent(
164        &self,
165        user_agent: Option<&str>,
166        accept_language: Option<&str>,
167        platform: Option<&str>,
168    ) -> BrowserResult<()> {
169        if user_agent.is_some_and(|value| value.len() > 512)
170            || accept_language.is_some_and(|value| value.len() > 128)
171            || platform.is_some_and(|value| value.len() > 128)
172        {
173            return Err("user-agent override is too long".into());
174        }
175        let restore = user_agent.is_none();
176        let original = if restore {
177            self.user_agent_original.lock().await.clone()
178        } else {
179            let existing = self.user_agent_original.lock().await.clone();
180            if let Some(existing) = existing {
181                Some(existing)
182            } else {
183                let current = self
184                    .evaluate_value("navigator.userAgent")
185                    .await?
186                    .as_str()
187                    .unwrap_or_default()
188                    .to_string();
189                *self.user_agent_original.lock().await = Some(current.clone());
190                Some(current)
191            }
192        };
193        if restore && original.is_none() {
194            return Ok(());
195        }
196        self.cdp
197            .with_current_route(async {
198                let user_agent = user_agent.or(original.as_deref()).unwrap_or_default();
199                let mut params = serde_json::json!({"userAgent": user_agent});
200                if let Some(language) = accept_language {
201                    params["acceptLanguage"] = serde_json::Value::String(language.to_string());
202                }
203                if let Some(platform) = platform {
204                    params["platform"] = serde_json::Value::String(platform.to_string());
205                }
206                self.cdp
207                    .send("Network.setUserAgentOverride", Some(params))
208                    .await?;
209                Ok::<(), crate::browser::cdp::CdpError>(())
210            })
211            .await?;
212        if restore {
213            *self.user_agent_original.lock().await = None;
214        }
215        Ok(())
216    }
217
218    /// Generate a PDF of the current page. Returns base64-encoded PDF data.
219    ///
220    /// Uses CDP `Page.printToPDF`. All [`PdfOptions`] fields are optional;
221    /// omitted fields use the browser's default values.
222    pub async fn print_to_pdf(&self, options: &PdfOptions) -> BrowserResult<String> {
223        let mut params = serde_json::Map::new();
224        if let Some(v) = options.paper_width {
225            params.insert("paperWidth".into(), v.into());
226        }
227        if let Some(v) = options.paper_height {
228            params.insert("paperHeight".into(), v.into());
229        }
230        if let Some(v) = options.print_background {
231            params.insert("printBackground".into(), v.into());
232        }
233        if let Some(v) = options.scale {
234            params.insert("scale".into(), v.into());
235        }
236        if let Some(v) = options.display_header_footer {
237            params.insert("displayHeaderFooter".into(), v.into());
238        }
239        if let Some(v) = options.margin_top {
240            params.insert("marginTop".into(), v.into());
241        }
242        if let Some(v) = options.margin_bottom {
243            params.insert("marginBottom".into(), v.into());
244        }
245        if let Some(v) = options.margin_left {
246            params.insert("marginLeft".into(), v.into());
247        }
248        if let Some(v) = options.margin_right {
249            params.insert("marginRight".into(), v.into());
250        }
251        let params = serde_json::Value::Object(params);
252        self.cdp
253            .with_current_route(async {
254                let result = self.cdp.send("Page.printToPDF", Some(params)).await?;
255                Ok(result["data"]
256                    .as_str()
257                    .ok_or("Page.printToPDF returned no data")?
258                    .to_string())
259            })
260            .await
261    }
262
263    /// Override the browser's reported geolocation.
264    ///
265    /// Pass `Some(GeoLocation)` to set coordinates; pass `None` to clear
266    /// the override and restore the real position.
267    pub async fn set_geolocation(&self, location: Option<&GeoLocation>) -> BrowserResult<()> {
268        self.cdp
269            .with_current_route(async {
270                if let Some(loc) = location {
271                    self.cdp
272                        .send(
273                            "Emulation.setGeolocationOverride",
274                            Some(serde_json::json!({
275                                "latitude": loc.latitude,
276                                "longitude": loc.longitude,
277                                "accuracy": loc.accuracy.unwrap_or(100.0)
278                            })),
279                        )
280                        .await?;
281                } else {
282                    self.cdp
283                        .send("Emulation.clearGeolocationOverride", None)
284                        .await?;
285                }
286                Ok(())
287            })
288            .await
289    }
290
291    /// Override the browser's reported timezone.
292    ///
293    /// Accepts IANA timezone IDs (e.g. `"America/New_York"`). Pass
294    /// `None` to clear the override and restore the system timezone.
295    pub async fn set_timezone(&self, timezone_id: Option<&str>) -> BrowserResult<()> {
296        self.cdp
297            .with_current_route(async {
298                let tz = timezone_id.unwrap_or("");
299                self.cdp
300                    .send(
301                        "Emulation.setTimezoneOverride",
302                        Some(serde_json::json!({"timezoneId": tz})),
303                    )
304                    .await?;
305                Ok(())
306            })
307            .await
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn pdf_options_letter_is_us_letter_with_background() {
317        let opts = PdfOptions::letter();
318        assert_eq!(opts.paper_width, Some(8.5));
319        assert_eq!(opts.paper_height, Some(11.0));
320        assert_eq!(opts.print_background, Some(true));
321        assert_eq!(opts.scale, None);
322        assert_eq!(opts.display_header_footer, None);
323        assert_eq!(opts.margin_top, None);
324    }
325
326    #[test]
327    fn pdf_options_default_is_all_none() {
328        let opts = PdfOptions::default();
329        assert!(opts.paper_width.is_none());
330        assert!(opts.paper_height.is_none());
331        assert!(opts.print_background.is_none());
332        assert!(opts.scale.is_none());
333        assert!(opts.margin_top.is_none());
334        assert!(opts.margin_bottom.is_none());
335        assert!(opts.margin_left.is_none());
336        assert!(opts.margin_right.is_none());
337    }
338
339    #[test]
340    fn geo_location_serializes_without_accuracy_when_none() {
341        let loc = GeoLocation {
342            latitude: 37.7749,
343            longitude: -122.4194,
344            accuracy: None,
345        };
346        let json = serde_json::to_value(&loc).unwrap();
347        assert_eq!(json["latitude"], 37.7749);
348        assert_eq!(json["longitude"], -122.4194);
349        assert!(json.get("accuracy").is_none());
350    }
351
352    #[test]
353    fn geo_location_serializes_accuracy_when_present() {
354        let loc = GeoLocation {
355            latitude: 51.5074,
356            longitude: -0.1278,
357            accuracy: Some(42.0),
358        };
359        let json = serde_json::to_value(&loc).unwrap();
360        assert_eq!(json["accuracy"], 42.0);
361    }
362
363    #[test]
364    fn geo_location_roundtrip_through_json() {
365        let original = GeoLocation {
366            latitude: -33.8688,
367            longitude: 151.2093,
368            accuracy: Some(5.0),
369        };
370        let json = serde_json::to_string(&original).unwrap();
371        let parsed: GeoLocation = serde_json::from_str(&json).unwrap();
372        assert!((parsed.latitude - original.latitude).abs() < 1e-10);
373        assert!((parsed.longitude - original.longitude).abs() < 1e-10);
374        assert!((parsed.accuracy.unwrap() - 5.0).abs() < 1e-10);
375    }
376
377    #[test]
378    fn geo_location_roundtrip_without_accuracy() {
379        let original = GeoLocation {
380            latitude: 35.6762,
381            longitude: 139.6503,
382            accuracy: None,
383        };
384        let json = serde_json::to_string(&original).unwrap();
385        let parsed: GeoLocation = serde_json::from_str(&json).unwrap();
386        assert!((parsed.latitude - 35.6762).abs() < 1e-10);
387        assert!((parsed.longitude - 139.6503).abs() < 1e-10);
388        assert!(parsed.accuracy.is_none());
389    }
390
391    #[test]
392    fn pdf_options_letter_serializes_correctly() {
393        let opts = PdfOptions::letter();
394        let json = serde_json::to_value(&opts).unwrap();
395        assert_eq!(json["paper_width"], 8.5);
396        assert_eq!(json["paper_height"], 11.0);
397        assert_eq!(json["print_background"], true);
398        // Omitted fields should be absent, not null
399        assert!(json.get("scale").is_none());
400        assert!(json.get("margin_top").is_none());
401    }
402
403    #[test]
404    fn pdf_options_default_serializes_empty_object() {
405        let opts = PdfOptions::default();
406        let json = serde_json::to_value(&opts).unwrap();
407        // All fields are None, so serialization should skip them all
408        assert!(json.as_object().unwrap().is_empty());
409    }
410}