Skip to main content

ferrous_browser/
page.rs

1use serde::de::DeserializeOwned;
2use serde_json::{json, Value};
3use std::sync::Arc;
4use tokio::time::{timeout, Duration};
5
6use crate::cdp::CDPClient;
7use crate::error::{BrowserError, Result};
8
9// ─── P2: WaitUntil enum ──────────────────────────────────────────────────────
10
11/// Controls when [`Page::goto`] considers navigation complete.
12#[derive(Debug, Clone, Copy, Default)]
13pub enum WaitUntil {
14    /// Wait for `Page.domContentEventFired` — the DOM is parsed but
15    /// sub-resources (images, stylesheets) may still be loading.
16    DomContentLoaded,
17    /// Wait for `Page.loadEventFired` — all resources have loaded.
18    /// This is the default.
19    #[default]
20    Load,
21    /// Wait until there are no in-flight network requests for 500 ms.
22    /// Useful for SPAs that fetch data after the load event.
23    NetworkIdle,
24}
25
26// ─── P3: Locator ─────────────────────────────────────────────────────────────
27
28/// A lazy handle to a DOM element identified by a CSS selector.
29///
30/// Locators are created with [`Page::locator`] and make the common
31/// "find-then-act" pattern ergonomic and composable.
32///
33/// # Example
34///
35/// ```no_run
36/// # use ferrous_browser::{Browser, WaitUntil};
37/// # #[tokio::main]
38/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
39/// let browser = Browser::launch().await?;
40/// let page = browser.new_page().await?;
41/// page.goto("https://example.com", WaitUntil::Load).await?;
42///
43/// // Locator API
44/// page.locator("button#submit").click().await?;
45/// page.locator("input[name=q]").type_text("hello").await?;
46/// page.locator(".result").wait_for().await?;
47/// # Ok(())
48/// # }
49/// ```
50#[derive(Clone)]
51pub struct Locator {
52    selector: String,
53    page: Page,
54}
55
56impl Locator {
57    fn new(selector: impl Into<String>, page: Page) -> Self {
58        Self {
59            selector: selector.into(),
60            page,
61        }
62    }
63
64    /// Click the element identified by this locator.
65    pub async fn click(&self) -> Result<()> {
66        self.page.click_selector(&self.selector).await
67    }
68
69    /// Type text into the element identified by this locator.
70    pub async fn type_text(&self, text: &str) -> Result<()> {
71        self.page.type_text_selector(&self.selector, text).await
72    }
73
74    /// Wait until the element is present in the DOM (30 s default timeout).
75    pub async fn wait_for(&self) -> Result<()> {
76        self.page.wait_for_selector(&self.selector).await
77    }
78
79    /// Wait until the element is present with a custom timeout.
80    pub async fn wait_for_timeout(&self, dur: Duration) -> Result<()> {
81        self.page.wait_for_selector_with_timeout(&self.selector, dur).await
82    }
83
84    /// Get the inner text of the element.
85    pub async fn inner_text(&self) -> Result<String> {
86        let expr = format!("document.querySelector('{}')?.innerText ?? ''", escape_selector(&self.selector));
87        let result = self.page.send_command(
88            "Runtime.evaluate".to_string(),
89            Some(json!({ "expression": expr, "returnByValue": true })),
90        ).await?;
91        result
92            .get("result")
93            .and_then(|r| r.get("value"))
94            .and_then(|v| v.as_str())
95            .map(|s| s.to_string())
96            .ok_or_else(|| BrowserError::invalid_response(
97                format!("inner_text('{}')", self.selector),
98                "unexpected result shape",
99            ))
100    }
101
102    /// Get an attribute value of the element.
103    pub async fn get_attribute(&self, name: &str) -> Result<Option<String>> {
104        let expr = format!(
105            "document.querySelector('{}')?.getAttribute('{}') ?? null",
106            escape_selector(&self.selector),
107            name,
108        );
109        let result = self.page.send_command(
110            "Runtime.evaluate".to_string(),
111            Some(json!({ "expression": expr, "returnByValue": true })),
112        ).await?;
113        let val = result
114            .get("result")
115            .and_then(|r| r.get("value"));
116        match val {
117            Some(Value::String(s)) => Ok(Some(s.clone())),
118            Some(Value::Null) | None => Ok(None),
119            _ => Ok(val.map(|v| v.to_string())),
120        }
121    }
122}
123
124// ─── Page ────────────────────────────────────────────────────────────────────
125
126/// A handle to a single page/tab in the browser.
127///
128/// Page provides methods for interacting with a specific page or tab,
129/// including navigation, content retrieval, screenshot capture, and
130/// element interaction.
131///
132/// # Example
133///
134/// ```no_run
135/// use ferrous_browser::{Browser, WaitUntil};
136///
137/// # #[tokio::main]
138/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
139/// let browser = Browser::launch().await?;
140/// let page = browser.new_page().await?;
141///
142/// page.goto("https://example.com", WaitUntil::Load).await?;
143/// let html = page.content().await?;
144/// let screenshot = page.screenshot().await?;
145/// # Ok(())
146/// # }
147/// ```
148#[derive(Clone)]
149pub struct Page {
150    /// Target/page ID
151    pub target_id: String,
152    /// Session ID for routing CDP commands
153    pub session_id: String,
154    /// Reference to CDP client
155    cdp: Arc<CDPClient>,
156}
157
158impl Page {
159    /// Create a new page handle
160    #[doc(hidden)]
161    pub fn new(target_id: String, session_id: String, cdp: Arc<CDPClient>) -> Self {
162        Page {
163            target_id,
164            session_id,
165            cdp,
166        }
167    }
168
169    // ─── P3: Locator entry point ──────────────────────────────────────────
170
171    /// Create a [`Locator`] for the given CSS selector.
172    ///
173    /// # Example
174    ///
175    /// ```no_run
176    /// # use ferrous_browser::{Browser, WaitUntil};
177    /// # #[tokio::main]
178    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
179    /// let browser = Browser::launch().await?;
180    /// let page = browser.new_page().await?;
181    /// page.goto("https://example.com", WaitUntil::Load).await?;
182    ///
183    /// page.locator("button#submit").click().await?;
184    /// page.locator("input[name=q]").type_text("rust").await?;
185    /// page.locator(".result").wait_for().await?;
186    /// # Ok(())
187    /// # }
188    /// ```
189    pub fn locator(&self, selector: &str) -> Locator {
190        Locator::new(selector, self.clone())
191    }
192
193    // ─── P2: goto with WaitUntil ─────────────────────────────────────────
194
195    /// Navigate to a URL and wait for the specified condition.
196    ///
197    /// # Arguments
198    ///
199    /// * `url`        — The URL to navigate to
200    /// * `wait_until` — When to consider navigation complete
201    ///
202    /// # Example
203    ///
204    /// ```no_run
205    /// # use ferrous_browser::{Browser, WaitUntil};
206    /// # #[tokio::main]
207    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
208    /// let browser = Browser::launch().await?;
209    /// let page = browser.new_page().await?;
210    /// page.goto("https://example.com", WaitUntil::Load).await?;
211    /// page.goto("https://example.com", WaitUntil::DomContentLoaded).await?;
212    /// page.goto("https://example.com", WaitUntil::NetworkIdle).await?;
213    /// # Ok(())
214    /// # }
215    /// ```
216    pub async fn goto(&self, url: &str, wait_until: WaitUntil) -> Result<()> {
217        const TIMEOUT_SECS: u64 = 30;
218        let url_owned = url.to_string();
219        // Capture session_id so the async block can own it
220        let session_id = self.session_id.clone();
221
222        let event_method = match wait_until {
223            WaitUntil::DomContentLoaded => "Page.domContentEventFired",
224            WaitUntil::Load | WaitUntil::NetworkIdle => "Page.loadEventFired",
225        };
226
227        // ── Subscribe BEFORE sending any command (race-condition fix) ─────────
228        // Filter by BOTH method name AND session_id so concurrent pages never
229        // receive each other's load events (multi-page isolation fix).
230        let mut event_rx = self.cdp.subscribe_events();
231        // ─────────────────────────────────────────────────────────────────────
232
233        let _ = self.send_command("Page.enable".to_string(), None).await;
234
235        let response = self.send_command(
236            "Page.navigate".to_string(),
237            Some(json!({ "url": url })),
238        ).await?;
239
240        if let Some(error_text) = response.get("errorText").and_then(|v| v.as_str()) {
241            return Err(BrowserError::navigation_failed(&url_owned, error_text));
242        }
243
244        let wait_result = timeout(Duration::from_secs(TIMEOUT_SECS), async {
245            match wait_until {
246                WaitUntil::NetworkIdle => {
247                    let mut last_activity = tokio::time::Instant::now();
248                    loop {
249                        tokio::select! {
250                            recv = event_rx.recv() => {
251                                match recv {
252                                    Ok(msg)
253                                        if msg.session_id.as_deref() == Some(&session_id) =>
254                                    {
255                                        last_activity = tokio::time::Instant::now();
256                                    }
257                                    Ok(_) => {} // different session
258                                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
259                                        last_activity = tokio::time::Instant::now();
260                                    }
261                                    Err(_) => {}
262                                }
263                            }
264                            _ = tokio::time::sleep(Duration::from_millis(50)) => {
265                                if last_activity.elapsed() >= Duration::from_millis(500) {
266                                    return Ok::<(), BrowserError>(());
267                                }
268                            }
269                        }
270                    }
271                }
272                _ => loop {
273                    match event_rx.recv().await {
274                        Ok(msg)
275                            if msg.method.as_deref() == Some(event_method)
276                                && msg.session_id.as_deref() == Some(&session_id) =>
277                        {
278                            return Ok(());
279                        }
280                        Ok(_) => {} // wrong session or wrong event
281                        Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
282                            return Ok(()); // assume fired
283                        }
284                        Err(_) => tokio::time::sleep(Duration::from_millis(50)).await,
285                    }
286                },
287            }
288        })
289        .await;
290
291        wait_result.map_err(|_| BrowserError::timeout(
292            format!("navigating to '{}'", url_owned),
293            TIMEOUT_SECS,
294        ))?
295    }
296
297    // ─── evaluate ─────────────────────────────────────────────────────────
298
299    /// Evaluate a JavaScript expression in the page context and deserialize the
300    /// result as `T`.
301    ///
302    /// # Example
303    ///
304    /// ```no_run
305    /// # use ferrous_browser::{Browser, WaitUntil};
306    /// # #[tokio::main]
307    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
308    /// let browser = Browser::launch_chrome(None).await?;
309    /// let page = browser.new_page().await?;
310    /// page.goto("https://example.com", WaitUntil::Load).await?;
311    /// let title: String = page.evaluate("document.title").await?;
312    /// let count: u64 = page.evaluate("document.querySelectorAll('a').length").await?;
313    /// # Ok(())
314    /// # }
315    /// ```
316    pub async fn evaluate<T: DeserializeOwned>(&self, expression: &str) -> Result<T> {
317        let result = self.send_command(
318            "Runtime.evaluate".to_string(),
319            Some(json!({
320                "expression": expression,
321                "returnByValue": true,
322                "awaitPromise": true,
323            })),
324        ).await?;
325
326        if let Some(exc) = result.get("exceptionDetails") {
327            let msg = exc
328                .get("exception")
329                .and_then(|e| e.get("description"))
330                .and_then(|d| d.as_str())
331                .unwrap_or("unknown JS exception");
332            return Err(BrowserError::command_failed("Runtime.evaluate", msg));
333        }
334
335        let value = result
336            .get("result")
337            .and_then(|r| r.get("value"))
338            .cloned()
339            .unwrap_or(Value::Null);
340
341        serde_json::from_value(value)
342            .map_err(|e| BrowserError::invalid_response("evaluate()", e.to_string()))
343    }
344
345    // ─── Wait helpers ─────────────────────────────────────────────────────
346
347    /// Wait for an element matching `selector` to appear in the DOM.
348    ///
349    /// Uses a 30-second timeout.
350    pub async fn wait_for_selector(&self, selector: &str) -> Result<()> {
351        self.wait_for_selector_with_timeout(selector, Duration::from_secs(30)).await
352    }
353
354    /// Wait for an element matching `selector` with a custom timeout.
355    pub async fn wait_for_selector_with_timeout(&self, selector: &str, dur: Duration) -> Result<()> {
356        let selector = selector.to_string();
357        let timeout_secs = dur.as_secs();
358
359        let fut = async {
360            loop {
361                let expr = format!(
362                    "!!document.querySelector('{}')",
363                    escape_selector(&selector),
364                );
365                let result = self.send_command(
366                    "Runtime.evaluate".to_string(),
367                    Some(json!({ "expression": expr, "returnByValue": true })),
368                ).await?;
369
370                if let Some(true) = result
371                    .get("result")
372                    .and_then(|r| r.get("value"))
373                    .and_then(|v| v.as_bool())
374                {
375                    return Ok::<(), BrowserError>(());
376                }
377
378                tokio::time::sleep(Duration::from_millis(100)).await;
379            }
380        };
381
382        timeout(dur, fut).await.map_err(|_| BrowserError::timeout(
383            format!("waiting for selector '{}'", selector),
384            timeout_secs,
385        ))?
386    }
387
388    // ─── Interaction helpers (internal, also used by Locator) ─────────────
389
390    /// Click an element matching the selector (internal implementation).
391    pub(crate) async fn click_selector(&self, selector: &str) -> Result<()> {
392        let expr = format!(
393            "document.querySelector('{}').click()",
394            escape_selector(selector),
395        );
396        self.send_command(
397            "Runtime.evaluate".to_string(),
398            Some(json!({ "expression": expr })),
399        ).await?;
400        Ok(())
401    }
402
403    /// Type text into an element (internal implementation).
404    pub(crate) async fn type_text_selector(&self, selector: &str, text: &str) -> Result<()> {
405        let focus_expr = format!("document.querySelector('{}').focus()", escape_selector(selector));
406        self.send_command(
407            "Runtime.evaluate".to_string(),
408            Some(json!({ "expression": focus_expr })),
409        ).await?;
410
411        for ch in text.chars() {
412            self.send_command(
413                "Input.dispatchKeyEvent".to_string(),
414                Some(json!({
415                    "type": "char",
416                    "text": ch.to_string(),
417                })),
418            ).await?;
419        }
420        Ok(())
421    }
422
423    // ─── Public raw-selector methods (legacy / power-user API) ────────────
424
425    /// Click an element matching the CSS selector.
426    ///
427    /// Prefer [`Page::locator`] for new code.
428    pub async fn click(&self, selector: &str) -> Result<()> {
429        self.click_selector(selector).await
430    }
431
432    /// Type text into an input element matching the CSS selector.
433    ///
434    /// Prefer [`Page::locator`] for new code.
435    pub async fn type_text(&self, selector: &str, text: &str) -> Result<()> {
436        self.type_text_selector(selector, text).await
437    }
438
439    // ─── Content / screenshot ────────────────────────────────────────────
440
441    /// Get the full HTML content of the page.
442    ///
443    /// # Example
444    ///
445    /// ```no_run
446    /// # use ferrous_browser::{Browser, WaitUntil};
447    /// # #[tokio::main]
448    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
449    /// let browser = Browser::launch().await?;
450    /// let page = browser.new_page().await?;
451    /// page.goto("https://example.com", WaitUntil::Load).await?;
452    /// let html = page.content().await?;
453    /// println!("HTML: {}", html);
454    /// # Ok(())
455    /// # }
456    /// ```
457    pub async fn content(&self) -> Result<String> {
458        let result = self.send_command(
459            "Runtime.evaluate".to_string(),
460            Some(json!({ "expression": "document.documentElement.outerHTML" })),
461        ).await?;
462
463        result
464            .get("result")
465            .and_then(|v| v.get("value"))
466            .and_then(|v| v.as_str())
467            .map(|s| s.to_string())
468            .ok_or_else(|| BrowserError::invalid_response("content()", "missing result.value string"))
469    }
470
471    /// Take a screenshot of the page and return PNG bytes.
472    ///
473    /// # Example
474    ///
475    /// ```no_run
476    /// # use ferrous_browser::{Browser, WaitUntil};
477    /// # #[tokio::main]
478    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
479    /// let browser = Browser::launch().await?;
480    /// let page = browser.new_page().await?;
481    /// page.goto("https://example.com", WaitUntil::Load).await?;
482    /// let png = page.screenshot().await?;
483    /// std::fs::write("screenshot.png", png)?;
484    /// # Ok(())
485    /// # }
486    /// ```
487    pub async fn screenshot(&self) -> Result<Vec<u8>> {
488        let result = self.send_command(
489            "Page.captureScreenshot".to_string(),
490            None,
491        ).await?;
492
493        let base64_data = result
494            .get("data")
495            .and_then(|v| v.as_str())
496            .ok_or_else(|| BrowserError::invalid_response("screenshot()", "missing data field"))?;
497
498        base64_decode(base64_data)
499    }
500
501    // ─── Network interception ────────────────────────────────────────────
502
503    /// Intercept network requests matching a pattern.
504    ///
505    /// Enables request interception and calls the callback for matching
506    /// requests. The callback receives `(url, resource_type)` and returns
507    /// `true` to abort the request.
508    pub async fn intercept_requests<F>(&self, callback: F) -> Result<()>
509    where
510        F: Fn(&str, &str) -> bool + Send + 'static,
511    {
512        let _ = self.send_command("Network.enable".to_string(), None).await;
513        let _ = self.send_command(
514            "Network.setRequestInterception".to_string(),
515            Some(json!({ "patterns": [{ "urlPattern": "*" }] })),
516        ).await;
517
518        // ── P1: Subscribe BEFORE the enable command fires events ─────────────
519        let mut event_rx = self.cdp.subscribe_events();
520        // ────────────────────────────────────────────────────────────────────
521
522        let cdp = self.cdp.clone();
523        let session_id = self.session_id.clone();
524        tokio::spawn(async move {
525            while let Ok(msg) = event_rx.recv().await {
526                // Only handle Network.requestIntercepted for this page's session
527                if msg.method.as_deref() != Some("Network.requestIntercepted") {
528                    continue;
529                }
530                if msg.session_id.as_deref() != Some(&session_id) {
531                    continue;
532                }
533                if let Some(params) = msg.params {
534                    let url = params
535                        .get("request")
536                        .and_then(|r| r.get("url"))
537                        .and_then(|u| u.as_str())
538                        .unwrap_or("");
539                    let resource_type = params
540                        .get("request")
541                        .and_then(|r| r.get("resourceType"))
542                        .and_then(|r| r.as_str())
543                        .unwrap_or("");
544                    let request_id = params
545                        .get("requestId")
546                        .and_then(|r| r.as_str())
547                        .unwrap_or("");
548
549                    let should_abort = callback(url, resource_type);
550
551                    let cdp_method = if should_abort {
552                        "Network.abortRequest"
553                    } else {
554                        "Network.continueInterceptedRequest"
555                    };
556
557                    let _ = cdp
558                        .send_command_with_session(
559                            &session_id,
560                            cdp_method.to_string(),
561                            Some(json!({ "requestId": request_id })),
562                        )
563                        .await;
564                }
565            }
566        });
567
568        Ok(())
569    }
570
571    // ─── Internal ─────────────────────────────────────────────────────────
572
573    /// Send a command to this page's session
574    pub(crate) async fn send_command(&self, method: String, params: Option<Value>) -> Result<Value> {
575        self.cdp.send_command_with_session(&self.session_id, method, params).await
576    }
577}
578
579// ─── Utilities ────────────────────────────────────────────────────────────────
580
581/// Escape single-quotes in a CSS selector used inside JS string literals.
582fn escape_selector(s: &str) -> String {
583    s.replace('\'', "\\'")
584}
585
586/// Decode base64 string to bytes
587fn base64_decode(s: &str) -> Result<Vec<u8>> {
588    use base64::Engine;
589    let engine = base64::engine::general_purpose::STANDARD;
590    engine
591        .decode(s)
592        .map_err(|e| BrowserError::invalid_response("screenshot()", format!("base64 decode failed: {e}")))
593}
594
595// ─── Tests ────────────────────────────────────────────────────────────────────
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    #[test]
602    fn test_wait_until_default() {
603        let w: WaitUntil = Default::default();
604        assert!(matches!(w, WaitUntil::Load));
605    }
606
607    #[test]
608    fn test_escape_selector_plain() {
609        assert_eq!(escape_selector("button#id"), "button#id");
610    }
611
612    #[test]
613    fn test_escape_selector_quotes() {
614        assert_eq!(escape_selector("input[name='q']"), "input[name=\\'q\\']");
615    }
616}