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}