Skip to main content

playhard_automation/
lib.rs

1//! High-level Chrome automation for Playhard.
2//!
3//! This crate composes `playhard-launcher`, `playhard-transport`, and
4//! `playhard-cdp` into a Rust-native browser automation API.
5
6#![deny(missing_docs)]
7
8use std::{
9    collections::BTreeMap,
10    future::Future,
11    path::PathBuf,
12    sync::{Arc, Mutex, MutexGuard},
13    time::Duration,
14};
15
16use base64::Engine;
17use playhard_cdp::{
18    CdpClient, CdpError, CdpResponse, CdpTransport, FetchContinueRequestParams, FetchEnableParams,
19    FetchFailRequestParams, FetchFulfillRequestParams, FetchGetResponseBodyParams,
20    FetchHeaderEntry, InputDispatchKeyEventParams, InputInsertTextParams, NetworkEnableParams,
21    PageCaptureScreenshotParams, PageCaptureScreenshotResult, PageCreateIsolatedWorldParams,
22    PageEnableParams, PageGetFrameTreeParams, PageNavigateParams, PageNavigateResult,
23    PageSetLifecycleEventsEnabledParams, RemoteObject, RuntimeCallArgument,
24    RuntimeCallFunctionOnParams, RuntimeEnableParams, RuntimeEvaluateParams,
25    RuntimeReleaseObjectParams, TargetAttachToTargetParams, TargetCreateTargetParams,
26    TargetSetDiscoverTargetsParams,
27};
28use playhard_launcher::{
29    LaunchConnection, LaunchError, LaunchOptions, LaunchedChrome, LaunchedChromeParts, Launcher,
30    ProfileDir, TransportMode,
31};
32use playhard_transport::{
33    Connection, ConnectionError, PipeTransport, TransportEvent, WebSocketTransport,
34};
35use serde::{Deserialize, Serialize};
36use serde_json::{json, Value};
37use thiserror::Error;
38use tokio::{
39    process::Child,
40    sync::broadcast,
41    time::{sleep, timeout, Instant},
42};
43
44/// Result alias for the automation crate.
45pub type Result<T> = std::result::Result<T, AutomationError>;
46
47fn lock_unpoisoned<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
48    match mutex.lock() {
49        Ok(guard) => guard,
50        Err(poisoned) => poisoned.into_inner(),
51    }
52}
53
54/// Errors produced by `playhard-automation`.
55#[derive(Debug, Error)]
56pub enum AutomationError {
57    /// Chrome launch failed.
58    #[error(transparent)]
59    Launch(#[from] LaunchError),
60    /// Transport connection failed.
61    #[error(transparent)]
62    Connection(#[from] ConnectionError),
63    /// CDP command failed.
64    #[error(transparent)]
65    Cdp(#[from] CdpError),
66    /// JSON serialization or deserialization failed.
67    #[error(transparent)]
68    Json(#[from] serde_json::Error),
69    /// Base64 decoding failed.
70    #[error(transparent)]
71    Base64(#[from] base64::DecodeError),
72    /// UTF-8 decoding failed.
73    #[error(transparent)]
74    Utf8(#[from] std::string::FromUtf8Error),
75    /// A required websocket endpoint was missing.
76    #[error("launcher did not expose a websocket endpoint")]
77    MissingWebSocketEndpoint,
78    /// A wait timed out.
79    #[error("timed out waiting for {what}")]
80    Timeout {
81        /// Human-readable description of the timed operation.
82        what: String,
83    },
84    /// A locator action could not find a matching element.
85    #[error("locator did not match any element")]
86    MissingElement,
87    /// A selector operation failed.
88    #[error("{0}")]
89    Selector(String),
90    /// An expected protocol field was missing.
91    #[error("missing protocol field `{0}`")]
92    MissingField(&'static str),
93    /// A route operation required a different interception stage.
94    #[error("{0}")]
95    InvalidRouteState(&'static str),
96    /// Input dispatch failed because the requested key is unsupported.
97    #[error("{0}")]
98    Input(String),
99}
100
101#[derive(Clone)]
102enum AutomationTransport {
103    WebSocket(Arc<Connection<WebSocketTransport>>),
104    Pipe(Arc<Connection<PipeTransport>>),
105}
106
107impl AutomationTransport {
108    fn subscribe_events(&self) -> broadcast::Receiver<TransportEvent> {
109        match self {
110            Self::WebSocket(connection) => connection.subscribe_events(),
111            Self::Pipe(connection) => connection.subscribe_events(),
112        }
113    }
114
115    async fn close(&self) -> Result<()> {
116        match self {
117            Self::WebSocket(connection) => connection.close().await.map_err(AutomationError::from),
118            Self::Pipe(connection) => connection.close().await.map_err(AutomationError::from),
119        }
120    }
121}
122
123impl CdpTransport for AutomationTransport {
124    async fn send(
125        &self,
126        request: playhard_cdp::CdpRequest,
127    ) -> std::result::Result<CdpResponse, CdpError> {
128        let response = match self {
129            Self::WebSocket(connection) => send_over_connection(connection, request).await,
130            Self::Pipe(connection) => send_over_connection(connection, request).await,
131        };
132        response.map_err(Into::into)
133    }
134}
135
136async fn send_over_connection<T>(
137    connection: &Connection<T>,
138    request: playhard_cdp::CdpRequest,
139) -> Result<CdpResponse>
140where
141    T: playhard_transport::TransportHandle,
142{
143    let message = if let Some(session_id) = request.session_id.clone() {
144        connection
145            .request_for_session(session_id, request.method, Some(request.params))
146            .await?
147    } else {
148        connection
149            .request(request.method, Some(request.params))
150            .await?
151    };
152
153    Ok(CdpResponse {
154        id: message.id.ok_or(AutomationError::MissingField("id"))?,
155        result: message.result,
156        error: message.error.map(|error| playhard_cdp::CdpResponseError {
157            code: error.code,
158            message: error.message,
159        }),
160        session_id: message.session_id,
161    })
162}
163
164impl From<AutomationError> for CdpError {
165    fn from(error: AutomationError) -> Self {
166        match error {
167            AutomationError::Cdp(error) => error,
168            other => CdpError::Transport(other.to_string()),
169        }
170    }
171}
172
173enum LaunchGuard {
174    WebSocket(LaunchedChrome),
175    Pipe(PipeGuard),
176}
177
178impl LaunchGuard {
179    async fn shutdown(self) -> Result<()> {
180        match self {
181            Self::WebSocket(launched) => launched.shutdown().await.map_err(AutomationError::from),
182            Self::Pipe(mut guard) => {
183                let _ = guard.child.start_kill();
184                let _ = timeout(Duration::from_secs(5), guard.child.wait()).await;
185                Ok(())
186            }
187        }
188    }
189}
190
191struct PipeGuard {
192    _executable_path: PathBuf,
193    _profile: ProfileDir,
194    child: Child,
195}
196
197impl Drop for PipeGuard {
198    fn drop(&mut self) {
199        let _ = self.child.start_kill();
200    }
201}
202
203struct BrowserState {
204    client: Arc<CdpClient<AutomationTransport>>,
205    transport: AutomationTransport,
206    launch_guard: Mutex<Option<LaunchGuard>>,
207    browser_interception_patterns: Mutex<Option<Vec<RequestPattern>>>,
208    page_sessions: Mutex<Vec<String>>,
209}
210
211/// A browser automation session.
212pub struct Browser {
213    state: Arc<BrowserState>,
214}
215
216impl Browser {
217    /// Launch a new browser using the supplied options.
218    pub async fn launch(options: LaunchOptions) -> Result<Self> {
219        let launched = Launcher::new(options).launch().await?;
220        let transport = match launched.transport_mode() {
221            TransportMode::WebSocket => {
222                let endpoint = launched
223                    .websocket_endpoint()
224                    .ok_or(AutomationError::MissingWebSocketEndpoint)?;
225                let websocket = WebSocketTransport::connect(endpoint)
226                    .await
227                    .map_err(ConnectionError::from)?;
228                let connection = Connection::new(websocket)?;
229                let transport = AutomationTransport::WebSocket(Arc::new(connection));
230                (transport, LaunchGuard::WebSocket(launched))
231            }
232            TransportMode::Pipe => {
233                let parts = launched.into_parts();
234                let (pipe_transport, guard) = pipe_transport_from_parts(parts)?;
235                let connection = Connection::new(pipe_transport)?;
236                let transport = AutomationTransport::Pipe(Arc::new(connection));
237                (transport, guard)
238            }
239        };
240
241        let browser = Self::from_transport(transport.0, Some(transport.1));
242        browser.initialize().await?;
243        Ok(browser)
244    }
245
246    /// Connect to an already-running Chrome DevTools websocket endpoint.
247    pub async fn connect_websocket(url: impl AsRef<str>) -> Result<Self> {
248        let websocket = WebSocketTransport::connect(url.as_ref())
249            .await
250            .map_err(ConnectionError::from)?;
251        let connection = Connection::new(websocket)?;
252        let browser =
253            Self::from_transport(AutomationTransport::WebSocket(Arc::new(connection)), None);
254        browser.initialize().await?;
255        Ok(browser)
256    }
257
258    fn from_transport(transport: AutomationTransport, launch_guard: Option<LaunchGuard>) -> Self {
259        let client = Arc::new(CdpClient::new(transport.clone()));
260        let state = BrowserState {
261            client,
262            transport,
263            launch_guard: Mutex::new(launch_guard),
264            browser_interception_patterns: Mutex::new(None),
265            page_sessions: Mutex::new(Vec::new()),
266        };
267        Self {
268            state: Arc::new(state),
269        }
270    }
271
272    async fn initialize(&self) -> Result<()> {
273        self.state
274            .client
275            .execute::<TargetSetDiscoverTargetsParams>(&TargetSetDiscoverTargetsParams {
276                discover: true,
277            })
278            .await?;
279        Ok(())
280    }
281
282    /// Returns a raw CDP escape hatch rooted at the browser session.
283    #[must_use]
284    pub fn cdp(&self) -> CdpSession {
285        CdpSession {
286            client: Arc::clone(&self.state.client),
287            session_id: None,
288        }
289    }
290
291    /// Open a new page and bootstrap the common CDP domains used by Playhard.
292    pub async fn new_page(&self) -> Result<Page> {
293        let target = self
294            .state
295            .client
296            .execute::<TargetCreateTargetParams>(&TargetCreateTargetParams {
297                url: "about:blank".to_owned(),
298                new_window: None,
299            })
300            .await?;
301        let target_id = target.target_id.clone();
302        let attached = self
303            .state
304            .client
305            .execute::<TargetAttachToTargetParams>(&TargetAttachToTargetParams {
306                target_id,
307                flatten: Some(true),
308            })
309            .await?;
310        let session_id = attached.session_id;
311
312        bootstrap_page_session(Arc::clone(&self.state), session_id, target.target_id).await
313    }
314
315    /// Subscribe to all browser-wide network and fetch events.
316    pub fn network_events(&self) -> EventStream {
317        EventStream::new(
318            self.state.transport.subscribe_events(),
319            None,
320            Some("Network."),
321        )
322        .with_extra_prefix("Fetch.")
323    }
324
325    /// Enable request interception for all existing and future pages.
326    pub async fn enable_request_interception<I>(&self, patterns: I) -> Result<()>
327    where
328        I: IntoIterator<Item = RequestPattern>,
329    {
330        let patterns = collect_request_patterns(patterns);
331        {
332            let mut stored = lock_unpoisoned(&self.state.browser_interception_patterns);
333            *stored = Some(patterns.clone());
334        }
335
336        let session_ids = lock_unpoisoned(&self.state.page_sessions).clone();
337        for session_id in session_ids {
338            self.state
339                .client
340                .execute_in_session::<FetchEnableParams>(
341                    session_id,
342                    &FetchEnableParams {
343                        patterns: Some(patterns.iter().map(RequestPattern::to_cdp).collect()),
344                    },
345                )
346                .await?;
347        }
348
349        Ok(())
350    }
351
352    /// Wait for the next paused request across all pages.
353    pub async fn next_route(&self, timeout_duration: Duration) -> Result<Route> {
354        let mut events = EventStream::new(
355            self.state.transport.subscribe_events(),
356            None,
357            Some("Fetch."),
358        );
359        let event = events
360            .recv_with_timeout(timeout_duration)
361            .await?
362            .ok_or_else(|| AutomationError::Timeout {
363                what: "browser route".to_owned(),
364            })?;
365        Route::from_event(Arc::clone(&self.state.client), event)
366    }
367
368    /// Shut down the browser and close the underlying transport.
369    pub async fn shutdown(self) -> Result<()> {
370        self.state.transport.close().await?;
371        let launch_guard = {
372            let mut guard = lock_unpoisoned(&self.state.launch_guard);
373            guard.take()
374        };
375        if let Some(guard) = launch_guard {
376            guard.shutdown().await?;
377        }
378        Ok(())
379    }
380}
381
382fn pipe_transport_from_parts(parts: LaunchedChromeParts) -> Result<(PipeTransport, LaunchGuard)> {
383    let LaunchedChromeParts {
384        executable_path,
385        profile,
386        child,
387        connection,
388    } = parts;
389
390    let LaunchConnection::Pipe { stdin, stdout } = connection else {
391        return Err(AutomationError::MissingWebSocketEndpoint);
392    };
393
394    let transport = PipeTransport::new(stdin, stdout).map_err(ConnectionError::from)?;
395    let guard = LaunchGuard::Pipe(PipeGuard {
396        _executable_path: executable_path,
397        _profile: profile,
398        child,
399    });
400    Ok((transport, guard))
401}
402
403/// A raw CDP session escape hatch.
404#[derive(Clone)]
405pub struct CdpSession {
406    client: Arc<CdpClient<AutomationTransport>>,
407    session_id: Option<String>,
408}
409
410impl CdpSession {
411    /// Send an arbitrary CDP command and return its JSON result.
412    pub async fn call_raw(&self, method: impl Into<String>, params: Value) -> Result<Value> {
413        self.client
414            .call_raw(method.into(), params, self.session_id.clone())
415            .await
416            .map_err(AutomationError::from)
417    }
418
419    /// Return the target session id, if this session is page-scoped.
420    #[must_use]
421    pub fn session_id(&self) -> Option<&str> {
422        self.session_id.as_deref()
423    }
424}
425
426/// A page/tab automation handle.
427#[derive(Clone)]
428pub struct Page {
429    state: Arc<BrowserState>,
430    session_id: String,
431    target_id: String,
432    default_timeout: Duration,
433}
434
435impl Page {
436    /// Returns the attached target session id.
437    #[must_use]
438    pub fn session_id(&self) -> &str {
439        &self.session_id
440    }
441
442    /// Returns the underlying Chrome target id.
443    #[must_use]
444    pub fn target_id(&self) -> &str {
445        &self.target_id
446    }
447
448    /// Returns a raw CDP escape hatch scoped to this page session.
449    #[must_use]
450    pub fn cdp(&self) -> CdpSession {
451        CdpSession {
452            client: Arc::clone(&self.state.client),
453            session_id: Some(self.session_id.clone()),
454        }
455    }
456
457    /// Navigate the page and wait for the load event.
458    pub async fn goto(&self, url: impl AsRef<str>) -> Result<PageNavigateResult> {
459        self.goto_with_options(
460            url,
461            NavigateOptions {
462                wait_until: LoadState::Load,
463                timeout: self.default_timeout,
464            },
465        )
466        .await
467    }
468
469    /// Navigate the page with explicit waiting behavior.
470    pub async fn goto_with_options(
471        &self,
472        url: impl AsRef<str>,
473        options: NavigateOptions,
474    ) -> Result<PageNavigateResult> {
475        let result = self
476            .state
477            .client
478            .execute_in_session::<PageNavigateParams>(
479                self.session_id.clone(),
480                &PageNavigateParams {
481                    url: url.as_ref().to_owned(),
482                },
483            )
484            .await?;
485        self.wait_for_load_state(options.wait_until, options.timeout)
486            .await?;
487        Ok(result)
488    }
489
490    /// Evaluate JavaScript in the page main world and return the JSON result.
491    pub async fn evaluate(&self, expression: impl AsRef<str>) -> Result<Value> {
492        self.evaluate_in_frame_value(expression.as_ref(), None)
493            .await
494    }
495
496    /// Evaluate JavaScript and keep the resulting remote object alive.
497    pub async fn evaluate_handle(&self, expression: impl AsRef<str>) -> Result<JsHandle> {
498        self.evaluate_in_frame_handle(expression.as_ref(), None)
499            .await
500    }
501
502    /// Capture a page screenshot.
503    pub async fn screenshot(&self) -> Result<Vec<u8>> {
504        let result: PageCaptureScreenshotResult = self
505            .state
506            .client
507            .execute_in_session::<PageCaptureScreenshotParams>(
508                self.session_id.clone(),
509                &PageCaptureScreenshotParams {
510                    format: Some("png".to_owned()),
511                },
512            )
513            .await?;
514        Ok(base64::engine::general_purpose::STANDARD.decode(result.data)?)
515    }
516
517    /// Capture a screenshot of the current element matched by the locator.
518    pub async fn element_screenshot(&self, locator: &Locator) -> Result<Vec<u8>> {
519        let rect = locator.bounding_rect().await?;
520        let clip = json!({
521            "x": rect.x,
522            "y": rect.y,
523            "width": rect.width,
524            "height": rect.height,
525            "scale": 1.0,
526        });
527        let result = self
528            .cdp()
529            .call_raw(
530                "Page.captureScreenshot",
531                json!({
532                    "format": "png",
533                    "clip": clip,
534                }),
535            )
536            .await?;
537        let data = result
538            .get("data")
539            .and_then(Value::as_str)
540            .ok_or(AutomationError::MissingField("data"))?;
541        Ok(base64::engine::general_purpose::STANDARD.decode(data)?)
542    }
543
544    /// Build a CSS locator.
545    #[must_use]
546    pub fn locator(&self, css_selector: impl Into<String>) -> Locator {
547        Locator::new(self.clone(), SelectorKind::Css(css_selector.into()), None)
548    }
549
550    /// Click the first element matching the CSS selector.
551    pub async fn click(&self, css_selector: impl Into<String>) -> Result<()> {
552        self.locator(css_selector).click().await
553    }
554
555    /// Click the first element matching the CSS selector with custom action options.
556    pub async fn click_with_options(
557        &self,
558        css_selector: impl Into<String>,
559        options: ActionOptions,
560    ) -> Result<()> {
561        self.locator(css_selector).click_with_options(options).await
562    }
563
564    /// Fill the first form control matching the CSS selector.
565    pub async fn fill(
566        &self,
567        css_selector: impl Into<String>,
568        value: impl AsRef<str>,
569    ) -> Result<()> {
570        self.locator(css_selector).fill(value).await
571    }
572
573    /// Fill the first form control matching the CSS selector with custom action options.
574    pub async fn fill_with_options(
575        &self,
576        css_selector: impl Into<String>,
577        value: impl AsRef<str>,
578        options: ActionOptions,
579    ) -> Result<()> {
580        self.locator(css_selector)
581            .fill_with_options(value, options)
582            .await
583    }
584
585    /// Focus the first element matching the CSS selector.
586    pub async fn focus(&self, css_selector: impl Into<String>) -> Result<()> {
587        self.locator(css_selector).focus().await
588    }
589
590    /// Hover the first element matching the CSS selector.
591    pub async fn hover(&self, css_selector: impl Into<String>) -> Result<()> {
592        self.locator(css_selector).hover().await
593    }
594
595    /// Select an option by value on the first matching `<select>` element.
596    pub async fn select(
597        &self,
598        css_selector: impl Into<String>,
599        value: impl AsRef<str>,
600    ) -> Result<()> {
601        self.locator(css_selector).select(value).await
602    }
603
604    /// Wait until the CSS selector matches an element.
605    pub async fn wait_for_selector(
606        &self,
607        css_selector: impl Into<String>,
608        timeout_duration: Duration,
609    ) -> Result<()> {
610        self.locator(css_selector).wait(timeout_duration).await
611    }
612
613    /// Return whether the CSS selector currently matches an element.
614    pub async fn exists(&self, css_selector: impl Into<String>) -> Result<bool> {
615        self.locator(css_selector).exists().await
616    }
617
618    /// Read the text content of the first matching element.
619    pub async fn text_content(&self, css_selector: impl Into<String>) -> Result<String> {
620        self.locator(css_selector).text_content().await
621    }
622
623    /// Read the current page URL.
624    pub async fn url(&self) -> Result<String> {
625        let value = self.evaluate("window.location.href").await?;
626        value
627            .as_str()
628            .map(str::to_owned)
629            .ok_or_else(|| AutomationError::Selector("location.href was not a string".to_owned()))
630    }
631
632    /// Read the current page title.
633    pub async fn title(&self) -> Result<String> {
634        let value = self.evaluate("document.title").await?;
635        value
636            .as_str()
637            .map(str::to_owned)
638            .ok_or_else(|| AutomationError::Selector("document.title was not a string".to_owned()))
639    }
640
641    /// Click the first element whose text content contains the supplied text.
642    pub async fn click_text(&self, text: impl Into<String>) -> Result<()> {
643        self.locator_text(text).click().await
644    }
645
646    /// Click the first element whose text content contains the supplied text with custom action options.
647    pub async fn click_text_with_options(
648        &self,
649        text: impl Into<String>,
650        options: ActionOptions,
651    ) -> Result<()> {
652        self.locator_text(text).click_with_options(options).await
653    }
654
655    /// Wait until an element containing the supplied text appears.
656    pub async fn wait_for_text(
657        &self,
658        text: impl Into<String>,
659        timeout_duration: Duration,
660    ) -> Result<()> {
661        self.locator_text(text).wait(timeout_duration).await
662    }
663
664    /// Wait for the page to reach the requested load state.
665    pub async fn wait_for_load_state(
666        &self,
667        state: LoadState,
668        timeout_duration: Duration,
669    ) -> Result<()> {
670        match state {
671            LoadState::DomContentLoaded => {
672                self.wait_for_event("Page.domContentEventFired", timeout_duration)
673                    .await?;
674            }
675            LoadState::Load => {
676                self.wait_for_event("Page.loadEventFired", timeout_duration)
677                    .await?;
678            }
679            LoadState::NetworkIdle => {
680                let deadline = Instant::now() + timeout_duration;
681                let mut events = EventStream::new(
682                    self.state.transport.subscribe_events(),
683                    Some(self.session_id.clone()),
684                    Some("Page.lifecycleEvent"),
685                );
686                loop {
687                    let remaining = deadline.saturating_duration_since(Instant::now());
688                    let event = events.recv_with_timeout(remaining).await?.ok_or_else(|| {
689                        AutomationError::Timeout {
690                            what: "Page.lifecycleEvent networkIdle".to_owned(),
691                        }
692                    })?;
693                    if event
694                        .params
695                        .get("name")
696                        .and_then(Value::as_str)
697                        .is_some_and(|name| name == "networkIdle")
698                    {
699                        break;
700                    }
701                }
702            }
703        }
704        Ok(())
705    }
706
707    /// Wait for a top-level navigation to commit and then for a chosen load state.
708    pub async fn wait_for_navigation(
709        &self,
710        state: LoadState,
711        timeout_duration: Duration,
712    ) -> Result<String> {
713        let deadline = Instant::now() + timeout_duration;
714        let mut events = EventStream::new(
715            self.state.transport.subscribe_events(),
716            Some(self.session_id.clone()),
717            Some("Page.frameNavigated"),
718        );
719        loop {
720            let remaining = deadline.saturating_duration_since(Instant::now());
721            let event = events.recv_with_timeout(remaining).await?.ok_or_else(|| {
722                AutomationError::Timeout {
723                    what: "top-level navigation".to_owned(),
724                }
725            })?;
726            let frame = event
727                .params
728                .get("frame")
729                .and_then(Value::as_object)
730                .ok_or(AutomationError::MissingField("frame"))?;
731            if frame.get("parentId").is_none() {
732                let remaining = deadline.saturating_duration_since(Instant::now());
733                self.wait_for_load_state(state, remaining).await?;
734                return self.url().await;
735            }
736        }
737    }
738
739    /// Wait until the current page URL contains the supplied substring.
740    pub async fn wait_for_url_contains(
741        &self,
742        needle: impl AsRef<str>,
743        timeout_duration: Duration,
744    ) -> Result<String> {
745        let deadline = Instant::now() + timeout_duration;
746        let needle = needle.as_ref().to_owned();
747
748        loop {
749            let current_url = self.url().await?;
750            if current_url.contains(&needle) {
751                return Ok(current_url);
752            }
753
754            if Instant::now() >= deadline {
755                return Err(AutomationError::Timeout {
756                    what: format!("URL containing {needle}"),
757                });
758            }
759
760            sleep(Duration::from_millis(200)).await;
761        }
762    }
763
764    /// Return the current page frame tree flattened into frame handles.
765    pub async fn frames(&self) -> Result<Vec<Frame>> {
766        let tree = self
767            .state
768            .client
769            .execute_in_session::<PageGetFrameTreeParams>(
770                self.session_id.clone(),
771                &PageGetFrameTreeParams {},
772            )
773            .await?
774            .frame_tree;
775        Ok(flatten_frame_tree(self.clone(), tree))
776    }
777
778    /// Return the current top-level frame.
779    pub async fn main_frame(&self) -> Result<Frame> {
780        self.frames()
781            .await?
782            .into_iter()
783            .find(|frame| frame.parent_frame_id().is_none())
784            .ok_or(AutomationError::MissingField("main frame"))
785    }
786
787    /// Capture cookies plus the current page origin's local and session storage.
788    pub async fn storage_state(&self) -> Result<StorageState> {
789        Ok(StorageState {
790            cookies: self.cookie_state().await?,
791            origins: self
792                .current_origin_storage_state()
793                .await?
794                .into_iter()
795                .collect(),
796        })
797    }
798
799    /// Restore cookies plus origin-scoped local and session storage onto this page.
800    pub async fn restore_storage_state(&self, state: &StorageState) -> Result<()> {
801        self.set_cookie_state(&state.cookies).await?;
802        for origin in &state.origins {
803            self.goto_with_options(
804                &origin.origin,
805                NavigateOptions {
806                    wait_until: LoadState::DomContentLoaded,
807                    timeout: self.default_timeout,
808                },
809            )
810            .await?;
811
812            let local_storage = serde_json::to_string(&origin.local_storage)?;
813            let session_storage = serde_json::to_string(&origin.session_storage)?;
814            self.evaluate(format!(
815                "(() => {{ const local = {local_storage}; const session = {session_storage}; localStorage.clear(); for (const entry of local) localStorage.setItem(entry.name, entry.value); sessionStorage.clear(); for (const entry of session) sessionStorage.setItem(entry.name, entry.value); return true; }})()"
816            ))
817            .await?;
818        }
819        Ok(())
820    }
821
822    /// Build a text locator.
823    #[must_use]
824    pub fn locator_text(&self, text: impl Into<String>) -> Locator {
825        Locator::new(self.clone(), SelectorKind::Text(text.into()), None)
826    }
827
828    /// Build a role locator.
829    #[must_use]
830    pub fn locator_role(&self, role: impl Into<String>) -> Locator {
831        Locator::new(self.clone(), SelectorKind::Role(role.into()), None)
832    }
833
834    /// Build a test-id locator.
835    #[must_use]
836    pub fn locator_test_id(&self, test_id: impl Into<String>) -> Locator {
837        Locator::new(self.clone(), SelectorKind::TestId(test_id.into()), None)
838    }
839
840    /// Press a key against the active element.
841    pub async fn press(&self, key: impl AsRef<str>) -> Result<()> {
842        let key = KeyDefinition::parse(key.as_ref())?;
843        self.state
844            .client
845            .execute_in_session::<InputDispatchKeyEventParams>(
846                self.session_id.clone(),
847                &InputDispatchKeyEventParams {
848                    event_type: "keyDown".to_owned(),
849                    key: key.key.clone(),
850                    code: key.code.clone(),
851                    text: None,
852                    unmodified_text: None,
853                    windows_virtual_key_code: key.key_code,
854                    native_virtual_key_code: key.key_code,
855                },
856            )
857            .await?;
858        if let Some(text) = key.text.clone() {
859            self.state
860                .client
861                .execute_in_session::<InputDispatchKeyEventParams>(
862                    self.session_id.clone(),
863                    &InputDispatchKeyEventParams {
864                        event_type: "char".to_owned(),
865                        key: key.key.clone(),
866                        code: key.code.clone(),
867                        text: Some(text.clone()),
868                        unmodified_text: Some(text),
869                        windows_virtual_key_code: key.key_code,
870                        native_virtual_key_code: key.key_code,
871                    },
872                )
873                .await?;
874        }
875        self.state
876            .client
877            .execute_in_session::<InputDispatchKeyEventParams>(
878                self.session_id.clone(),
879                &InputDispatchKeyEventParams {
880                    event_type: "keyUp".to_owned(),
881                    key: key.key,
882                    code: key.code,
883                    text: None,
884                    unmodified_text: None,
885                    windows_virtual_key_code: key.key_code,
886                    native_virtual_key_code: key.key_code,
887                },
888            )
889            .await?;
890        Ok(())
891    }
892
893    /// Move the mouse pointer to the given viewport coordinates.
894    pub async fn move_mouse(&self, x: f64, y: f64) -> Result<()> {
895        self.dispatch_mouse_event("mouseMoved", x, y, MouseButton::None, 0, 0)
896            .await
897    }
898
899    /// Press a mouse button at the given viewport coordinates.
900    pub async fn mouse_down(&self, x: f64, y: f64, button: MouseButton) -> Result<()> {
901        self.dispatch_mouse_event("mousePressed", x, y, button, 1, 1)
902            .await
903    }
904
905    /// Release a mouse button at the given viewport coordinates.
906    pub async fn mouse_up(&self, x: f64, y: f64, button: MouseButton) -> Result<()> {
907        self.dispatch_mouse_event("mouseReleased", x, y, button, 0, 1)
908            .await
909    }
910
911    /// Click at the given viewport coordinates.
912    pub async fn click_at(&self, x: f64, y: f64, options: ClickOptions) -> Result<()> {
913        self.move_mouse(x, y).await?;
914        self.dispatch_mouse_event("mousePressed", x, y, options.button, 1, options.click_count)
915            .await?;
916        sleep(options.down_up_delay).await;
917        self.dispatch_mouse_event(
918            "mouseReleased",
919            x,
920            y,
921            options.button,
922            0,
923            options.click_count,
924        )
925        .await
926    }
927
928    /// Insert text into the currently focused element.
929    pub async fn insert_text(&self, text: impl AsRef<str>) -> Result<()> {
930        self.state
931            .client
932            .execute_in_session::<InputInsertTextParams>(
933                self.session_id.clone(),
934                &InputInsertTextParams {
935                    text: text.as_ref().to_owned(),
936                },
937            )
938            .await?;
939        Ok(())
940    }
941
942    /// Type text into the currently focused element, waiting between characters.
943    pub async fn type_text(&self, text: impl AsRef<str>, delay: Duration) -> Result<()> {
944        for character in text.as_ref().chars() {
945            self.insert_text(character.to_string()).await?;
946            sleep(delay).await;
947        }
948        Ok(())
949    }
950
951    /// Subscribe to all events for this page session.
952    pub fn events(&self) -> EventStream {
953        EventStream::new(
954            self.state.transport.subscribe_events(),
955            Some(self.session_id.clone()),
956            None,
957        )
958    }
959
960    /// Subscribe to network and fetch events for this page session.
961    pub fn network_events(&self) -> EventStream {
962        EventStream::new(
963            self.state.transport.subscribe_events(),
964            Some(self.session_id.clone()),
965            Some("Network."),
966        )
967        .with_extra_prefix("Fetch.")
968    }
969
970    /// Spawn a background task that runs for each outgoing page request.
971    pub fn on_request<F, Fut>(&self, handler: F) -> tokio::task::JoinHandle<()>
972    where
973        F: Fn(Request) -> Fut + Send + Sync + 'static,
974        Fut: Future<Output = ()> + Send + 'static,
975    {
976        let mut events = self.network_events();
977        let handler = Arc::new(handler);
978        tokio::spawn(async move {
979            loop {
980                let Ok(event) = events.recv().await else {
981                    break;
982                };
983                let Some(request) = Request::from_network_event(event) else {
984                    continue;
985                };
986                handler(request).await;
987            }
988        })
989    }
990
991    /// Spawn a background task that runs for each page response.
992    pub fn on_response<F, Fut>(&self, handler: F) -> tokio::task::JoinHandle<()>
993    where
994        F: Fn(Response) -> Fut + Send + Sync + 'static,
995        Fut: Future<Output = ()> + Send + 'static,
996    {
997        let mut events = self.network_events();
998        let handler = Arc::new(handler);
999        tokio::spawn(async move {
1000            loop {
1001                let Ok(event) = events.recv().await else {
1002                    break;
1003                };
1004                let Some(response) = Response::from_network_event(event) else {
1005                    continue;
1006                };
1007                handler(response).await;
1008            }
1009        })
1010    }
1011
1012    /// Wait for the next request that satisfies the predicate.
1013    pub async fn wait_for_request<F>(
1014        &self,
1015        timeout_duration: Duration,
1016        predicate: F,
1017    ) -> Result<Request>
1018    where
1019        F: Fn(&Request) -> bool,
1020    {
1021        let deadline = Instant::now() + timeout_duration;
1022        let mut events = self.network_events();
1023        loop {
1024            let remaining = deadline.saturating_duration_since(Instant::now());
1025            let event = events.recv_with_timeout(remaining).await?.ok_or_else(|| {
1026                AutomationError::Timeout {
1027                    what: "request event".to_owned(),
1028                }
1029            })?;
1030            let Some(request) = Request::from_network_event(event) else {
1031                continue;
1032            };
1033            if predicate(&request) {
1034                return Ok(request);
1035            }
1036        }
1037    }
1038
1039    /// Wait for the next response that satisfies the predicate.
1040    pub async fn wait_for_response<F>(
1041        &self,
1042        timeout_duration: Duration,
1043        predicate: F,
1044    ) -> Result<Response>
1045    where
1046        F: Fn(&Response) -> bool,
1047    {
1048        let deadline = Instant::now() + timeout_duration;
1049        let mut events = self.network_events();
1050        loop {
1051            let remaining = deadline.saturating_duration_since(Instant::now());
1052            let event = events.recv_with_timeout(remaining).await?.ok_or_else(|| {
1053                AutomationError::Timeout {
1054                    what: "response event".to_owned(),
1055                }
1056            })?;
1057            let Some(response) = Response::from_network_event(event) else {
1058                continue;
1059            };
1060            if predicate(&response) {
1061                return Ok(response);
1062            }
1063        }
1064    }
1065
1066    /// Enable request interception for this page.
1067    pub async fn enable_request_interception<I>(&self, patterns: I) -> Result<()>
1068    where
1069        I: IntoIterator<Item = RequestPattern>,
1070    {
1071        let patterns = collect_request_patterns(patterns);
1072        self.state
1073            .client
1074            .execute_in_session::<FetchEnableParams>(
1075                self.session_id.clone(),
1076                &FetchEnableParams {
1077                    patterns: Some(patterns.iter().map(RequestPattern::to_cdp).collect()),
1078                },
1079            )
1080            .await?;
1081        Ok(())
1082    }
1083
1084    /// Enable request interception and handle the next matched route in the background.
1085    pub async fn route_once<I, F, Fut>(
1086        &self,
1087        patterns: I,
1088        timeout_duration: Duration,
1089        handler: F,
1090    ) -> Result<tokio::task::JoinHandle<Result<()>>>
1091    where
1092        I: IntoIterator<Item = RequestPattern>,
1093        F: FnOnce(Route) -> Fut + Send + 'static,
1094        Fut: Future<Output = Result<()>> + Send + 'static,
1095    {
1096        let mut events = EventStream::new(
1097            self.state.transport.subscribe_events(),
1098            Some(self.session_id.clone()),
1099            Some("Fetch."),
1100        );
1101        self.enable_request_interception(patterns).await?;
1102        let client = Arc::clone(&self.state.client);
1103        let session_id = self.session_id.clone();
1104
1105        Ok(tokio::spawn(async move {
1106            let event = events
1107                .recv_with_timeout(timeout_duration)
1108                .await?
1109                .ok_or_else(|| AutomationError::Timeout {
1110                    what: format!("page route for session {session_id}"),
1111                })?;
1112            let route = Route::from_event(client, event)?;
1113            handler(route).await
1114        }))
1115    }
1116
1117    /// Wait for the next intercepted route on this page.
1118    pub async fn next_route(&self, timeout_duration: Duration) -> Result<Route> {
1119        let mut events = EventStream::new(
1120            self.state.transport.subscribe_events(),
1121            Some(self.session_id.clone()),
1122            Some("Fetch."),
1123        );
1124        let event = events
1125            .recv_with_timeout(timeout_duration)
1126            .await?
1127            .ok_or_else(|| AutomationError::Timeout {
1128                what: "page route".to_owned(),
1129            })?;
1130        Route::from_event(Arc::clone(&self.state.client), event)
1131    }
1132
1133    /// Wait for a popup page opened by this page.
1134    pub async fn wait_for_popup(&self, timeout_duration: Duration) -> Result<Page> {
1135        let deadline = Instant::now() + timeout_duration;
1136        let mut events = EventStream::new(
1137            self.state.transport.subscribe_events(),
1138            None,
1139            Some("Target.targetCreated"),
1140        );
1141        loop {
1142            let remaining = deadline.saturating_duration_since(Instant::now());
1143            let event = events.recv_with_timeout(remaining).await?.ok_or_else(|| {
1144                AutomationError::Timeout {
1145                    what: "popup target".to_owned(),
1146                }
1147            })?;
1148            let target = serde_json::from_value::<CreatedTargetEvent>(event.params)?;
1149            if target.target_info.target_type != "page" {
1150                continue;
1151            }
1152            if target.target_info.opener_id.as_deref() != Some(self.target_id.as_str()) {
1153                continue;
1154            }
1155            let attached = self
1156                .state
1157                .client
1158                .execute::<TargetAttachToTargetParams>(&TargetAttachToTargetParams {
1159                    target_id: target.target_info.target_id.clone(),
1160                    flatten: Some(true),
1161                })
1162                .await?;
1163            return bootstrap_page_session(
1164                Arc::clone(&self.state),
1165                attached.session_id,
1166                target.target_info.target_id,
1167            )
1168            .await;
1169        }
1170    }
1171
1172    async fn wait_for_event(
1173        &self,
1174        method: &str,
1175        timeout_duration: Duration,
1176    ) -> Result<NetworkEvent> {
1177        let mut events = EventStream::new(
1178            self.state.transport.subscribe_events(),
1179            Some(self.session_id.clone()),
1180            Some(method),
1181        );
1182        events
1183            .recv_with_timeout(timeout_duration)
1184            .await?
1185            .ok_or_else(|| AutomationError::Timeout {
1186                what: method.to_owned(),
1187            })
1188    }
1189
1190    async fn dispatch_mouse_event(
1191        &self,
1192        event_type: &str,
1193        x: f64,
1194        y: f64,
1195        button: MouseButton,
1196        buttons: u8,
1197        click_count: u8,
1198    ) -> Result<()> {
1199        self.cdp()
1200            .call_raw(
1201                "Input.dispatchMouseEvent",
1202                json!({
1203                    "type": event_type,
1204                    "x": x,
1205                    "y": y,
1206                    "button": button.as_cdp_value(),
1207                    "buttons": buttons,
1208                    "clickCount": click_count,
1209                }),
1210            )
1211            .await?;
1212        Ok(())
1213    }
1214
1215    async fn evaluate_in_frame_value(
1216        &self,
1217        expression: &str,
1218        frame_id: Option<&str>,
1219    ) -> Result<Value> {
1220        let result = self
1221            .state
1222            .client
1223            .execute_in_session::<RuntimeEvaluateParams>(
1224                self.session_id.clone(),
1225                &RuntimeEvaluateParams {
1226                    expression: expression.to_owned(),
1227                    await_promise: Some(true),
1228                    return_by_value: Some(true),
1229                    context_id: match frame_id {
1230                        Some(frame_id) => Some(self.execution_context_id(frame_id).await?),
1231                        None => None,
1232                    },
1233                },
1234            )
1235            .await?;
1236        Ok(result.result.value.unwrap_or(Value::Null))
1237    }
1238
1239    async fn evaluate_in_frame_handle(
1240        &self,
1241        expression: &str,
1242        frame_id: Option<&str>,
1243    ) -> Result<JsHandle> {
1244        let result = self
1245            .state
1246            .client
1247            .execute_in_session::<RuntimeEvaluateParams>(
1248                self.session_id.clone(),
1249                &RuntimeEvaluateParams {
1250                    expression: expression.to_owned(),
1251                    await_promise: Some(true),
1252                    return_by_value: Some(false),
1253                    context_id: match frame_id {
1254                        Some(frame_id) => Some(self.execution_context_id(frame_id).await?),
1255                        None => None,
1256                    },
1257                },
1258            )
1259            .await?;
1260        JsHandle::from_remote_object(self.clone(), result.result)
1261    }
1262
1263    async fn execution_context_id(&self, frame_id: &str) -> Result<i64> {
1264        Ok(self
1265            .state
1266            .client
1267            .execute_in_session::<PageCreateIsolatedWorldParams>(
1268                self.session_id.clone(),
1269                &PageCreateIsolatedWorldParams {
1270                    frame_id: frame_id.to_owned(),
1271                    world_name: Some("playhard".to_owned()),
1272                    grant_universal_access: None,
1273                },
1274            )
1275            .await?
1276            .execution_context_id)
1277    }
1278
1279    async fn cookie_state(&self) -> Result<Vec<CookieState>> {
1280        let value = self
1281            .state
1282            .client
1283            .call_raw("Storage.getCookies", json!({}), None)
1284            .await
1285            .map_err(AutomationError::from)?;
1286        Ok(serde_json::from_value::<CookieResult>(value)?
1287            .cookies
1288            .into_iter()
1289            .map(CookieState::from)
1290            .collect())
1291    }
1292
1293    async fn set_cookie_state(&self, cookies: &[CookieState]) -> Result<()> {
1294        if cookies.is_empty() {
1295            return Ok(());
1296        }
1297
1298        let cookies = cookies
1299            .iter()
1300            .cloned()
1301            .map(SetCookie::from)
1302            .collect::<Vec<_>>();
1303        self.state
1304            .client
1305            .call_raw("Storage.setCookies", json!({ "cookies": cookies }), None)
1306            .await
1307            .map_err(AutomationError::from)?;
1308        Ok(())
1309    }
1310
1311    async fn current_origin_storage_state(&self) -> Result<Option<OriginStorageState>> {
1312        let value = self
1313            .evaluate(
1314                "(() => { const origin = window.location.origin; if (!origin || origin === 'null') return null; const dump = (storage) => Object.keys(storage).sort().map((name) => ({ name, value: storage.getItem(name) ?? '' })); return { origin, localStorage: dump(window.localStorage), sessionStorage: dump(window.sessionStorage) }; })()",
1315            )
1316            .await?;
1317        Ok(serde_json::from_value(value)?)
1318    }
1319}
1320
1321async fn bootstrap_page_session(
1322    state: Arc<BrowserState>,
1323    session_id: String,
1324    target_id: String,
1325) -> Result<Page> {
1326    state
1327        .client
1328        .execute_in_session::<PageEnableParams>(session_id.clone(), &PageEnableParams {})
1329        .await?;
1330    state
1331        .client
1332        .execute_in_session::<PageSetLifecycleEventsEnabledParams>(
1333            session_id.clone(),
1334            &PageSetLifecycleEventsEnabledParams { enabled: true },
1335        )
1336        .await?;
1337    state
1338        .client
1339        .execute_in_session::<RuntimeEnableParams>(session_id.clone(), &RuntimeEnableParams {})
1340        .await?;
1341    state
1342        .client
1343        .execute_in_session::<NetworkEnableParams>(session_id.clone(), &NetworkEnableParams {})
1344        .await?;
1345    state
1346        .client
1347        .call_raw("DOM.enable", json!({}), Some(session_id.clone()))
1348        .await?;
1349
1350    {
1351        let mut sessions = lock_unpoisoned(&state.page_sessions);
1352        if !sessions.iter().any(|existing| existing == &session_id) {
1353            sessions.push(session_id.clone());
1354        }
1355    }
1356
1357    let page = Page {
1358        state: Arc::clone(&state),
1359        session_id,
1360        target_id,
1361        default_timeout: Duration::from_secs(30),
1362    };
1363
1364    let browser_patterns = {
1365        let patterns = lock_unpoisoned(&state.browser_interception_patterns);
1366        patterns.clone()
1367    };
1368    if let Some(patterns) = browser_patterns {
1369        page.enable_request_interception(patterns).await?;
1370    }
1371
1372    Ok(page)
1373}
1374
1375fn flatten_frame_tree(page: Page, tree: playhard_cdp::PageFrameTree) -> Vec<Frame> {
1376    fn visit(page: &Page, tree: playhard_cdp::PageFrameTree, frames: &mut Vec<Frame>) {
1377        let frame = tree.frame;
1378        frames.push(Frame {
1379            page: page.clone(),
1380            frame_id: frame.id,
1381            parent_frame_id: frame.parent_id,
1382            name: frame.name,
1383            url: frame.url,
1384        });
1385        for child in tree.child_frames {
1386            visit(page, child, frames);
1387        }
1388    }
1389
1390    let mut frames = Vec::new();
1391    visit(&page, tree, &mut frames);
1392    frames
1393}
1394
1395/// Load states that a page can wait for after navigation.
1396#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1397pub enum LoadState {
1398    /// The DOMContentLoaded event fired.
1399    DomContentLoaded,
1400    /// The full load event fired.
1401    Load,
1402    /// Chrome reported the page as network idle.
1403    NetworkIdle,
1404}
1405
1406/// Navigation options for page loads.
1407#[derive(Debug, Clone, Copy)]
1408pub struct NavigateOptions {
1409    /// Load state that must be reached before the call returns.
1410    pub wait_until: LoadState,
1411    /// Maximum wait time for the chosen load state.
1412    pub timeout: Duration,
1413}
1414
1415impl Default for NavigateOptions {
1416    fn default() -> Self {
1417        Self {
1418            wait_until: LoadState::Load,
1419            timeout: Duration::from_secs(30),
1420        }
1421    }
1422}
1423
1424/// Serializable page storage state.
1425#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1426pub struct StorageState {
1427    /// Browser cookies that should be restored before navigation.
1428    pub cookies: Vec<CookieState>,
1429    /// Origin-scoped local and session storage values.
1430    pub origins: Vec<OriginStorageState>,
1431}
1432
1433/// Serializable browser cookie state.
1434#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1435pub struct CookieState {
1436    /// Cookie name.
1437    pub name: String,
1438    /// Cookie value.
1439    pub value: String,
1440    /// Cookie domain.
1441    pub domain: String,
1442    /// Cookie path.
1443    pub path: String,
1444    /// Expiration timestamp in seconds since the epoch, when persistent.
1445    #[serde(skip_serializing_if = "Option::is_none")]
1446    pub expires: Option<f64>,
1447    /// Whether the cookie is HTTP only.
1448    #[serde(rename = "httpOnly")]
1449    pub http_only: bool,
1450    /// Whether the cookie is marked secure.
1451    pub secure: bool,
1452    /// SameSite policy when Chrome reported one.
1453    #[serde(rename = "sameSite", skip_serializing_if = "Option::is_none")]
1454    pub same_site: Option<String>,
1455}
1456
1457/// Serializable origin-scoped web storage state.
1458#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1459pub struct OriginStorageState {
1460    /// Storage origin such as `https://example.com`.
1461    pub origin: String,
1462    /// Local storage entries for the origin.
1463    #[serde(rename = "localStorage", default)]
1464    pub local_storage: Vec<StorageEntry>,
1465    /// Session storage entries for the origin on the current page.
1466    #[serde(rename = "sessionStorage", default)]
1467    pub session_storage: Vec<StorageEntry>,
1468}
1469
1470/// Serializable key/value storage entry.
1471#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1472pub struct StorageEntry {
1473    /// Storage key.
1474    pub name: String,
1475    /// Storage value.
1476    pub value: String,
1477}
1478
1479/// A page frame handle.
1480#[derive(Clone)]
1481pub struct Frame {
1482    page: Page,
1483    frame_id: String,
1484    parent_frame_id: Option<String>,
1485    name: Option<String>,
1486    url: String,
1487}
1488
1489impl Frame {
1490    /// Returns the unique frame id.
1491    #[must_use]
1492    pub fn id(&self) -> &str {
1493        &self.frame_id
1494    }
1495
1496    /// Returns the parent frame id when this is not the main frame.
1497    #[must_use]
1498    pub fn parent_frame_id(&self) -> Option<&str> {
1499        self.parent_frame_id.as_deref()
1500    }
1501
1502    /// Returns the current frame name, when present.
1503    #[must_use]
1504    pub fn name(&self) -> Option<&str> {
1505        self.name.as_deref()
1506    }
1507
1508    /// Returns the last observed frame URL.
1509    #[must_use]
1510    pub fn url(&self) -> &str {
1511        &self.url
1512    }
1513
1514    /// Evaluate JavaScript inside this frame.
1515    pub async fn evaluate(&self, expression: impl AsRef<str>) -> Result<Value> {
1516        self.page
1517            .evaluate_in_frame_value(expression.as_ref(), Some(&self.frame_id))
1518            .await
1519    }
1520
1521    /// Evaluate JavaScript inside this frame and keep the result alive remotely.
1522    pub async fn evaluate_handle(&self, expression: impl AsRef<str>) -> Result<JsHandle> {
1523        self.page
1524            .evaluate_in_frame_handle(expression.as_ref(), Some(&self.frame_id))
1525            .await
1526    }
1527
1528    /// Build a CSS locator rooted in this frame.
1529    #[must_use]
1530    pub fn locator(&self, css_selector: impl Into<String>) -> Locator {
1531        Locator::new(
1532            self.page.clone(),
1533            SelectorKind::Css(css_selector.into()),
1534            Some(self.frame_id.clone()),
1535        )
1536    }
1537
1538    /// Read text from the first element matching the CSS selector inside this frame.
1539    pub async fn text_content(&self, css_selector: impl Into<String>) -> Result<String> {
1540        self.locator(css_selector).text_content().await
1541    }
1542
1543    /// Wait for an element inside this frame.
1544    pub async fn wait_for_selector(
1545        &self,
1546        css_selector: impl Into<String>,
1547        timeout_duration: Duration,
1548    ) -> Result<()> {
1549        self.locator(css_selector).wait(timeout_duration).await
1550    }
1551}
1552
1553/// A query that resolves a DOM element.
1554#[derive(Clone)]
1555pub struct Locator {
1556    page: Page,
1557    selector: SelectorKind,
1558    frame_id: Option<String>,
1559}
1560
1561impl Locator {
1562    fn new(page: Page, selector: SelectorKind, frame_id: Option<String>) -> Self {
1563        Self {
1564            page,
1565            selector,
1566            frame_id,
1567        }
1568    }
1569
1570    /// Resolve the current locator into an element handle.
1571    pub async fn element_handle(&self) -> Result<ElementHandle> {
1572        let script = format!(
1573            "(() => {{ const el = {selector}; return el; }})()",
1574            selector = self.selector.javascript_expression()?,
1575        );
1576        self.page
1577            .evaluate_in_frame_handle(&script, self.frame_id.as_deref())
1578            .await?
1579            .as_element()
1580            .ok_or_else(|| {
1581                AutomationError::Selector("locator did not resolve to an element".to_owned())
1582            })
1583    }
1584
1585    /// Click the matched element.
1586    pub async fn click(&self) -> Result<()> {
1587        self.click_with_options(ActionOptions::default()).await
1588    }
1589
1590    /// Click the matched element with custom action options.
1591    pub async fn click_with_options(&self, options: ActionOptions) -> Result<()> {
1592        self.wait(options.timeout).await?;
1593        if !options.force {
1594            self.wait_for_actionable(options.timeout).await?;
1595        }
1596        self.scroll_into_view().await?;
1597        let rect = self.bounding_rect().await?;
1598        let x = rect.x + (rect.width / 2.0);
1599        let y = rect.y + (rect.height / 2.0);
1600        self.page.click_at(x, y, ClickOptions::default()).await
1601    }
1602
1603    /// Fill the matched form field.
1604    pub async fn fill(&self, value: impl AsRef<str>) -> Result<()> {
1605        self.fill_with_options(value, ActionOptions::default())
1606            .await
1607    }
1608
1609    /// Fill the matched form field with custom action options.
1610    pub async fn fill_with_options(
1611        &self,
1612        value: impl AsRef<str>,
1613        options: ActionOptions,
1614    ) -> Result<()> {
1615        self.wait(options.timeout).await?;
1616        if !options.force {
1617            self.wait_for_actionable(options.timeout).await?;
1618        }
1619        let element = self.element_handle().await?;
1620        element.scroll_into_view().await?;
1621        element.focus().await?;
1622        element.select_text().await?;
1623        self.page.insert_text(value.as_ref()).await
1624    }
1625
1626    /// Focus the matched element.
1627    pub async fn focus(&self) -> Result<()> {
1628        self.wait(ActionOptions::default().timeout).await?;
1629        self.element_handle().await?.focus().await
1630    }
1631
1632    /// Hover the matched element.
1633    pub async fn hover(&self) -> Result<()> {
1634        self.wait(ActionOptions::default().timeout).await?;
1635        self.wait_for_actionable(ActionOptions::default().timeout)
1636            .await?;
1637        self.scroll_into_view().await?;
1638        let rect = self.bounding_rect().await?;
1639        let x = rect.x + (rect.width / 2.0);
1640        let y = rect.y + (rect.height / 2.0);
1641        self.page.move_mouse(x, y).await
1642    }
1643
1644    /// Select an option by value on the matched `<select>`.
1645    pub async fn select(&self, value: impl AsRef<str>) -> Result<()> {
1646        self.wait(ActionOptions::default().timeout).await?;
1647        let value = serde_json::to_string(value.as_ref())?;
1648        self.run_selector_action(&format!(
1649            "el.value = {value}; el.dispatchEvent(new Event('input', {{ bubbles: true }})); el.dispatchEvent(new Event('change', {{ bubbles: true }}));"
1650        ))
1651        .await
1652    }
1653
1654    /// Wait until the locator matches an element.
1655    pub async fn wait(&self, timeout_duration: Duration) -> Result<()> {
1656        let deadline = Instant::now() + timeout_duration;
1657        loop {
1658            if self.exists().await? {
1659                return Ok(());
1660            }
1661            if Instant::now() >= deadline {
1662                return Err(AutomationError::Timeout {
1663                    what: "locator existence".to_owned(),
1664                });
1665            }
1666            sleep(Duration::from_millis(100)).await;
1667        }
1668    }
1669
1670    /// Returns true when the locator currently matches an element.
1671    pub async fn exists(&self) -> Result<bool> {
1672        let status = self.element_status().await?;
1673        Ok(status.exists)
1674    }
1675
1676    /// Read the text content of the matched element.
1677    pub async fn text_content(&self) -> Result<String> {
1678        self.element_handle().await?.text_content().await
1679    }
1680
1681    /// Wait until the matched element's text content satisfies the predicate.
1682    pub async fn wait_for_text_content<F>(
1683        &self,
1684        timeout_duration: Duration,
1685        predicate: F,
1686    ) -> Result<String>
1687    where
1688        F: Fn(&str) -> bool,
1689    {
1690        let deadline = Instant::now() + timeout_duration;
1691        loop {
1692            match self.text_content().await {
1693                Ok(text) if predicate(&text) => return Ok(text),
1694                Ok(_) | Err(AutomationError::MissingElement) => {}
1695                Err(error) => return Err(error),
1696            }
1697
1698            if Instant::now() >= deadline {
1699                return Err(AutomationError::Timeout {
1700                    what: "locator text content".to_owned(),
1701                });
1702            }
1703
1704            sleep(Duration::from_millis(100)).await;
1705        }
1706    }
1707
1708    async fn bounding_rect(&self) -> Result<BoundingRect> {
1709        self.element_handle().await?.bounding_rect().await
1710    }
1711
1712    async fn scroll_into_view(&self) -> Result<()> {
1713        self.element_handle().await?.scroll_into_view().await
1714    }
1715
1716    async fn wait_for_actionable(&self, timeout_duration: Duration) -> Result<()> {
1717        let deadline = Instant::now() + timeout_duration;
1718        loop {
1719            let status = self.element_status().await?;
1720            if status.exists && status.visible && status.enabled {
1721                return Ok(());
1722            }
1723            if Instant::now() >= deadline {
1724                return Err(AutomationError::Timeout {
1725                    what: "locator actionability".to_owned(),
1726                });
1727            }
1728            sleep(Duration::from_millis(100)).await;
1729        }
1730    }
1731
1732    async fn element_status(&self) -> Result<ElementStatus> {
1733        let script = format!(
1734            "(() => {{ const el = {selector}; if (!el) return {{ exists: false, visible: false, enabled: false }}; const style = window.getComputedStyle(el); const rect = el.getBoundingClientRect(); const visible = style.display !== 'none' && style.visibility !== 'hidden' && Number(rect.width) > 0 && Number(rect.height) > 0; const enabled = !('disabled' in el) || !el.disabled; return {{ exists: true, visible, enabled }}; }})()",
1735            selector = self.selector.javascript_expression()?,
1736        );
1737        let value = self
1738            .page
1739            .evaluate_in_frame_value(&script, self.frame_id.as_deref())
1740            .await?;
1741        Ok(serde_json::from_value(value)?)
1742    }
1743
1744    async fn run_selector_action(&self, body: &str) -> Result<()> {
1745        let script = format!(
1746            "(() => {{ const el = {selector}; if (!el) return {{ ok: false, message: 'missing element' }}; {body} return {{ ok: true }}; }})()",
1747            selector = self.selector.javascript_expression()?,
1748            body = body,
1749        );
1750        let value = self
1751            .page
1752            .evaluate_in_frame_value(&script, self.frame_id.as_deref())
1753            .await?;
1754        let result = serde_json::from_value::<SelectorActionResult>(value)?;
1755        if result.ok {
1756            Ok(())
1757        } else {
1758            Err(AutomationError::Selector(
1759                result
1760                    .message
1761                    .unwrap_or_else(|| "selector action failed".to_owned()),
1762            ))
1763        }
1764    }
1765}
1766
1767#[derive(Debug, Deserialize)]
1768struct SelectorActionResult {
1769    ok: bool,
1770    message: Option<String>,
1771}
1772
1773#[derive(Debug, Deserialize)]
1774struct ElementStatus {
1775    exists: bool,
1776    visible: bool,
1777    enabled: bool,
1778}
1779
1780/// Rectangle returned from DOM element geometry queries.
1781#[derive(Debug, Deserialize)]
1782pub struct BoundingRect {
1783    /// The left edge in CSS pixels.
1784    pub x: f64,
1785    /// The top edge in CSS pixels.
1786    pub y: f64,
1787    /// The rectangle width in CSS pixels.
1788    pub width: f64,
1789    /// The rectangle height in CSS pixels.
1790    pub height: f64,
1791}
1792
1793#[derive(Clone, Debug)]
1794enum SelectorKind {
1795    Css(String),
1796    Text(String),
1797    Role(String),
1798    TestId(String),
1799}
1800
1801impl SelectorKind {
1802    fn javascript_expression(&self) -> Result<String> {
1803        let value = match self {
1804            Self::Css(selector) => {
1805                format!(
1806                    "document.querySelector({})",
1807                    serde_json::to_string(selector)?
1808                )
1809            }
1810            Self::Text(text) => {
1811                let text = serde_json::to_string(text)?;
1812                format!(
1813                    "(() => {{ const needle = {text}; const nodes = Array.from(document.querySelectorAll('body, body *')); const candidates = nodes.filter((node) => (node.textContent ?? '').includes(needle)); return candidates.find((node) => !Array.from(node.children).some((child) => (child.textContent ?? '').includes(needle))) ?? candidates[0] ?? null; }})()"
1814                )
1815            }
1816            Self::Role(role) => {
1817                let role = serde_json::to_string(role)?;
1818                format!(
1819                    "(() => {{ const wanted = {role}; const inferRole = (el) => {{ if (el.hasAttribute('role')) return el.getAttribute('role'); const tag = el.tagName.toLowerCase(); if (tag === 'button') return 'button'; if (tag === 'a' && el.hasAttribute('href')) return 'link'; if (tag === 'select') return 'combobox'; if (tag === 'textarea') return 'textbox'; if (tag === 'img') return 'img'; if (['h1','h2','h3','h4','h5','h6'].includes(tag)) return 'heading'; if (tag === 'input') {{ const type = (el.getAttribute('type') ?? 'text').toLowerCase(); if (['button','submit','reset'].includes(type)) return 'button'; if (type === 'checkbox') return 'checkbox'; if (type === 'radio') return 'radio'; return 'textbox'; }} return null; }}; const nodes = Array.from(document.querySelectorAll('[role],button,a,input,select,textarea,img,h1,h2,h3,h4,h5,h6')); return nodes.find((node) => inferRole(node) === wanted) ?? null; }})()"
1820                )
1821            }
1822            Self::TestId(test_id) => format!(
1823                "document.querySelector({})",
1824                serde_json::to_string(&format!(r#"[data-testid="{test_id}"]"#))?
1825            ),
1826        };
1827        Ok(value)
1828    }
1829}
1830
1831/// Options for actions like click and fill.
1832#[derive(Debug, Clone, Copy)]
1833pub struct ActionOptions {
1834    /// Timeout for auto-wait behavior.
1835    pub timeout: Duration,
1836    /// Skip actionability checks while still resolving the locator.
1837    pub force: bool,
1838}
1839
1840impl Default for ActionOptions {
1841    fn default() -> Self {
1842        Self {
1843            timeout: Duration::from_secs(30),
1844            force: false,
1845        }
1846    }
1847}
1848
1849/// Mouse buttons recognized by Chrome input dispatch.
1850#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1851pub enum MouseButton {
1852    /// No mouse button.
1853    None,
1854    /// The primary mouse button.
1855    Left,
1856    /// The auxiliary mouse button.
1857    Middle,
1858    /// The secondary mouse button.
1859    Right,
1860    /// The browser back button.
1861    Back,
1862    /// The browser forward button.
1863    Forward,
1864}
1865
1866impl MouseButton {
1867    fn as_cdp_value(self) -> &'static str {
1868        match self {
1869            Self::None => "none",
1870            Self::Left => "left",
1871            Self::Middle => "middle",
1872            Self::Right => "right",
1873            Self::Back => "back",
1874            Self::Forward => "forward",
1875        }
1876    }
1877}
1878
1879/// Options for synthetic mouse clicks.
1880#[derive(Debug, Clone, Copy)]
1881pub struct ClickOptions {
1882    /// Which mouse button to click with.
1883    pub button: MouseButton,
1884    /// Number of clicks to report to the page.
1885    pub click_count: u8,
1886    /// Delay between mouse down and mouse up.
1887    pub down_up_delay: Duration,
1888}
1889
1890impl Default for ClickOptions {
1891    fn default() -> Self {
1892        Self {
1893            button: MouseButton::Left,
1894            click_count: 1,
1895            down_up_delay: Duration::from_millis(50),
1896        }
1897    }
1898}
1899
1900/// Request interception pattern.
1901#[derive(Debug, Clone, PartialEq, Eq)]
1902pub struct RequestPattern {
1903    /// Optional URL pattern.
1904    pub url_pattern: Option<String>,
1905    /// Optional request stage.
1906    pub request_stage: Option<String>,
1907}
1908
1909impl RequestPattern {
1910    /// Create a request interception pattern for any stage.
1911    #[must_use]
1912    pub fn new(url_pattern: impl Into<String>) -> Self {
1913        Self {
1914            url_pattern: Some(url_pattern.into()),
1915            request_stage: None,
1916        }
1917    }
1918
1919    /// Create a request-stage interception pattern.
1920    #[must_use]
1921    pub fn request(url_pattern: impl Into<String>) -> Self {
1922        Self {
1923            url_pattern: Some(url_pattern.into()),
1924            request_stage: Some("Request".to_owned()),
1925        }
1926    }
1927
1928    /// Create a response-stage interception pattern.
1929    #[must_use]
1930    pub fn response(url_pattern: impl Into<String>) -> Self {
1931        Self {
1932            url_pattern: Some(url_pattern.into()),
1933            request_stage: Some("Response".to_owned()),
1934        }
1935    }
1936
1937    fn to_cdp(&self) -> playhard_cdp::FetchPattern {
1938        playhard_cdp::FetchPattern {
1939            url_pattern: self.url_pattern.clone(),
1940            request_stage: self.request_stage.clone(),
1941        }
1942    }
1943}
1944
1945impl From<&str> for RequestPattern {
1946    fn from(url_pattern: &str) -> Self {
1947        Self::new(url_pattern)
1948    }
1949}
1950
1951impl From<String> for RequestPattern {
1952    fn from(url_pattern: String) -> Self {
1953        Self::new(url_pattern)
1954    }
1955}
1956
1957fn collect_request_patterns<I>(patterns: I) -> Vec<RequestPattern>
1958where
1959    I: IntoIterator<Item = RequestPattern>,
1960{
1961    patterns.into_iter().collect()
1962}
1963
1964/// A normalized network or fetch event.
1965#[derive(Debug, Clone)]
1966pub struct NetworkEvent {
1967    /// Event method name.
1968    pub method: String,
1969    /// Optional page session id.
1970    pub session_id: Option<String>,
1971    /// Raw JSON parameters.
1972    pub params: Value,
1973}
1974
1975impl From<TransportEvent> for NetworkEvent {
1976    fn from(event: TransportEvent) -> Self {
1977        Self {
1978            method: event.method,
1979            session_id: event.session_id,
1980            params: event.params.unwrap_or(Value::Null),
1981        }
1982    }
1983}
1984
1985/// A page request surfaced from `Network.requestWillBeSent`.
1986#[derive(Debug, Clone)]
1987pub struct Request {
1988    request_id: String,
1989    method: String,
1990    url: String,
1991    headers: BTreeMap<String, String>,
1992    post_data: Option<String>,
1993    resource_type: Option<String>,
1994    frame_id: Option<String>,
1995    session_id: Option<String>,
1996}
1997
1998impl Request {
1999    fn from_network_event(event: NetworkEvent) -> Option<Self> {
2000        if event.method != "Network.requestWillBeSent" {
2001            return None;
2002        }
2003
2004        let request_id = event.params.get("requestId")?.as_str()?.to_owned();
2005        let request = event.params.get("request")?;
2006        Some(Self {
2007            request_id,
2008            method: request.get("method")?.as_str()?.to_owned(),
2009            url: request.get("url")?.as_str()?.to_owned(),
2010            headers: normalize_headers(request.get("headers")),
2011            post_data: request
2012                .get("postData")
2013                .and_then(Value::as_str)
2014                .map(str::to_owned),
2015            resource_type: event
2016                .params
2017                .get("type")
2018                .and_then(Value::as_str)
2019                .map(str::to_owned),
2020            frame_id: event
2021                .params
2022                .get("frameId")
2023                .and_then(Value::as_str)
2024                .map(str::to_owned),
2025            session_id: event.session_id,
2026        })
2027    }
2028
2029    /// Returns the protocol request id.
2030    #[must_use]
2031    pub fn request_id(&self) -> &str {
2032        &self.request_id
2033    }
2034
2035    /// Returns the HTTP method.
2036    #[must_use]
2037    pub fn method(&self) -> &str {
2038        &self.method
2039    }
2040
2041    /// Returns the request URL.
2042    #[must_use]
2043    pub fn url(&self) -> &str {
2044        &self.url
2045    }
2046
2047    /// Returns the outgoing request headers.
2048    #[must_use]
2049    pub fn headers(&self) -> &BTreeMap<String, String> {
2050        &self.headers
2051    }
2052
2053    /// Returns the request body payload when the browser exposed one.
2054    #[must_use]
2055    pub fn post_data(&self) -> Option<&str> {
2056        self.post_data.as_deref()
2057    }
2058
2059    /// Returns the resource type when Chrome reported one.
2060    #[must_use]
2061    pub fn resource_type(&self) -> Option<&str> {
2062        self.resource_type.as_deref()
2063    }
2064
2065    /// Returns the associated frame id when available.
2066    #[must_use]
2067    pub fn frame_id(&self) -> Option<&str> {
2068        self.frame_id.as_deref()
2069    }
2070
2071    /// Returns the originating page session when available.
2072    #[must_use]
2073    pub fn session_id(&self) -> Option<&str> {
2074        self.session_id.as_deref()
2075    }
2076}
2077
2078/// A page response surfaced from `Network.responseReceived`.
2079#[derive(Debug, Clone)]
2080pub struct Response {
2081    request_id: String,
2082    url: String,
2083    status: u16,
2084    status_text: Option<String>,
2085    headers: BTreeMap<String, String>,
2086    mime_type: Option<String>,
2087    resource_type: Option<String>,
2088    frame_id: Option<String>,
2089    session_id: Option<String>,
2090}
2091
2092impl Response {
2093    fn from_network_event(event: NetworkEvent) -> Option<Self> {
2094        if event.method != "Network.responseReceived" {
2095            return None;
2096        }
2097
2098        let response = event.params.get("response")?;
2099        Some(Self {
2100            request_id: event.params.get("requestId")?.as_str()?.to_owned(),
2101            url: response.get("url")?.as_str()?.to_owned(),
2102            status: response.get("status")?.as_f64()? as u16,
2103            status_text: response
2104                .get("statusText")
2105                .and_then(Value::as_str)
2106                .map(str::to_owned),
2107            headers: normalize_headers(response.get("headers")),
2108            mime_type: response
2109                .get("mimeType")
2110                .and_then(Value::as_str)
2111                .map(str::to_owned),
2112            resource_type: event
2113                .params
2114                .get("type")
2115                .and_then(Value::as_str)
2116                .map(str::to_owned),
2117            frame_id: event
2118                .params
2119                .get("frameId")
2120                .and_then(Value::as_str)
2121                .map(str::to_owned),
2122            session_id: event.session_id,
2123        })
2124    }
2125
2126    /// Returns the protocol request id associated with this response.
2127    #[must_use]
2128    pub fn request_id(&self) -> &str {
2129        &self.request_id
2130    }
2131
2132    /// Returns the response URL.
2133    #[must_use]
2134    pub fn url(&self) -> &str {
2135        &self.url
2136    }
2137
2138    /// Returns the HTTP status code.
2139    #[must_use]
2140    pub fn status(&self) -> u16 {
2141        self.status
2142    }
2143
2144    /// Returns the HTTP reason phrase when Chrome exposed it.
2145    #[must_use]
2146    pub fn status_text(&self) -> Option<&str> {
2147        self.status_text.as_deref()
2148    }
2149
2150    /// Returns the response headers.
2151    #[must_use]
2152    pub fn headers(&self) -> &BTreeMap<String, String> {
2153        &self.headers
2154    }
2155
2156    /// Returns the response MIME type when available.
2157    #[must_use]
2158    pub fn mime_type(&self) -> Option<&str> {
2159        self.mime_type.as_deref()
2160    }
2161
2162    /// Returns the resource type when Chrome reported one.
2163    #[must_use]
2164    pub fn resource_type(&self) -> Option<&str> {
2165        self.resource_type.as_deref()
2166    }
2167
2168    /// Returns the frame id that initiated the response when available.
2169    #[must_use]
2170    pub fn frame_id(&self) -> Option<&str> {
2171        self.frame_id.as_deref()
2172    }
2173
2174    /// Returns the originating page session when available.
2175    #[must_use]
2176    pub fn session_id(&self) -> Option<&str> {
2177        self.session_id.as_deref()
2178    }
2179}
2180
2181/// A filtered event stream.
2182pub struct EventStream {
2183    receiver: broadcast::Receiver<TransportEvent>,
2184    session_id: Option<String>,
2185    method_prefixes: Vec<String>,
2186}
2187
2188impl EventStream {
2189    fn new(
2190        receiver: broadcast::Receiver<TransportEvent>,
2191        session_id: Option<String>,
2192        method_prefix: Option<&str>,
2193    ) -> Self {
2194        let method_prefixes = method_prefix.into_iter().map(str::to_owned).collect();
2195        Self {
2196            receiver,
2197            session_id,
2198            method_prefixes,
2199        }
2200    }
2201
2202    fn with_extra_prefix(mut self, prefix: &str) -> Self {
2203        self.method_prefixes.push(prefix.to_owned());
2204        self
2205    }
2206
2207    /// Receive the next matching event.
2208    pub async fn recv(&mut self) -> Result<NetworkEvent> {
2209        loop {
2210            match self.receiver.recv().await {
2211                Ok(event) => {
2212                    if self.matches(&event) {
2213                        return Ok(event.into());
2214                    }
2215                }
2216                Err(broadcast::error::RecvError::Closed) => {
2217                    return Err(AutomationError::Timeout {
2218                        what: "event stream closed".to_owned(),
2219                    });
2220                }
2221                Err(broadcast::error::RecvError::Lagged(_)) => {}
2222            }
2223        }
2224    }
2225
2226    async fn recv_with_timeout(&mut self, duration: Duration) -> Result<Option<NetworkEvent>> {
2227        match timeout(duration, self.recv()).await {
2228            Ok(event) => event.map(Some),
2229            Err(_) => Ok(None),
2230        }
2231    }
2232
2233    fn matches(&self, event: &TransportEvent) -> bool {
2234        if let Some(session_id) = &self.session_id {
2235            if event.session_id.as_deref() != Some(session_id.as_str()) {
2236                return false;
2237            }
2238        }
2239
2240        if self.method_prefixes.is_empty() {
2241            return true;
2242        }
2243
2244        self.method_prefixes
2245            .iter()
2246            .any(|prefix| event.method.starts_with(prefix))
2247    }
2248}
2249
2250/// An intercepted request route.
2251pub struct Route {
2252    client: Arc<CdpClient<AutomationTransport>>,
2253    /// The intercepted page session.
2254    pub session_id: String,
2255    /// The CDP fetch request id.
2256    pub request_id: String,
2257    /// The intercepted request URL, when present.
2258    pub url: Option<String>,
2259    /// The intercepted request method, when present.
2260    pub method: Option<String>,
2261    /// Response code when intercepted in the response stage.
2262    pub response_status_code: Option<u16>,
2263    /// Response status text when intercepted in the response stage.
2264    pub response_status_text: Option<String>,
2265    /// Response headers when intercepted in the response stage.
2266    pub response_headers: Option<Vec<HeaderEntry>>,
2267}
2268
2269impl Route {
2270    fn from_event(
2271        client: Arc<CdpClient<AutomationTransport>>,
2272        event: NetworkEvent,
2273    ) -> Result<Self> {
2274        let paused = serde_json::from_value::<RequestPausedEvent>(event.params)?;
2275        Ok(Self {
2276            client,
2277            session_id: event
2278                .session_id
2279                .ok_or(AutomationError::MissingField("sessionId"))?,
2280            request_id: paused.request_id,
2281            url: paused.request.as_ref().map(|request| request.url.clone()),
2282            method: paused.request.map(|request| request.method),
2283            response_status_code: paused.response_status_code.map(|status| status as u16),
2284            response_status_text: paused.response_status_text,
2285            response_headers: paused.response_headers,
2286        })
2287    }
2288
2289    /// Returns true when the route was paused after the upstream response arrived.
2290    #[must_use]
2291    pub fn is_response_stage(&self) -> bool {
2292        self.response_status_code.is_some()
2293    }
2294
2295    /// Continue the intercepted request.
2296    pub async fn continue_request(&self) -> Result<()> {
2297        self.client
2298            .execute_in_session::<FetchContinueRequestParams>(
2299                self.session_id.clone(),
2300                &FetchContinueRequestParams {
2301                    request_id: self.request_id.clone(),
2302                },
2303            )
2304            .await?;
2305        Ok(())
2306    }
2307
2308    /// Abort the intercepted request.
2309    pub async fn abort(&self, error_reason: impl Into<String>) -> Result<()> {
2310        self.client
2311            .execute_in_session::<FetchFailRequestParams>(
2312                self.session_id.clone(),
2313                &FetchFailRequestParams {
2314                    request_id: self.request_id.clone(),
2315                    error_reason: error_reason.into(),
2316                },
2317            )
2318            .await?;
2319        Ok(())
2320    }
2321
2322    /// Fulfill the intercepted request with a synthetic response.
2323    pub async fn fulfill(&self, response_code: u16, body: Option<Vec<u8>>) -> Result<()> {
2324        let body = body.map(|bytes| base64::engine::general_purpose::STANDARD.encode(bytes));
2325        self.client
2326            .execute_in_session::<FetchFulfillRequestParams>(
2327                self.session_id.clone(),
2328                &FetchFulfillRequestParams {
2329                    request_id: self.request_id.clone(),
2330                    response_code,
2331                    response_headers: None,
2332                    body,
2333                    response_phrase: None,
2334                },
2335            )
2336            .await?;
2337        Ok(())
2338    }
2339
2340    /// Read the upstream response body for a route paused in the response stage.
2341    pub async fn response_body(&self) -> Result<Vec<u8>> {
2342        if !self.is_response_stage() {
2343            return Err(AutomationError::InvalidRouteState(
2344                "response body is only available in the response stage",
2345            ));
2346        }
2347
2348        let result = self
2349            .client
2350            .execute_in_session::<FetchGetResponseBodyParams>(
2351                self.session_id.clone(),
2352                &FetchGetResponseBodyParams {
2353                    request_id: self.request_id.clone(),
2354                },
2355            )
2356            .await?;
2357
2358        if result.base64_encoded {
2359            Ok(base64::engine::general_purpose::STANDARD.decode(result.body)?)
2360        } else {
2361            Ok(result.body.into_bytes())
2362        }
2363    }
2364
2365    /// Read the upstream response body as UTF-8 text for a route paused in the response stage.
2366    pub async fn response_text(&self) -> Result<String> {
2367        String::from_utf8(self.response_body().await?).map_err(AutomationError::from)
2368    }
2369
2370    /// Fulfill a response-stage route while preserving upstream status and headers by default.
2371    pub async fn fulfill_response(&self, body: Vec<u8>) -> Result<()> {
2372        if !self.is_response_stage() {
2373            return Err(AutomationError::InvalidRouteState(
2374                "response fulfillment requires a route paused in the response stage",
2375            ));
2376        }
2377
2378        let mut response_headers = Vec::new();
2379        if let Some(content_type) = self
2380            .response_headers
2381            .as_ref()
2382            .and_then(|headers| find_header(headers, "content-type"))
2383        {
2384            response_headers.push(FetchHeaderEntry {
2385                name: "Content-Type".to_owned(),
2386                value: content_type.to_owned(),
2387            });
2388        }
2389        response_headers.push(FetchHeaderEntry {
2390            name: "Content-Length".to_owned(),
2391            value: body.len().to_string(),
2392        });
2393
2394        self.client
2395            .execute_in_session::<FetchFulfillRequestParams>(
2396                self.session_id.clone(),
2397                &FetchFulfillRequestParams {
2398                    request_id: self.request_id.clone(),
2399                    response_code: self.response_status_code.unwrap_or(200),
2400                    response_headers: Some(response_headers),
2401                    body: Some(base64::engine::general_purpose::STANDARD.encode(body)),
2402                    response_phrase: None,
2403                },
2404            )
2405            .await?;
2406        Ok(())
2407    }
2408}
2409
2410#[derive(Debug, Deserialize)]
2411struct RequestPausedEvent {
2412    #[serde(rename = "requestId")]
2413    request_id: String,
2414    request: Option<RequestDetails>,
2415    #[serde(rename = "responseStatusCode")]
2416    response_status_code: Option<u64>,
2417    #[serde(rename = "responseStatusText")]
2418    response_status_text: Option<String>,
2419    #[serde(rename = "responseHeaders")]
2420    response_headers: Option<Vec<HeaderEntry>>,
2421}
2422
2423fn find_header<'a>(headers: &'a [HeaderEntry], name: &str) -> Option<&'a str> {
2424    headers
2425        .iter()
2426        .find(|header| header.name.eq_ignore_ascii_case(name))
2427        .map(|header| header.value.as_str())
2428}
2429
2430#[derive(Debug, Deserialize)]
2431struct RequestDetails {
2432    url: String,
2433    method: String,
2434}
2435
2436/// Response header captured for an intercepted route.
2437#[derive(Debug, Clone, Deserialize)]
2438pub struct HeaderEntry {
2439    /// Header name.
2440    pub name: String,
2441    /// Header value.
2442    pub value: String,
2443}
2444
2445#[derive(Debug, Deserialize)]
2446struct CookieResult {
2447    cookies: Vec<RawCookie>,
2448}
2449
2450#[derive(Debug, Clone, Deserialize)]
2451struct RawCookie {
2452    name: String,
2453    value: String,
2454    domain: String,
2455    path: String,
2456    expires: f64,
2457    #[serde(rename = "httpOnly")]
2458    http_only: bool,
2459    secure: bool,
2460    #[serde(rename = "sameSite")]
2461    same_site: Option<String>,
2462}
2463
2464impl From<RawCookie> for CookieState {
2465    fn from(cookie: RawCookie) -> Self {
2466        let expires = if cookie.expires < 0.0 {
2467            None
2468        } else {
2469            Some(cookie.expires)
2470        };
2471
2472        Self {
2473            name: cookie.name,
2474            value: cookie.value,
2475            domain: cookie.domain,
2476            path: cookie.path,
2477            expires,
2478            http_only: cookie.http_only,
2479            secure: cookie.secure,
2480            same_site: cookie.same_site,
2481        }
2482    }
2483}
2484
2485#[derive(Debug, Clone, Serialize)]
2486struct SetCookie {
2487    name: String,
2488    value: String,
2489    domain: String,
2490    path: String,
2491    #[serde(skip_serializing_if = "Option::is_none")]
2492    expires: Option<f64>,
2493    #[serde(rename = "httpOnly")]
2494    http_only: bool,
2495    secure: bool,
2496    #[serde(rename = "sameSite", skip_serializing_if = "Option::is_none")]
2497    same_site: Option<String>,
2498}
2499
2500impl From<CookieState> for SetCookie {
2501    fn from(cookie: CookieState) -> Self {
2502        Self {
2503            name: cookie.name,
2504            value: cookie.value,
2505            domain: cookie.domain,
2506            path: cookie.path,
2507            expires: cookie.expires,
2508            http_only: cookie.http_only,
2509            secure: cookie.secure,
2510            same_site: cookie.same_site,
2511        }
2512    }
2513}
2514
2515/// A remote JavaScript object handle.
2516#[derive(Clone)]
2517pub struct JsHandle {
2518    page: Page,
2519    object_id: String,
2520    object_type: String,
2521    subtype: Option<String>,
2522    description: Option<String>,
2523}
2524
2525impl JsHandle {
2526    fn from_remote_object(page: Page, object: RemoteObject) -> Result<Self> {
2527        Ok(Self {
2528            page,
2529            object_id: object.object_id.ok_or_else(|| {
2530                AutomationError::Selector("expression did not produce a remote object".to_owned())
2531            })?,
2532            object_type: object.object_type,
2533            subtype: object.subtype,
2534            description: object.description,
2535        })
2536    }
2537
2538    /// Returns the underlying CDP remote object id.
2539    #[must_use]
2540    pub fn object_id(&self) -> &str {
2541        &self.object_id
2542    }
2543
2544    /// Returns the remote object type.
2545    #[must_use]
2546    pub fn object_type(&self) -> &str {
2547        &self.object_type
2548    }
2549
2550    /// Returns the remote object subtype when present.
2551    #[must_use]
2552    pub fn subtype(&self) -> Option<&str> {
2553        self.subtype.as_deref()
2554    }
2555
2556    /// Returns the browser-side description when present.
2557    #[must_use]
2558    pub fn description(&self) -> Option<&str> {
2559        self.description.as_deref()
2560    }
2561
2562    /// Materialize the remote value as JSON.
2563    pub async fn json_value(&self) -> Result<Value> {
2564        self.call_function_value("function() { return this; }", Vec::new())
2565            .await
2566    }
2567
2568    /// Release the remote object from Chrome.
2569    pub async fn dispose(&self) -> Result<()> {
2570        self.page
2571            .state
2572            .client
2573            .execute_in_session::<RuntimeReleaseObjectParams>(
2574                self.page.session_id.clone(),
2575                &RuntimeReleaseObjectParams {
2576                    object_id: self.object_id.clone(),
2577                },
2578            )
2579            .await?;
2580        Ok(())
2581    }
2582
2583    /// Convert the handle into an element handle when the remote object is a DOM node.
2584    #[must_use]
2585    pub fn as_element(&self) -> Option<ElementHandle> {
2586        if self.subtype.as_deref() == Some("node") {
2587            Some(ElementHandle {
2588                handle: self.clone(),
2589            })
2590        } else {
2591            None
2592        }
2593    }
2594
2595    async fn call_function_value(
2596        &self,
2597        function_declaration: &str,
2598        arguments: Vec<RuntimeCallArgument>,
2599    ) -> Result<Value> {
2600        let result = self
2601            .page
2602            .state
2603            .client
2604            .execute_in_session::<RuntimeCallFunctionOnParams>(
2605                self.page.session_id.clone(),
2606                &RuntimeCallFunctionOnParams {
2607                    object_id: self.object_id.clone(),
2608                    function_declaration: function_declaration.to_owned(),
2609                    arguments,
2610                    await_promise: Some(true),
2611                    return_by_value: Some(true),
2612                },
2613            )
2614            .await?;
2615        Ok(result.result.value.unwrap_or(Value::Null))
2616    }
2617}
2618
2619/// A DOM element handle.
2620#[derive(Clone)]
2621pub struct ElementHandle {
2622    handle: JsHandle,
2623}
2624
2625impl ElementHandle {
2626    /// Focus the element.
2627    pub async fn focus(&self) -> Result<()> {
2628        let _ = self
2629            .handle
2630            .call_function_value("function() { this.focus(); return true; }", Vec::new())
2631            .await?;
2632        Ok(())
2633    }
2634
2635    /// Scroll the element into view.
2636    pub async fn scroll_into_view(&self) -> Result<()> {
2637        let _ = self
2638            .handle
2639            .call_function_value(
2640                "function() { this.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' }); return true; }",
2641                Vec::new(),
2642            )
2643            .await?;
2644        Ok(())
2645    }
2646
2647    /// Select the element's editable contents when possible.
2648    pub async fn select_text(&self) -> Result<()> {
2649        let _ = self
2650            .handle
2651            .call_function_value(
2652                "function() { if (this instanceof HTMLInputElement || this instanceof HTMLTextAreaElement) { this.select(); return true; } if (this.isContentEditable) { const selection = window.getSelection(); const range = document.createRange(); range.selectNodeContents(this); selection.removeAllRanges(); selection.addRange(range); return true; } return false; }",
2653                Vec::new(),
2654            )
2655            .await?;
2656        Ok(())
2657    }
2658
2659    /// Read the text content.
2660    pub async fn text_content(&self) -> Result<String> {
2661        let value = self
2662            .handle
2663            .call_function_value("function() { return this.textContent ?? ''; }", Vec::new())
2664            .await?;
2665        value
2666            .as_str()
2667            .map(str::to_owned)
2668            .ok_or(AutomationError::MissingElement)
2669    }
2670
2671    /// Return the current bounding client rect.
2672    pub async fn bounding_rect(&self) -> Result<BoundingRect> {
2673        let value = self
2674            .handle
2675            .call_function_value(
2676                "function() { const rect = this.getBoundingClientRect(); return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; }",
2677                Vec::new(),
2678            )
2679            .await?;
2680        Ok(serde_json::from_value(value)?)
2681    }
2682}
2683
2684#[derive(Debug, Deserialize)]
2685struct CreatedTargetEvent {
2686    #[serde(rename = "targetInfo")]
2687    target_info: CreatedTargetInfo,
2688}
2689
2690#[derive(Debug, Deserialize)]
2691struct CreatedTargetInfo {
2692    #[serde(rename = "targetId")]
2693    target_id: String,
2694    #[serde(rename = "type")]
2695    target_type: String,
2696    #[serde(rename = "openerId")]
2697    opener_id: Option<String>,
2698}
2699
2700struct KeyDefinition {
2701    key: String,
2702    code: String,
2703    text: Option<String>,
2704    key_code: i64,
2705}
2706
2707impl KeyDefinition {
2708    fn parse(key: &str) -> Result<Self> {
2709        match key {
2710            "Enter" => Ok(Self::named("Enter", "Enter", 13)),
2711            "Tab" => Ok(Self::named("Tab", "Tab", 9)),
2712            "Escape" => Ok(Self::named("Escape", "Escape", 27)),
2713            "Backspace" => Ok(Self::named("Backspace", "Backspace", 8)),
2714            "Delete" => Ok(Self::named("Delete", "Delete", 46)),
2715            "ArrowLeft" => Ok(Self::named("ArrowLeft", "ArrowLeft", 37)),
2716            "ArrowUp" => Ok(Self::named("ArrowUp", "ArrowUp", 38)),
2717            "ArrowRight" => Ok(Self::named("ArrowRight", "ArrowRight", 39)),
2718            "ArrowDown" => Ok(Self::named("ArrowDown", "ArrowDown", 40)),
2719            " " => Ok(Self {
2720                key: " ".to_owned(),
2721                code: "Space".to_owned(),
2722                text: Some(" ".to_owned()),
2723                key_code: 32,
2724            }),
2725            _ if key.chars().count() == 1 => {
2726                let character = key
2727                    .chars()
2728                    .next()
2729                    .ok_or_else(|| AutomationError::Input("empty key".to_owned()))?;
2730                Ok(Self {
2731                    key: character.to_string(),
2732                    code: format!("Key{}", character.to_ascii_uppercase()),
2733                    text: Some(character.to_string()),
2734                    key_code: character.to_ascii_uppercase() as i64,
2735                })
2736            }
2737            _ => Err(AutomationError::Input(format!("unsupported key `{key}`"))),
2738        }
2739    }
2740
2741    fn named(key: &str, code: &str, key_code: i64) -> Self {
2742        Self {
2743            key: key.to_owned(),
2744            code: code.to_owned(),
2745            text: None,
2746            key_code,
2747        }
2748    }
2749}
2750
2751fn normalize_headers(value: Option<&Value>) -> BTreeMap<String, String> {
2752    let Some(Value::Object(headers)) = value else {
2753        return BTreeMap::new();
2754    };
2755    headers
2756        .iter()
2757        .map(|(name, value)| {
2758            let value = value
2759                .as_str()
2760                .map(str::to_owned)
2761                .unwrap_or_else(|| value.to_string());
2762            (name.clone(), value)
2763        })
2764        .collect()
2765}
2766
2767#[cfg(test)]
2768mod tests {
2769    use super::{NetworkEvent, Request, SelectorKind};
2770    use serde_json::json;
2771
2772    #[test]
2773    fn css_selector_should_compile_to_query_selector() {
2774        let selector = SelectorKind::Css("button.primary".to_owned());
2775        let expression = selector.javascript_expression().unwrap();
2776        assert!(expression.contains("document.querySelector"));
2777    }
2778
2779    #[test]
2780    fn role_selector_should_embed_common_role_inference() {
2781        let selector = SelectorKind::Role("button".to_owned());
2782        let expression = selector.javascript_expression().unwrap();
2783        assert!(expression.contains("inferRole"));
2784    }
2785
2786    #[test]
2787    fn test_id_selector_should_target_data_testid() {
2788        let selector = SelectorKind::TestId("checkout".to_owned());
2789        let expression = selector.javascript_expression().unwrap();
2790        assert!(expression.contains("data-testid"));
2791    }
2792
2793    #[test]
2794    fn text_selector_should_prefer_smallest_matching_element() {
2795        let selector = SelectorKind::Text("Fingerprint JSON".to_owned());
2796        let expression = selector.javascript_expression().unwrap();
2797        assert!(expression.contains("node.children"));
2798        assert!(expression.contains("candidates.find"));
2799    }
2800
2801    #[test]
2802    fn request_event_should_parse_network_request_will_be_sent() {
2803        let event = NetworkEvent {
2804            method: "Network.requestWillBeSent".to_owned(),
2805            session_id: Some("page-session".to_owned()),
2806            params: json!({
2807                "requestId": "request-1",
2808                "request": {
2809                    "url": "https://example.com/",
2810                    "method": "GET",
2811                    "headers": {
2812                        "accept": "text/html"
2813                    }
2814                },
2815                "type": "Document",
2816                "frameId": "frame-1"
2817            }),
2818        };
2819
2820        let request = Request::from_network_event(event).expect("request event");
2821        assert_eq!(request.request_id(), "request-1");
2822        assert_eq!(request.method(), "GET");
2823        assert_eq!(request.url(), "https://example.com/");
2824        assert_eq!(
2825            request.headers().get("accept"),
2826            Some(&"text/html".to_owned())
2827        );
2828        assert_eq!(request.resource_type(), Some("Document"));
2829        assert_eq!(request.frame_id(), Some("frame-1"));
2830        assert_eq!(request.session_id(), Some("page-session"));
2831    }
2832}