Skip to main content

ferrous_browser/
page.rs

1use serde::de::DeserializeOwned;
2use serde::{Deserialize, Serialize};
3use serde_json::{json, Value};
4use std::sync::Arc;
5use tokio::time::{timeout, Duration};
6
7use crate::cdp::CDPClient;
8use crate::error::{BrowserError, Result};
9
10// ─── P2: WaitUntil enum ──────────────────────────────────────────────────────
11
12/// Controls when [`Page::goto`] considers navigation complete.
13#[derive(Debug, Clone, Copy, Default)]
14pub enum WaitUntil {
15    /// Wait for `Page.domContentEventFired` — the DOM is parsed but
16    /// sub-resources (images, stylesheets) may still be loading.
17    DomContentLoaded,
18    /// Wait for `Page.loadEventFired` — all resources have loaded.
19    /// This is the default.
20    #[default]
21    Load,
22    /// Wait until there are no in-flight network requests for 500 ms.
23    /// Useful for SPAs that fetch data after the load event.
24    NetworkIdle,
25}
26
27// ─── P2B: Cookie ─────────────────────────────────────────────────────────────
28
29/// Represents a browser cookie for session persistence.
30///
31/// # Example
32///
33/// ```no_run
34/// # use ferrous_browser::{Browser, Cookie, WaitUntil};
35/// # #[tokio::main]
36/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
37/// let browser = Browser::launch().await?;
38/// let page = browser.new_page().await?;
39/// let cookies = vec![Cookie {
40///     name: "session".to_string(),
41///     value: "abc123".to_string(),
42///     ..Default::default()
43/// }];
44/// page.set_cookies(&cookies).await?;
45/// let retrieved = page.cookies().await?;
46/// # Ok(())
47/// # }
48/// ```
49#[derive(Debug, Clone, Serialize, Deserialize, Default)]
50pub struct Cookie {
51    /// Cookie name
52    pub name: String,
53    /// Cookie value
54    pub value: String,
55    /// Cookie domain (default: page domain)
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub domain: Option<String>,
58    /// Cookie path (default: "/")
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub path: Option<String>,
61    /// Seconds since epoch when cookie expires (default: session cookie)
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub expires: Option<f64>,
64    /// HTTPS only flag
65    #[serde(default)]
66    pub secure: bool,
67    /// HTTP only flag (not accessible via JavaScript)
68    #[serde(default, rename = "httpOnly")]
69    pub http_only: bool,
70    /// SameSite attribute ("Strict", "Lax", "None")
71    #[serde(skip_serializing_if = "Option::is_none", rename = "sameSite")]
72    pub same_site: Option<String>,
73}
74
75// ─── P3: Locator ─────────────────────────────────────────────────────────────
76
77/// A lazy handle to a DOM element identified by a CSS selector.
78///
79/// Locators are created with [`Page::locator`] and make the common
80/// "find-then-act" pattern ergonomic and composable.
81///
82/// # Example
83///
84/// ```no_run
85/// # use ferrous_browser::{Browser, WaitUntil};
86/// # #[tokio::main]
87/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
88/// let browser = Browser::launch().await?;
89/// let page = browser.new_page().await?;
90/// page.goto("https://example.com", WaitUntil::Load).await?;
91///
92/// // Locator API
93/// page.locator("button#submit").click().await?;
94/// page.locator("input[name=q]").type_text("hello").await?;
95/// page.locator(".result").wait_for().await?;
96/// # Ok(())
97/// # }
98/// ```
99#[derive(Clone)]
100pub struct Locator {
101    selector: String,
102    page: Page,
103}
104
105impl Locator {
106    fn new(selector: impl Into<String>, page: Page) -> Self {
107        Self {
108            selector: selector.into(),
109            page,
110        }
111    }
112
113    /// Click the element identified by this locator.
114    pub async fn click(&self) -> Result<()> {
115        self.page.click_selector(&self.selector).await
116    }
117
118    /// Type text into the element identified by this locator.
119    pub async fn type_text(&self, text: &str) -> Result<()> {
120        self.page.type_text_selector(&self.selector, text).await
121    }
122
123    /// Wait until the element is present in the DOM (30 s default timeout).
124    pub async fn wait_for(&self) -> Result<()> {
125        self.page.wait_for_selector(&self.selector).await
126    }
127
128    /// Wait until the element is present with a custom timeout.
129    pub async fn wait_for_timeout(&self, dur: Duration) -> Result<()> {
130        self.page
131            .wait_for_selector_with_timeout(&self.selector, dur)
132            .await
133    }
134
135    /// Get the inner text of the element.
136    pub async fn inner_text(&self) -> Result<String> {
137        let expr = format!(
138            "document.querySelector('{}')?.innerText ?? ''",
139            escape_selector(&self.selector)
140        );
141        let result = self
142            .page
143            .send_command(
144                "Runtime.evaluate".to_string(),
145                Some(json!({ "expression": expr, "returnByValue": true })),
146            )
147            .await?;
148        result
149            .get("result")
150            .and_then(|r| r.get("value"))
151            .and_then(|v| v.as_str())
152            .map(|s| s.to_string())
153            .ok_or_else(|| {
154                BrowserError::invalid_response(
155                    format!("inner_text('{}')", self.selector),
156                    "unexpected result shape",
157                )
158            })
159    }
160
161    /// Get an attribute value of the element.
162    pub async fn get_attribute(&self, name: &str) -> Result<Option<String>> {
163        let expr = format!(
164            "document.querySelector('{}')?.getAttribute('{}') ?? null",
165            escape_selector(&self.selector),
166            name,
167        );
168        let result = self
169            .page
170            .send_command(
171                "Runtime.evaluate".to_string(),
172                Some(json!({ "expression": expr, "returnByValue": true })),
173            )
174            .await?;
175        let val = result.get("result").and_then(|r| r.get("value"));
176        match val {
177            Some(Value::String(s)) => Ok(Some(s.clone())),
178            Some(Value::Null) | None => Ok(None),
179            _ => Ok(val.map(|v| v.to_string())),
180        }
181    }
182}
183
184// ─── Page ────────────────────────────────────────────────────────────────────
185
186/// A handle to a single page/tab in the browser.
187///
188/// Page provides methods for interacting with a specific page or tab,
189/// including navigation, content retrieval, screenshot capture, and
190/// element interaction.
191///
192/// # Example
193///
194/// ```no_run
195/// use ferrous_browser::{Browser, WaitUntil};
196///
197/// # #[tokio::main]
198/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
199/// let browser = Browser::launch().await?;
200/// let page = browser.new_page().await?;
201///
202/// page.goto("https://example.com", WaitUntil::Load).await?;
203/// let html = page.content().await?;
204/// let screenshot = page.screenshot().await?;
205/// # Ok(())
206/// # }
207/// ```
208#[derive(Clone)]
209pub struct Page {
210    /// Target/page ID
211    pub target_id: String,
212    /// Session ID for routing CDP commands
213    pub session_id: String,
214    /// Reference to CDP client
215    cdp: Arc<CDPClient>,
216}
217
218impl Page {
219    /// Create a new page handle
220    #[doc(hidden)]
221    pub fn new(target_id: String, session_id: String, cdp: Arc<CDPClient>) -> Self {
222        Page {
223            target_id,
224            session_id,
225            cdp,
226        }
227    }
228
229    // ─── P3: Locator entry point ──────────────────────────────────────────
230
231    /// Create a [`Locator`] for the given CSS selector.
232    ///
233    /// # Example
234    ///
235    /// ```no_run
236    /// # use ferrous_browser::{Browser, WaitUntil};
237    /// # #[tokio::main]
238    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
239    /// let browser = Browser::launch().await?;
240    /// let page = browser.new_page().await?;
241    /// page.goto("https://example.com", WaitUntil::Load).await?;
242    ///
243    /// page.locator("button#submit").click().await?;
244    /// page.locator("input[name=q]").type_text("rust").await?;
245    /// page.locator(".result").wait_for().await?;
246    /// # Ok(())
247    /// # }
248    /// ```
249    pub fn locator(&self, selector: &str) -> Locator {
250        Locator::new(selector, self.clone())
251    }
252
253    // ─── P2: goto with WaitUntil ─────────────────────────────────────────
254
255    /// Navigate to a URL and wait for the specified condition.
256    ///
257    /// # Arguments
258    ///
259    /// * `url`        — The URL to navigate to
260    /// * `wait_until` — When to consider navigation complete
261    ///
262    /// # Example
263    ///
264    /// ```no_run
265    /// # use ferrous_browser::{Browser, WaitUntil};
266    /// # #[tokio::main]
267    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
268    /// let browser = Browser::launch().await?;
269    /// let page = browser.new_page().await?;
270    /// page.goto("https://example.com", WaitUntil::Load).await?;
271    /// page.goto("https://example.com", WaitUntil::DomContentLoaded).await?;
272    /// page.goto("https://example.com", WaitUntil::NetworkIdle).await?;
273    /// # Ok(())
274    /// # }
275    /// ```
276    pub async fn goto(&self, url: &str, wait_until: WaitUntil) -> Result<()> {
277        const TIMEOUT_SECS: u64 = 30;
278        let url_owned = url.to_string();
279        // Capture session_id so the async block can own it
280        let session_id = self.session_id.clone();
281
282        let event_method = match wait_until {
283            WaitUntil::DomContentLoaded => "Page.domContentEventFired",
284            WaitUntil::Load | WaitUntil::NetworkIdle => "Page.loadEventFired",
285        };
286
287        // ── Subscribe BEFORE sending any command (race-condition fix) ─────────
288        // Filter by BOTH method name AND session_id so concurrent pages never
289        // receive each other's load events (multi-page isolation fix).
290        let mut event_rx = self.cdp.subscribe_events();
291        // ─────────────────────────────────────────────────────────────────────
292
293        let _ = self.send_command("Page.enable".to_string(), None).await;
294
295        let response = self
296            .send_command("Page.navigate".to_string(), Some(json!({ "url": url })))
297            .await?;
298
299        if let Some(error_text) = response.get("errorText").and_then(|v| v.as_str()) {
300            return Err(BrowserError::navigation_failed(&url_owned, error_text));
301        }
302
303        let wait_result = timeout(Duration::from_secs(TIMEOUT_SECS), async {
304            match wait_until {
305                WaitUntil::NetworkIdle => {
306                    let mut last_activity = tokio::time::Instant::now();
307                    loop {
308                        tokio::select! {
309                            recv = event_rx.recv() => {
310                                match recv {
311                                    Ok(msg)
312                                        if msg.session_id.as_deref() == Some(&session_id) =>
313                                    {
314                                        last_activity = tokio::time::Instant::now();
315                                    }
316                                    Ok(_) => {} // different session
317                                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
318                                        last_activity = tokio::time::Instant::now();
319                                    }
320                                    Err(_) => {}
321                                }
322                            }
323                            _ = tokio::time::sleep(Duration::from_millis(50)) => {
324                                if last_activity.elapsed() >= Duration::from_millis(500) {
325                                    return Ok::<(), BrowserError>(());
326                                }
327                            }
328                        }
329                    }
330                }
331                _ => loop {
332                    match event_rx.recv().await {
333                        Ok(msg)
334                            if msg.method.as_deref() == Some(event_method)
335                                && msg.session_id.as_deref() == Some(&session_id) =>
336                        {
337                            return Ok(());
338                        }
339                        Ok(_) => {} // wrong session or wrong event
340                        Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
341                            return Ok(()); // assume fired
342                        }
343                        Err(_) => tokio::time::sleep(Duration::from_millis(50)).await,
344                    }
345                },
346            }
347        })
348        .await;
349
350        wait_result.map_err(|_| {
351            BrowserError::timeout(format!("navigating to '{}'", url_owned), TIMEOUT_SECS)
352        })?
353    }
354
355    // ─── evaluate ─────────────────────────────────────────────────────────
356
357    /// Evaluate a JavaScript expression and return a remote object handle.
358    ///
359    /// This is useful when you need a reference to a JavaScript object without
360    /// serializing it back to Rust. The returned handle is valid only for this
361    /// session and should be disposed of when no longer needed.
362    ///
363    /// # Example
364    ///
365    /// ```no_run
366    /// # use ferrous_browser::{Browser, WaitUntil};
367    /// # #[tokio::main]
368    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
369    /// let browser = Browser::launch_chrome(None).await?;
370    /// let page = browser.new_page().await?;
371    /// page.goto("https://example.com", WaitUntil::Load).await?;
372    /// // Get a remote reference to an object
373    /// let handle = page.evaluate_handle("document.body").await?;
374    /// println!("Remote object handle: {}", handle);
375    /// # Ok(())
376    /// # }
377    /// ```
378    pub async fn evaluate_handle(&self, expression: &str) -> Result<String> {
379        let result = self
380            .send_command(
381                "Runtime.evaluate".to_string(),
382                Some(json!({
383                    "expression": expression,
384                    "returnByValue": false
385                })),
386            )
387            .await?;
388
389        if let Some(exc) = result.get("exceptionDetails") {
390            let msg = exc
391                .get("exception")
392                .and_then(|e| e.get("description"))
393                .and_then(|d| d.as_str())
394                .unwrap_or("unknown JS exception");
395            return Err(BrowserError::command_failed("Runtime.evaluate", msg));
396        }
397
398        result
399            .get("result")
400            .and_then(|v| v.get("objectId"))
401            .and_then(|v| v.as_str())
402            .map(|s| s.to_string())
403            .ok_or_else(|| {
404                BrowserError::invalid_response(
405                    "evaluate_handle()",
406                    "missing result.objectId — may have evaluated to a primitive",
407                )
408            })
409    }
410
411    /// Evaluate a JavaScript expression in the page context and deserialize the
412    /// result as `T`.
413    ///
414    /// # Example
415    ///
416    /// ```no_run
417    /// # use ferrous_browser::{Browser, WaitUntil};
418    /// # #[tokio::main]
419    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
420    /// let browser = Browser::launch_chrome(None).await?;
421    /// let page = browser.new_page().await?;
422    /// page.goto("https://example.com", WaitUntil::Load).await?;
423    /// let title: String = page.evaluate("document.title").await?;
424    /// let count: u64 = page.evaluate("document.querySelectorAll('a').length").await?;
425    /// # Ok(())
426    /// # }
427    /// ```
428    pub async fn evaluate<T: DeserializeOwned>(&self, expression: &str) -> Result<T> {
429        let result = self
430            .send_command(
431                "Runtime.evaluate".to_string(),
432                Some(json!({
433                    "expression": expression,
434                    "returnByValue": true,
435                    "awaitPromise": true,
436                })),
437            )
438            .await?;
439
440        if let Some(exc) = result.get("exceptionDetails") {
441            let msg = exc
442                .get("exception")
443                .and_then(|e| e.get("description"))
444                .and_then(|d| d.as_str())
445                .unwrap_or("unknown JS exception");
446            return Err(BrowserError::command_failed("Runtime.evaluate", msg));
447        }
448
449        let value = result
450            .get("result")
451            .and_then(|r| r.get("value"))
452            .cloned()
453            .unwrap_or(Value::Null);
454
455        serde_json::from_value(value)
456            .map_err(|e| BrowserError::invalid_response("evaluate()", e.to_string()))
457    }
458
459    // ─── Wait helpers ─────────────────────────────────────────────────────
460
461    /// Wait for an element matching `selector` to appear in the DOM.
462    ///
463    /// Uses a 30-second timeout.
464    pub async fn wait_for_selector(&self, selector: &str) -> Result<()> {
465        self.wait_for_selector_with_timeout(selector, Duration::from_secs(30))
466            .await
467    }
468
469    /// Wait for an element matching `selector` with a custom timeout.
470    pub async fn wait_for_selector_with_timeout(
471        &self,
472        selector: &str,
473        dur: Duration,
474    ) -> Result<()> {
475        let selector = selector.to_string();
476        let timeout_secs = dur.as_secs();
477
478        let fut = async {
479            loop {
480                let expr = format!("!!document.querySelector('{}')", escape_selector(&selector),);
481                let result = self
482                    .send_command(
483                        "Runtime.evaluate".to_string(),
484                        Some(json!({ "expression": expr, "returnByValue": true })),
485                    )
486                    .await?;
487
488                if let Some(true) = result
489                    .get("result")
490                    .and_then(|r| r.get("value"))
491                    .and_then(|v| v.as_bool())
492                {
493                    return Ok::<(), BrowserError>(());
494                }
495
496                tokio::time::sleep(Duration::from_millis(100)).await;
497            }
498        };
499
500        timeout(dur, fut).await.map_err(|_| {
501            BrowserError::timeout(format!("waiting for selector '{}'", selector), timeout_secs)
502        })?
503    }
504
505    // ─── Interaction helpers (internal, also used by Locator) ─────────────
506
507    /// Click an element matching the selector (internal implementation).
508    pub(crate) async fn click_selector(&self, selector: &str) -> Result<()> {
509        let expr = format!(
510            "document.querySelector('{}').click()",
511            escape_selector(selector),
512        );
513        self.send_command(
514            "Runtime.evaluate".to_string(),
515            Some(json!({ "expression": expr })),
516        )
517        .await?;
518        Ok(())
519    }
520
521    /// Type text into an element (internal implementation).
522    pub(crate) async fn type_text_selector(&self, selector: &str, text: &str) -> Result<()> {
523        let focus_expr = format!(
524            "document.querySelector('{}').focus()",
525            escape_selector(selector)
526        );
527        self.send_command(
528            "Runtime.evaluate".to_string(),
529            Some(json!({ "expression": focus_expr })),
530        )
531        .await?;
532
533        for ch in text.chars() {
534            self.send_command(
535                "Input.dispatchKeyEvent".to_string(),
536                Some(json!({
537                    "type": "char",
538                    "text": ch.to_string(),
539                })),
540            )
541            .await?;
542        }
543        Ok(())
544    }
545
546    // ─── Public raw-selector methods (legacy / power-user API) ────────────
547
548    /// Click an element matching the CSS selector.
549    ///
550    /// Prefer [`Page::locator`] for new code.
551    pub async fn click(&self, selector: &str) -> Result<()> {
552        self.click_selector(selector).await
553    }
554
555    /// Type text into an input element matching the CSS selector.
556    ///
557    /// Prefer [`Page::locator`] for new code.
558    pub async fn type_text(&self, selector: &str, text: &str) -> Result<()> {
559        self.type_text_selector(selector, text).await
560    }
561
562    // ─── Content / screenshot ────────────────────────────────────────────
563
564    /// Get the full HTML content of the page.
565    ///
566    /// # Example
567    ///
568    /// ```no_run
569    /// # use ferrous_browser::{Browser, WaitUntil};
570    /// # #[tokio::main]
571    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
572    /// let browser = Browser::launch().await?;
573    /// let page = browser.new_page().await?;
574    /// page.goto("https://example.com", WaitUntil::Load).await?;
575    /// let html = page.content().await?;
576    /// println!("HTML: {}", html);
577    /// # Ok(())
578    /// # }
579    /// ```
580    pub async fn content(&self) -> Result<String> {
581        let result = self
582            .send_command(
583                "Runtime.evaluate".to_string(),
584                Some(json!({ "expression": "document.documentElement.outerHTML" })),
585            )
586            .await?;
587
588        result
589            .get("result")
590            .and_then(|v| v.get("value"))
591            .and_then(|v| v.as_str())
592            .map(|s| s.to_string())
593            .ok_or_else(|| {
594                BrowserError::invalid_response("content()", "missing result.value string")
595            })
596    }
597
598    /// Take a screenshot of the page and return PNG bytes.
599    ///
600    /// # Example
601    ///
602    /// ```no_run
603    /// # use ferrous_browser::{Browser, WaitUntil};
604    /// # #[tokio::main]
605    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
606    /// let browser = Browser::launch().await?;
607    /// let page = browser.new_page().await?;
608    /// page.goto("https://example.com", WaitUntil::Load).await?;
609    /// let png = page.screenshot().await?;
610    /// std::fs::write("screenshot.png", png)?;
611    /// # Ok(())
612    /// # }
613    /// ```
614    pub async fn screenshot(&self) -> Result<Vec<u8>> {
615        let result = self
616            .send_command("Page.captureScreenshot".to_string(), None)
617            .await?;
618
619        let base64_data = result
620            .get("data")
621            .and_then(|v| v.as_str())
622            .ok_or_else(|| BrowserError::invalid_response("screenshot()", "missing data field"))?;
623
624        base64_decode(base64_data)
625    }
626
627    // ─── Network interception ────────────────────────────────────────────
628
629    /// Intercept network requests matching a pattern.
630    ///
631    /// Enables request interception and calls the callback for matching
632    /// requests. The callback receives `(url, resource_type)` and returns
633    /// `true` to abort the request.
634    pub async fn intercept_requests<F>(&self, callback: F) -> Result<()>
635    where
636        F: Fn(&str, &str) -> bool + Send + 'static,
637    {
638        let _ = self.send_command("Network.enable".to_string(), None).await;
639        let _ = self
640            .send_command(
641                "Network.setRequestInterception".to_string(),
642                Some(json!({ "patterns": [{ "urlPattern": "*" }] })),
643            )
644            .await;
645
646        // ── P1: Subscribe BEFORE the enable command fires events ─────────────
647        let mut event_rx = self.cdp.subscribe_events();
648        // ────────────────────────────────────────────────────────────────────
649
650        let cdp = self.cdp.clone();
651        let session_id = self.session_id.clone();
652        tokio::spawn(async move {
653            while let Ok(msg) = event_rx.recv().await {
654                // Only handle Network.requestIntercepted for this page's session
655                if msg.method.as_deref() != Some("Network.requestIntercepted") {
656                    continue;
657                }
658                if msg.session_id.as_deref() != Some(&session_id) {
659                    continue;
660                }
661                if let Some(params) = msg.params {
662                    let url = params
663                        .get("request")
664                        .and_then(|r| r.get("url"))
665                        .and_then(|u| u.as_str())
666                        .unwrap_or("");
667                    let resource_type = params
668                        .get("request")
669                        .and_then(|r| r.get("resourceType"))
670                        .and_then(|r| r.as_str())
671                        .unwrap_or("");
672                    let request_id = params
673                        .get("requestId")
674                        .and_then(|r| r.as_str())
675                        .unwrap_or("");
676
677                    let should_abort = callback(url, resource_type);
678
679                    let cdp_method = if should_abort {
680                        "Network.abortRequest"
681                    } else {
682                        "Network.continueInterceptedRequest"
683                    };
684
685                    let _ = cdp
686                        .send_command_with_session(
687                            &session_id,
688                            cdp_method.to_string(),
689                            Some(json!({ "requestId": request_id })),
690                        )
691                        .await;
692                }
693            }
694        });
695
696        Ok(())
697    }
698
699    // ─── Session persistence ────────────────────────────────────────────────
700
701    /// Get all cookies from the page.
702    ///
703    /// Retrieves all cookies visible to the current page, including
704    /// expired cookies if they are still in the cookie jar.
705    ///
706    /// # Example
707    ///
708    /// ```no_run
709    /// # use ferrous_browser::{Browser, WaitUntil};
710    /// # #[tokio::main]
711    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
712    /// let browser = Browser::launch().await?;
713    /// let page = browser.new_page().await?;
714    /// page.goto("https://example.com", WaitUntil::Load).await?;
715    /// let cookies = page.cookies().await?;
716    /// for cookie in cookies {
717    ///     println!("{}={}", cookie.name, cookie.value);
718    /// }
719    /// # Ok(())
720    /// # }
721    /// ```
722    pub async fn cookies(&self) -> Result<Vec<Cookie>> {
723        let result = self
724            .send_command("Network.getCookies".to_string(), None)
725            .await?;
726
727        let cookies_array = result
728            .get("cookies")
729            .and_then(|v| v.as_array())
730            .ok_or_else(|| BrowserError::invalid_response("cookies()", "missing cookies array"))?;
731
732        let mut cookies = Vec::new();
733        for cookie_val in cookies_array {
734            if let Ok(cookie) = serde_json::from_value::<Cookie>(cookie_val.clone()) {
735                cookies.push(cookie);
736            }
737        }
738
739        Ok(cookies)
740    }
741
742    /// Set cookies for the page (session persistence).
743    ///
744    /// Sets one or more cookies that will be visible to JavaScript and HTTP requests.
745    /// Typically called before navigation to pre-populate cookies for authentication.
746    ///
747    /// # Example
748    ///
749    /// ```no_run
750    /// # use ferrous_browser::{Browser, Cookie, WaitUntil};
751    /// # #[tokio::main]
752    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
753    /// let browser = Browser::launch().await?;
754    /// let page = browser.new_page().await?;
755    /// let cookies = vec![Cookie {
756    ///     name: "session_id".to_string(),
757    ///     value: "abc123xyz".to_string(),
758    ///     domain: Some("example.com".to_string()),
759    ///     ..Default::default()
760    /// }];
761    /// page.set_cookies(&cookies).await?;
762    /// page.goto("https://example.com", WaitUntil::Load).await?;
763    /// # Ok(())
764    /// # }
765    /// ```
766    pub async fn set_cookies(&self, cookies: &[Cookie]) -> Result<()> {
767        // Convert cookies to JSON array with proper formatting for CDP
768        let cookie_params: Vec<Value> = cookies
769            .iter()
770            .map(|c| {
771                let mut obj = json!({
772                    "name": c.name,
773                    "value": c.value,
774                });
775                if let Some(domain) = &c.domain {
776                    obj["domain"] = json!(domain);
777                }
778                if let Some(path) = &c.path {
779                    obj["path"] = json!(path);
780                }
781                if let Some(expires) = c.expires {
782                    obj["expires"] = json!(expires);
783                }
784                if c.secure {
785                    obj["secure"] = json!(true);
786                }
787                if c.http_only {
788                    obj["httpOnly"] = json!(true);
789                }
790                if let Some(same_site) = &c.same_site {
791                    obj["sameSite"] = json!(same_site);
792                }
793                obj
794            })
795            .collect();
796
797        self.send_command(
798            "Network.setCookies".to_string(),
799            Some(json!({ "cookies": cookie_params })),
800        )
801        .await?;
802
803        Ok(())
804    }
805
806    // ─── PDF Export ──────────────────────────────────────────────────────────
807
808    /// Export the page as PDF and return the bytes.
809    ///
810    /// Converts the current page to PDF format. By default, includes all pages
811    /// and uses A4 paper size in portrait mode.
812    ///
813    /// # Example
814    ///
815    /// ```no_run
816    /// # use ferrous_browser::{Browser, WaitUntil};
817    /// # #[tokio::main]
818    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
819    /// let browser = Browser::launch().await?;
820    /// let page = browser.new_page().await?;
821    /// page.goto("https://example.com", WaitUntil::Load).await?;
822    /// let pdf = page.pdf().await?;
823    /// std::fs::write("page.pdf", pdf)?;
824    /// # Ok(())
825    /// # }
826    /// ```
827    pub async fn pdf(&self) -> Result<Vec<u8>> {
828        self.pdf_with_options(None).await
829    }
830
831    /// Export the page as PDF with custom options.
832    ///
833    /// Allows control over paper size, margins, scale, landscape mode, and more.
834    ///
835    /// # Example
836    ///
837    /// ```no_run
838    /// # use ferrous_browser::{Browser, WaitUntil};
839    /// # use serde_json::json;
840    /// # #[tokio::main]
841    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
842    /// let browser = Browser::launch().await?;
843    /// let page = browser.new_page().await?;
844    /// page.goto("https://example.com", WaitUntil::Load).await?;
845    /// let options = json!({
846    ///     "landscape": true,
847    ///     "scale": 1.5,
848    ///     "paperWidth": 11.0,
849    ///     "paperHeight": 8.5,
850    /// });
851    /// let pdf = page.pdf_with_options(Some(&options)).await?;
852    /// # Ok(())
853    /// # }
854    /// ```
855    pub async fn pdf_with_options(&self, options: Option<&Value>) -> Result<Vec<u8>> {
856        let mut params = json!({
857            "landscape": false,
858            "displayHeaderFooter": false,
859            "scale": 1.0,
860            "paperWidth": 8.5,
861            "paperHeight": 11.0,
862            "marginTop": 0.4,
863            "marginBottom": 0.4,
864            "marginLeft": 0.4,
865            "marginRight": 0.4,
866            "preferCSSPageSize": true,
867            "transferMode": "ReturnAsBase64",
868        });
869
870        // Merge with provided options
871        if let Some(opts) = options {
872            if let Some(obj) = params.as_object_mut() {
873                if let Some(opts_obj) = opts.as_object() {
874                    for (key, value) in opts_obj.iter() {
875                        obj.insert(key.clone(), value.clone());
876                    }
877                }
878            }
879        }
880
881        let result = self
882            .send_command("Page.printToPDF".to_string(), Some(params))
883            .await?;
884
885        let base64_data = result
886            .get("data")
887            .and_then(|v| v.as_str())
888            .ok_or_else(|| BrowserError::invalid_response("pdf()", "missing data field"))?;
889
890        base64_decode(base64_data)
891    }
892
893    // ─── Internal ─────────────────────────────────────────────────────────
894
895    /// Send a command to this page's session
896    pub(crate) async fn send_command(
897        &self,
898        method: String,
899        params: Option<Value>,
900    ) -> Result<Value> {
901        self.cdp
902            .send_command_with_session(&self.session_id, method, params)
903            .await
904    }
905}
906
907// ─── Utilities ────────────────────────────────────────────────────────────────
908
909/// Escape single-quotes in a CSS selector used inside JS string literals.
910fn escape_selector(s: &str) -> String {
911    s.replace('\'', "\\'")
912}
913
914/// Decode base64 string to bytes
915fn base64_decode(s: &str) -> Result<Vec<u8>> {
916    use base64::Engine;
917    let engine = base64::engine::general_purpose::STANDARD;
918    engine.decode(s).map_err(|e| {
919        BrowserError::invalid_response("screenshot()", format!("base64 decode failed: {e}"))
920    })
921}
922
923// ─── Tests ────────────────────────────────────────────────────────────────────
924
925#[cfg(test)]
926mod tests {
927    use super::*;
928
929    #[test]
930    fn test_wait_until_default() {
931        let w: WaitUntil = Default::default();
932        assert!(matches!(w, WaitUntil::Load));
933    }
934
935    #[test]
936    fn test_escape_selector_plain() {
937        assert_eq!(escape_selector("button#id"), "button#id");
938    }
939
940    #[test]
941    fn test_escape_selector_quotes() {
942        assert_eq!(escape_selector("input[name='q']"), "input[name=\\'q\\']");
943    }
944}