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}