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    path::PathBuf,
10    sync::{Arc, Mutex, MutexGuard},
11    time::Duration,
12};
13
14use base64::Engine;
15use playhard_cdp::{
16    CdpClient, CdpError, CdpResponse, CdpTransport, FetchContinueRequestParams, FetchEnableParams,
17    FetchFailRequestParams, FetchFulfillRequestParams, NetworkEnableParams,
18    PageCaptureScreenshotParams, PageCaptureScreenshotResult, PageEnableParams, PageNavigateParams,
19    PageNavigateResult, RuntimeEnableParams, RuntimeEvaluateParams, TargetAttachToTargetParams,
20    TargetCreateTargetParams,
21};
22use playhard_launcher::{
23    LaunchConnection, LaunchError, LaunchOptions, LaunchedChrome, LaunchedChromeParts, Launcher,
24    ProfileDir, TransportMode,
25};
26use playhard_transport::{
27    Connection, ConnectionError, PipeTransport, TransportEvent, WebSocketTransport,
28};
29use serde::Deserialize;
30use serde_json::{json, Value};
31use thiserror::Error;
32use tokio::{
33    process::Child,
34    sync::broadcast,
35    time::{sleep, timeout, Instant},
36};
37
38/// Result alias for the automation crate.
39pub type Result<T> = std::result::Result<T, AutomationError>;
40
41fn lock_unpoisoned<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
42    match mutex.lock() {
43        Ok(guard) => guard,
44        Err(poisoned) => poisoned.into_inner(),
45    }
46}
47
48/// Errors produced by `playhard-automation`.
49#[derive(Debug, Error)]
50pub enum AutomationError {
51    /// Chrome launch failed.
52    #[error(transparent)]
53    Launch(#[from] LaunchError),
54    /// Transport connection failed.
55    #[error(transparent)]
56    Connection(#[from] ConnectionError),
57    /// CDP command failed.
58    #[error(transparent)]
59    Cdp(#[from] CdpError),
60    /// JSON serialization or deserialization failed.
61    #[error(transparent)]
62    Json(#[from] serde_json::Error),
63    /// Base64 decoding failed.
64    #[error(transparent)]
65    Base64(#[from] base64::DecodeError),
66    /// A required websocket endpoint was missing.
67    #[error("launcher did not expose a websocket endpoint")]
68    MissingWebSocketEndpoint,
69    /// A wait timed out.
70    #[error("timed out waiting for {what}")]
71    Timeout {
72        /// Human-readable description of the timed operation.
73        what: String,
74    },
75    /// A locator action could not find a matching element.
76    #[error("locator did not match any element")]
77    MissingElement,
78    /// A selector operation failed.
79    #[error("{0}")]
80    Selector(String),
81    /// An expected protocol field was missing.
82    #[error("missing protocol field `{0}`")]
83    MissingField(&'static str),
84}
85
86#[derive(Clone)]
87enum AutomationTransport {
88    WebSocket(Arc<Connection<WebSocketTransport>>),
89    Pipe(Arc<Connection<PipeTransport>>),
90}
91
92impl AutomationTransport {
93    fn subscribe_events(&self) -> broadcast::Receiver<TransportEvent> {
94        match self {
95            Self::WebSocket(connection) => connection.subscribe_events(),
96            Self::Pipe(connection) => connection.subscribe_events(),
97        }
98    }
99
100    async fn close(&self) -> Result<()> {
101        match self {
102            Self::WebSocket(connection) => connection.close().await.map_err(AutomationError::from),
103            Self::Pipe(connection) => connection.close().await.map_err(AutomationError::from),
104        }
105    }
106}
107
108impl CdpTransport for AutomationTransport {
109    async fn send(
110        &self,
111        request: playhard_cdp::CdpRequest,
112    ) -> std::result::Result<CdpResponse, CdpError> {
113        let response = match self {
114            Self::WebSocket(connection) => send_over_connection(connection, request).await,
115            Self::Pipe(connection) => send_over_connection(connection, request).await,
116        };
117        response.map_err(Into::into)
118    }
119}
120
121async fn send_over_connection<T>(
122    connection: &Connection<T>,
123    request: playhard_cdp::CdpRequest,
124) -> Result<CdpResponse>
125where
126    T: playhard_transport::TransportHandle,
127{
128    let message = if let Some(session_id) = request.session_id.clone() {
129        connection
130            .request_for_session(session_id, request.method, Some(request.params))
131            .await?
132    } else {
133        connection
134            .request(request.method, Some(request.params))
135            .await?
136    };
137
138    Ok(CdpResponse {
139        id: message.id.ok_or(AutomationError::MissingField("id"))?,
140        result: message.result,
141        error: message.error.map(|error| playhard_cdp::CdpResponseError {
142            code: error.code,
143            message: error.message,
144        }),
145        session_id: message.session_id,
146    })
147}
148
149impl From<AutomationError> for CdpError {
150    fn from(error: AutomationError) -> Self {
151        match error {
152            AutomationError::Cdp(error) => error,
153            other => CdpError::Transport(other.to_string()),
154        }
155    }
156}
157
158enum LaunchGuard {
159    WebSocket(LaunchedChrome),
160    Pipe(PipeGuard),
161}
162
163impl LaunchGuard {
164    async fn shutdown(self) -> Result<()> {
165        match self {
166            Self::WebSocket(launched) => launched.shutdown().await.map_err(AutomationError::from),
167            Self::Pipe(mut guard) => {
168                let _ = guard.child.start_kill();
169                let _ = timeout(Duration::from_secs(5), guard.child.wait()).await;
170                Ok(())
171            }
172        }
173    }
174}
175
176struct PipeGuard {
177    #[allow(dead_code)]
178    executable_path: PathBuf,
179    #[allow(dead_code)]
180    profile: ProfileDir,
181    child: Child,
182}
183
184impl Drop for PipeGuard {
185    fn drop(&mut self) {
186        let _ = self.child.start_kill();
187    }
188}
189
190struct BrowserState {
191    client: Arc<CdpClient<AutomationTransport>>,
192    transport: AutomationTransport,
193    launch_guard: Mutex<Option<LaunchGuard>>,
194    browser_interception_patterns: Mutex<Option<Vec<RequestPattern>>>,
195    page_sessions: Mutex<Vec<String>>,
196}
197
198/// A browser automation session.
199pub struct Browser {
200    state: Arc<BrowserState>,
201}
202
203impl Browser {
204    /// Launch a new browser using the supplied options.
205    pub async fn launch(options: LaunchOptions) -> Result<Self> {
206        let launched = Launcher::new(options).launch().await?;
207        let transport = match launched.transport_mode() {
208            TransportMode::WebSocket => {
209                let endpoint = launched
210                    .websocket_endpoint()
211                    .ok_or(AutomationError::MissingWebSocketEndpoint)?;
212                let websocket = WebSocketTransport::connect(endpoint)
213                    .await
214                    .map_err(ConnectionError::from)?;
215                let connection = Connection::new(websocket)?;
216                let transport = AutomationTransport::WebSocket(Arc::new(connection));
217                (transport, LaunchGuard::WebSocket(launched))
218            }
219            TransportMode::Pipe => {
220                let parts = launched.into_parts();
221                let (pipe_transport, guard) = pipe_transport_from_parts(parts)?;
222                let connection = Connection::new(pipe_transport)?;
223                let transport = AutomationTransport::Pipe(Arc::new(connection));
224                (transport, guard)
225            }
226        };
227
228        Ok(Self::from_transport(transport.0, Some(transport.1)))
229    }
230
231    /// Connect to an already-running Chrome DevTools websocket endpoint.
232    pub async fn connect_websocket(url: impl AsRef<str>) -> Result<Self> {
233        let websocket = WebSocketTransport::connect(url.as_ref())
234            .await
235            .map_err(ConnectionError::from)?;
236        let connection = Connection::new(websocket)?;
237        Ok(Self::from_transport(
238            AutomationTransport::WebSocket(Arc::new(connection)),
239            None,
240        ))
241    }
242
243    fn from_transport(transport: AutomationTransport, launch_guard: Option<LaunchGuard>) -> Self {
244        let client = Arc::new(CdpClient::new(transport.clone()));
245        let state = BrowserState {
246            client,
247            transport,
248            launch_guard: Mutex::new(launch_guard),
249            browser_interception_patterns: Mutex::new(None),
250            page_sessions: Mutex::new(Vec::new()),
251        };
252        Self {
253            state: Arc::new(state),
254        }
255    }
256
257    /// Returns a raw CDP escape hatch rooted at the browser session.
258    #[must_use]
259    pub fn cdp(&self) -> CdpSession {
260        CdpSession {
261            client: Arc::clone(&self.state.client),
262            session_id: None,
263        }
264    }
265
266    /// Open a new page and bootstrap the common CDP domains used by Playhard.
267    pub async fn new_page(&self) -> Result<Page> {
268        let target = self
269            .state
270            .client
271            .execute::<TargetCreateTargetParams>(&TargetCreateTargetParams {
272                url: "about:blank".to_owned(),
273                new_window: None,
274            })
275            .await?;
276        let attached = self
277            .state
278            .client
279            .execute::<TargetAttachToTargetParams>(&TargetAttachToTargetParams {
280                target_id: target.target_id,
281                flatten: Some(true),
282            })
283            .await?;
284        let session_id = attached.session_id;
285
286        self.state
287            .client
288            .execute_in_session::<PageEnableParams>(session_id.clone(), &PageEnableParams {})
289            .await?;
290        self.state
291            .client
292            .execute_in_session::<RuntimeEnableParams>(session_id.clone(), &RuntimeEnableParams {})
293            .await?;
294        self.state
295            .client
296            .execute_in_session::<NetworkEnableParams>(session_id.clone(), &NetworkEnableParams {})
297            .await?;
298        self.state
299            .client
300            .call_raw("DOM.enable", json!({}), Some(session_id.clone()))
301            .await?;
302
303        {
304            let mut sessions = lock_unpoisoned(&self.state.page_sessions);
305            sessions.push(session_id.clone());
306        }
307
308        let page = Page {
309            client: Arc::clone(&self.state.client),
310            transport: self.state.transport.clone(),
311            session_id,
312            default_timeout: Duration::from_secs(30),
313        };
314
315        let browser_patterns = {
316            let patterns = lock_unpoisoned(&self.state.browser_interception_patterns);
317            patterns.clone()
318        };
319        if let Some(patterns) = browser_patterns {
320            page.enable_request_interception(patterns).await?;
321        }
322
323        Ok(page)
324    }
325
326    /// Subscribe to all browser-wide network and fetch events.
327    pub fn network_events(&self) -> EventStream {
328        EventStream::new(
329            self.state.transport.subscribe_events(),
330            None,
331            Some("Network."),
332        )
333        .with_extra_prefix("Fetch.")
334    }
335
336    /// Enable request interception for all existing and future pages.
337    pub async fn enable_request_interception(&self, patterns: Vec<RequestPattern>) -> Result<()> {
338        {
339            let mut stored = lock_unpoisoned(&self.state.browser_interception_patterns);
340            *stored = Some(patterns.clone());
341        }
342
343        let session_ids = lock_unpoisoned(&self.state.page_sessions).clone();
344        for session_id in session_ids {
345            let page = Page {
346                client: Arc::clone(&self.state.client),
347                transport: self.state.transport.clone(),
348                session_id,
349                default_timeout: Duration::from_secs(30),
350            };
351            page.enable_request_interception(patterns.clone()).await?;
352        }
353
354        Ok(())
355    }
356
357    /// Wait for the next paused request across all pages.
358    pub async fn next_route(&self, timeout_duration: Duration) -> Result<Route> {
359        let mut events = EventStream::new(
360            self.state.transport.subscribe_events(),
361            None,
362            Some("Fetch."),
363        );
364        let event = events
365            .recv_with_timeout(timeout_duration)
366            .await?
367            .ok_or_else(|| AutomationError::Timeout {
368                what: "browser route".to_owned(),
369            })?;
370        Route::from_event(Arc::clone(&self.state.client), event)
371    }
372
373    /// Shut down the browser and close the underlying transport.
374    pub async fn shutdown(self) -> Result<()> {
375        self.state.transport.close().await?;
376        let launch_guard = {
377            let mut guard = lock_unpoisoned(&self.state.launch_guard);
378            guard.take()
379        };
380        if let Some(guard) = launch_guard {
381            guard.shutdown().await?;
382        }
383        Ok(())
384    }
385}
386
387fn pipe_transport_from_parts(parts: LaunchedChromeParts) -> Result<(PipeTransport, LaunchGuard)> {
388    let LaunchedChromeParts {
389        executable_path,
390        profile,
391        child,
392        connection,
393    } = parts;
394
395    let LaunchConnection::Pipe { stdin, stdout } = connection else {
396        return Err(AutomationError::MissingWebSocketEndpoint);
397    };
398
399    let transport = PipeTransport::new(stdin, stdout).map_err(ConnectionError::from)?;
400    let guard = LaunchGuard::Pipe(PipeGuard {
401        executable_path,
402        profile,
403        child,
404    });
405    Ok((transport, guard))
406}
407
408/// A raw CDP session escape hatch.
409#[derive(Clone)]
410pub struct CdpSession {
411    client: Arc<CdpClient<AutomationTransport>>,
412    session_id: Option<String>,
413}
414
415impl CdpSession {
416    /// Send an arbitrary CDP command and return its JSON result.
417    pub async fn call_raw(&self, method: impl Into<String>, params: Value) -> Result<Value> {
418        self.client
419            .call_raw(method.into(), params, self.session_id.clone())
420            .await
421            .map_err(AutomationError::from)
422    }
423
424    /// Return the target session id, if this session is page-scoped.
425    #[must_use]
426    pub fn session_id(&self) -> Option<&str> {
427        self.session_id.as_deref()
428    }
429}
430
431/// A page/tab automation handle.
432#[derive(Clone)]
433pub struct Page {
434    client: Arc<CdpClient<AutomationTransport>>,
435    transport: AutomationTransport,
436    session_id: String,
437    default_timeout: Duration,
438}
439
440impl Page {
441    /// Returns the attached target session id.
442    #[must_use]
443    pub fn session_id(&self) -> &str {
444        &self.session_id
445    }
446
447    /// Returns a raw CDP escape hatch scoped to this page session.
448    #[must_use]
449    pub fn cdp(&self) -> CdpSession {
450        CdpSession {
451            client: Arc::clone(&self.client),
452            session_id: Some(self.session_id.clone()),
453        }
454    }
455
456    /// Navigate the page and wait for the load event.
457    pub async fn goto(&self, url: impl AsRef<str>) -> Result<PageNavigateResult> {
458        let result = self
459            .client
460            .execute_in_session::<PageNavigateParams>(
461                self.session_id.clone(),
462                &PageNavigateParams {
463                    url: url.as_ref().to_owned(),
464                },
465            )
466            .await?;
467        self.wait_for_event("Page.loadEventFired", self.default_timeout)
468            .await?;
469        Ok(result)
470    }
471
472    /// Evaluate JavaScript in the page main world and return the JSON result.
473    pub async fn evaluate(&self, expression: impl AsRef<str>) -> Result<Value> {
474        let result = self
475            .client
476            .execute_in_session::<RuntimeEvaluateParams>(
477                self.session_id.clone(),
478                &RuntimeEvaluateParams {
479                    expression: expression.as_ref().to_owned(),
480                    await_promise: Some(true),
481                    return_by_value: Some(true),
482                },
483            )
484            .await?;
485        Ok(result.result.value.unwrap_or(Value::Null))
486    }
487
488    /// Capture a page screenshot.
489    pub async fn screenshot(&self) -> Result<Vec<u8>> {
490        let result: PageCaptureScreenshotResult = self
491            .client
492            .execute_in_session::<PageCaptureScreenshotParams>(
493                self.session_id.clone(),
494                &PageCaptureScreenshotParams {
495                    format: Some("png".to_owned()),
496                },
497            )
498            .await?;
499        Ok(base64::engine::general_purpose::STANDARD.decode(result.data)?)
500    }
501
502    /// Capture a screenshot of the current element matched by the locator.
503    pub async fn element_screenshot(&self, locator: &Locator) -> Result<Vec<u8>> {
504        let rect = locator.bounding_rect().await?;
505        let clip = json!({
506            "x": rect.x,
507            "y": rect.y,
508            "width": rect.width,
509            "height": rect.height,
510            "scale": 1.0,
511        });
512        let result = self
513            .cdp()
514            .call_raw(
515                "Page.captureScreenshot",
516                json!({
517                    "format": "png",
518                    "clip": clip,
519                }),
520            )
521            .await?;
522        let data = result
523            .get("data")
524            .and_then(Value::as_str)
525            .ok_or(AutomationError::MissingField("data"))?;
526        Ok(base64::engine::general_purpose::STANDARD.decode(data)?)
527    }
528
529    /// Build a CSS locator.
530    #[must_use]
531    pub fn locator(&self, css_selector: impl Into<String>) -> Locator {
532        Locator::new(self.clone(), SelectorKind::Css(css_selector.into()))
533    }
534
535    /// Click the first element matching the CSS selector.
536    pub async fn click(&self, css_selector: impl Into<String>) -> Result<()> {
537        self.locator(css_selector).click().await
538    }
539
540    /// Click the first element matching the CSS selector with custom action options.
541    pub async fn click_with_options(
542        &self,
543        css_selector: impl Into<String>,
544        options: ActionOptions,
545    ) -> Result<()> {
546        self.locator(css_selector).click_with_options(options).await
547    }
548
549    /// Fill the first form control matching the CSS selector.
550    pub async fn fill(
551        &self,
552        css_selector: impl Into<String>,
553        value: impl AsRef<str>,
554    ) -> Result<()> {
555        self.locator(css_selector).fill(value).await
556    }
557
558    /// Fill the first form control matching the CSS selector with custom action options.
559    pub async fn fill_with_options(
560        &self,
561        css_selector: impl Into<String>,
562        value: impl AsRef<str>,
563        options: ActionOptions,
564    ) -> Result<()> {
565        self.locator(css_selector)
566            .fill_with_options(value, options)
567            .await
568    }
569
570    /// Focus the first element matching the CSS selector.
571    pub async fn focus(&self, css_selector: impl Into<String>) -> Result<()> {
572        self.locator(css_selector).focus().await
573    }
574
575    /// Hover the first element matching the CSS selector.
576    pub async fn hover(&self, css_selector: impl Into<String>) -> Result<()> {
577        self.locator(css_selector).hover().await
578    }
579
580    /// Select an option by value on the first matching `<select>` element.
581    pub async fn select(
582        &self,
583        css_selector: impl Into<String>,
584        value: impl AsRef<str>,
585    ) -> Result<()> {
586        self.locator(css_selector).select(value).await
587    }
588
589    /// Wait until the CSS selector matches an element.
590    pub async fn wait_for_selector(
591        &self,
592        css_selector: impl Into<String>,
593        timeout_duration: Duration,
594    ) -> Result<()> {
595        self.locator(css_selector).wait(timeout_duration).await
596    }
597
598    /// Return whether the CSS selector currently matches an element.
599    pub async fn exists(&self, css_selector: impl Into<String>) -> Result<bool> {
600        self.locator(css_selector).exists().await
601    }
602
603    /// Read the text content of the first matching element.
604    pub async fn text_content(&self, css_selector: impl Into<String>) -> Result<String> {
605        self.locator(css_selector).text_content().await
606    }
607
608    /// Read the current page URL.
609    pub async fn url(&self) -> Result<String> {
610        let value = self.evaluate("window.location.href").await?;
611        value
612            .as_str()
613            .map(str::to_owned)
614            .ok_or_else(|| AutomationError::Selector("location.href was not a string".to_owned()))
615    }
616
617    /// Read the current page title.
618    pub async fn title(&self) -> Result<String> {
619        let value = self.evaluate("document.title").await?;
620        value
621            .as_str()
622            .map(str::to_owned)
623            .ok_or_else(|| AutomationError::Selector("document.title was not a string".to_owned()))
624    }
625
626    /// Click the first element whose text content contains the supplied text.
627    pub async fn click_text(&self, text: impl Into<String>) -> Result<()> {
628        self.locator_text(text).click().await
629    }
630
631    /// Click the first element whose text content contains the supplied text with custom action options.
632    pub async fn click_text_with_options(
633        &self,
634        text: impl Into<String>,
635        options: ActionOptions,
636    ) -> Result<()> {
637        self.locator_text(text).click_with_options(options).await
638    }
639
640    /// Wait until an element containing the supplied text appears.
641    pub async fn wait_for_text(
642        &self,
643        text: impl Into<String>,
644        timeout_duration: Duration,
645    ) -> Result<()> {
646        self.locator_text(text).wait(timeout_duration).await
647    }
648
649    /// Wait until the current page URL contains the supplied substring.
650    pub async fn wait_for_url_contains(
651        &self,
652        needle: impl AsRef<str>,
653        timeout_duration: Duration,
654    ) -> Result<String> {
655        let deadline = Instant::now() + timeout_duration;
656        let needle = needle.as_ref().to_owned();
657
658        loop {
659            let current_url = self.url().await?;
660            if current_url.contains(&needle) {
661                return Ok(current_url);
662            }
663
664            if Instant::now() >= deadline {
665                return Err(AutomationError::Timeout {
666                    what: format!("URL containing {needle}"),
667                });
668            }
669
670            sleep(Duration::from_millis(200)).await;
671        }
672    }
673
674    /// Build a text locator.
675    #[must_use]
676    pub fn locator_text(&self, text: impl Into<String>) -> Locator {
677        Locator::new(self.clone(), SelectorKind::Text(text.into()))
678    }
679
680    /// Build a role locator.
681    #[must_use]
682    pub fn locator_role(&self, role: impl Into<String>) -> Locator {
683        Locator::new(self.clone(), SelectorKind::Role(role.into()))
684    }
685
686    /// Build a test-id locator.
687    #[must_use]
688    pub fn locator_test_id(&self, test_id: impl Into<String>) -> Locator {
689        Locator::new(self.clone(), SelectorKind::TestId(test_id.into()))
690    }
691
692    /// Press a key against the active element.
693    pub async fn press(&self, key: impl AsRef<str>) -> Result<()> {
694        let key = serde_json::to_string(key.as_ref())?;
695        let _ = self
696            .evaluate(format!(
697                "(() => {{ const key = {key}; const el = document.activeElement ?? document.body; for (const type of ['keydown','keypress','keyup']) {{ el.dispatchEvent(new KeyboardEvent(type, {{ key, bubbles: true }})); }} return true; }})()"
698            ))
699            .await?;
700        Ok(())
701    }
702
703    /// Move the mouse pointer to the given viewport coordinates.
704    pub async fn move_mouse(&self, x: f64, y: f64) -> Result<()> {
705        self.dispatch_mouse_event("mouseMoved", x, y, MouseButton::None, 0, 0)
706            .await
707    }
708
709    /// Press a mouse button at the given viewport coordinates.
710    pub async fn mouse_down(&self, x: f64, y: f64, button: MouseButton) -> Result<()> {
711        self.dispatch_mouse_event("mousePressed", x, y, button, 1, 1)
712            .await
713    }
714
715    /// Release a mouse button at the given viewport coordinates.
716    pub async fn mouse_up(&self, x: f64, y: f64, button: MouseButton) -> Result<()> {
717        self.dispatch_mouse_event("mouseReleased", x, y, button, 0, 1)
718            .await
719    }
720
721    /// Click at the given viewport coordinates.
722    pub async fn click_at(&self, x: f64, y: f64, options: ClickOptions) -> Result<()> {
723        self.move_mouse(x, y).await?;
724        self.dispatch_mouse_event("mousePressed", x, y, options.button, 1, options.click_count)
725            .await?;
726        sleep(options.down_up_delay).await;
727        self.dispatch_mouse_event(
728            "mouseReleased",
729            x,
730            y,
731            options.button,
732            0,
733            options.click_count,
734        )
735        .await
736    }
737
738    /// Insert text into the currently focused element.
739    pub async fn insert_text(&self, text: impl AsRef<str>) -> Result<()> {
740        self.cdp()
741            .call_raw("Input.insertText", json!({ "text": text.as_ref() }))
742            .await?;
743        Ok(())
744    }
745
746    /// Type text into the currently focused element, waiting between characters.
747    pub async fn type_text(&self, text: impl AsRef<str>, delay: Duration) -> Result<()> {
748        for character in text.as_ref().chars() {
749            self.insert_text(character.to_string()).await?;
750            sleep(delay).await;
751        }
752        Ok(())
753    }
754
755    /// Subscribe to all events for this page session.
756    pub fn events(&self) -> EventStream {
757        EventStream::new(
758            self.transport.subscribe_events(),
759            Some(self.session_id.clone()),
760            None,
761        )
762    }
763
764    /// Subscribe to network and fetch events for this page session.
765    pub fn network_events(&self) -> EventStream {
766        EventStream::new(
767            self.transport.subscribe_events(),
768            Some(self.session_id.clone()),
769            Some("Network."),
770        )
771        .with_extra_prefix("Fetch.")
772    }
773
774    /// Enable request interception for this page.
775    pub async fn enable_request_interception(&self, patterns: Vec<RequestPattern>) -> Result<()> {
776        self.client
777            .execute_in_session::<FetchEnableParams>(
778                self.session_id.clone(),
779                &FetchEnableParams {
780                    patterns: Some(
781                        patterns
782                            .iter()
783                            .map(RequestPattern::to_json)
784                            .map(serde_json::from_value)
785                            .collect::<std::result::Result<Vec<_>, _>>()?,
786                    ),
787                },
788            )
789            .await?;
790        Ok(())
791    }
792
793    /// Wait for the next intercepted route on this page.
794    pub async fn next_route(&self, timeout_duration: Duration) -> Result<Route> {
795        let mut events = EventStream::new(
796            self.transport.subscribe_events(),
797            Some(self.session_id.clone()),
798            Some("Fetch."),
799        );
800        let event = events
801            .recv_with_timeout(timeout_duration)
802            .await?
803            .ok_or_else(|| AutomationError::Timeout {
804                what: "page route".to_owned(),
805            })?;
806        Route::from_event(Arc::clone(&self.client), event)
807    }
808
809    async fn wait_for_event(
810        &self,
811        method: &str,
812        timeout_duration: Duration,
813    ) -> Result<NetworkEvent> {
814        let mut events = EventStream::new(
815            self.transport.subscribe_events(),
816            Some(self.session_id.clone()),
817            Some(method),
818        );
819        events
820            .recv_with_timeout(timeout_duration)
821            .await?
822            .ok_or_else(|| AutomationError::Timeout {
823                what: method.to_owned(),
824            })
825    }
826
827    async fn dispatch_mouse_event(
828        &self,
829        event_type: &str,
830        x: f64,
831        y: f64,
832        button: MouseButton,
833        buttons: u8,
834        click_count: u8,
835    ) -> Result<()> {
836        self.cdp()
837            .call_raw(
838                "Input.dispatchMouseEvent",
839                json!({
840                    "type": event_type,
841                    "x": x,
842                    "y": y,
843                    "button": button.as_cdp_value(),
844                    "buttons": buttons,
845                    "clickCount": click_count,
846                }),
847            )
848            .await?;
849        Ok(())
850    }
851}
852
853/// A query that resolves a DOM element.
854#[derive(Clone)]
855pub struct Locator {
856    page: Page,
857    selector: SelectorKind,
858}
859
860impl Locator {
861    fn new(page: Page, selector: SelectorKind) -> Self {
862        Self { page, selector }
863    }
864
865    /// Click the matched element.
866    pub async fn click(&self) -> Result<()> {
867        self.click_with_options(ActionOptions::default()).await
868    }
869
870    /// Click the matched element with custom action options.
871    pub async fn click_with_options(&self, options: ActionOptions) -> Result<()> {
872        self.wait(options.timeout).await?;
873        if !options.force {
874            self.wait_for_actionable(options.timeout).await?;
875        }
876        self.scroll_into_view().await?;
877        let rect = self.bounding_rect().await?;
878        let x = rect.x + (rect.width / 2.0);
879        let y = rect.y + (rect.height / 2.0);
880        self.page.click_at(x, y, ClickOptions::default()).await
881    }
882
883    /// Fill the matched form field.
884    pub async fn fill(&self, value: impl AsRef<str>) -> Result<()> {
885        self.fill_with_options(value, ActionOptions::default())
886            .await
887    }
888
889    /// Fill the matched form field with custom action options.
890    pub async fn fill_with_options(
891        &self,
892        value: impl AsRef<str>,
893        options: ActionOptions,
894    ) -> Result<()> {
895        self.wait(options.timeout).await?;
896        if !options.force {
897            self.wait_for_actionable(options.timeout).await?;
898        }
899        self.scroll_into_view().await?;
900        let rect = self.bounding_rect().await?;
901        let x = rect.x + (rect.width / 2.0);
902        let y = rect.y + (rect.height / 2.0);
903        self.page.click_at(x, y, ClickOptions::default()).await?;
904        self.run_selector_action(
905            "el.focus(); if ('value' in el) { el.value = ''; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); }",
906        )
907        .await?;
908        self.page
909            .type_text(value.as_ref(), Duration::from_millis(45))
910            .await
911    }
912
913    /// Focus the matched element.
914    pub async fn focus(&self) -> Result<()> {
915        self.wait(ActionOptions::default().timeout).await?;
916        self.run_selector_action("el.focus();").await
917    }
918
919    /// Hover the matched element.
920    pub async fn hover(&self) -> Result<()> {
921        self.wait(ActionOptions::default().timeout).await?;
922        self.run_selector_action(
923            "el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));",
924        )
925        .await
926    }
927
928    /// Select an option by value on the matched `<select>`.
929    pub async fn select(&self, value: impl AsRef<str>) -> Result<()> {
930        self.wait(ActionOptions::default().timeout).await?;
931        let value = serde_json::to_string(value.as_ref())?;
932        self.run_selector_action(&format!(
933            "el.value = {value}; el.dispatchEvent(new Event('input', {{ bubbles: true }})); el.dispatchEvent(new Event('change', {{ bubbles: true }}));"
934        ))
935        .await
936    }
937
938    /// Wait until the locator matches an element.
939    pub async fn wait(&self, timeout_duration: Duration) -> Result<()> {
940        let deadline = Instant::now() + timeout_duration;
941        loop {
942            if self.exists().await? {
943                return Ok(());
944            }
945            if Instant::now() >= deadline {
946                return Err(AutomationError::Timeout {
947                    what: "locator existence".to_owned(),
948                });
949            }
950            sleep(Duration::from_millis(100)).await;
951        }
952    }
953
954    /// Returns true when the locator currently matches an element.
955    pub async fn exists(&self) -> Result<bool> {
956        let status = self.element_status().await?;
957        Ok(status.exists)
958    }
959
960    /// Read the text content of the matched element.
961    pub async fn text_content(&self) -> Result<String> {
962        let script = format!(
963            "(() => {{ const el = {selector}; return el ? (el.textContent ?? '') : null; }})()",
964            selector = self.selector.javascript_expression()?,
965        );
966        let value = self.page.evaluate(script).await?;
967        value
968            .as_str()
969            .map(str::to_owned)
970            .ok_or(AutomationError::MissingElement)
971    }
972
973    async fn bounding_rect(&self) -> Result<BoundingRect> {
974        let script = format!(
975            "(() => {{ const el = {selector}; if (!el) return null; const rect = el.getBoundingClientRect(); return {{ x: rect.x, y: rect.y, width: rect.width, height: rect.height }}; }})()",
976            selector = self.selector.javascript_expression()?,
977        );
978        let value = self.page.evaluate(script).await?;
979        let rect = serde_json::from_value::<Option<BoundingRect>>(value)?
980            .ok_or(AutomationError::MissingElement)?;
981        Ok(rect)
982    }
983
984    async fn scroll_into_view(&self) -> Result<()> {
985        self.run_selector_action(
986            "el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });",
987        )
988        .await
989    }
990
991    async fn wait_for_actionable(&self, timeout_duration: Duration) -> Result<()> {
992        let deadline = Instant::now() + timeout_duration;
993        loop {
994            let status = self.element_status().await?;
995            if status.exists && status.visible && status.enabled {
996                return Ok(());
997            }
998            if Instant::now() >= deadline {
999                return Err(AutomationError::Timeout {
1000                    what: "locator actionability".to_owned(),
1001                });
1002            }
1003            sleep(Duration::from_millis(100)).await;
1004        }
1005    }
1006
1007    async fn element_status(&self) -> Result<ElementStatus> {
1008        let script = format!(
1009            "(() => {{ 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 }}; }})()",
1010            selector = self.selector.javascript_expression()?,
1011        );
1012        let value = self.page.evaluate(script).await?;
1013        Ok(serde_json::from_value(value)?)
1014    }
1015
1016    async fn run_selector_action(&self, body: &str) -> Result<()> {
1017        let script = format!(
1018            "(() => {{ const el = {selector}; if (!el) return {{ ok: false, message: 'missing element' }}; {body} return {{ ok: true }}; }})()",
1019            selector = self.selector.javascript_expression()?,
1020            body = body,
1021        );
1022        let value = self.page.evaluate(script).await?;
1023        let result = serde_json::from_value::<SelectorActionResult>(value)?;
1024        if result.ok {
1025            Ok(())
1026        } else {
1027            Err(AutomationError::Selector(
1028                result
1029                    .message
1030                    .unwrap_or_else(|| "selector action failed".to_owned()),
1031            ))
1032        }
1033    }
1034}
1035
1036#[derive(Debug, Deserialize)]
1037struct SelectorActionResult {
1038    ok: bool,
1039    message: Option<String>,
1040}
1041
1042#[derive(Debug, Deserialize)]
1043struct ElementStatus {
1044    exists: bool,
1045    visible: bool,
1046    enabled: bool,
1047}
1048
1049#[derive(Debug, Deserialize)]
1050struct BoundingRect {
1051    x: f64,
1052    y: f64,
1053    width: f64,
1054    height: f64,
1055}
1056
1057#[derive(Clone, Debug)]
1058enum SelectorKind {
1059    Css(String),
1060    Text(String),
1061    Role(String),
1062    TestId(String),
1063}
1064
1065impl SelectorKind {
1066    fn javascript_expression(&self) -> Result<String> {
1067        let value = match self {
1068            Self::Css(selector) => {
1069                format!(
1070                    "document.querySelector({})",
1071                    serde_json::to_string(selector)?
1072                )
1073            }
1074            Self::Text(text) => {
1075                let text = serde_json::to_string(text)?;
1076                format!(
1077                    "(() => {{ const needle = {text}; const nodes = Array.from(document.querySelectorAll('body, body *')); return nodes.find((node) => (node.textContent ?? '').includes(needle)) ?? null; }})()"
1078                )
1079            }
1080            Self::Role(role) => {
1081                let role = serde_json::to_string(role)?;
1082                format!(
1083                    "(() => {{ 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 (['checkbox'].includes(type)) return 'checkbox'; if (['radio'].includes(type)) 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; }})()"
1084                )
1085            }
1086            Self::TestId(test_id) => format!(
1087                "document.querySelector({})",
1088                serde_json::to_string(&format!(r#"[data-testid="{test_id}"]"#))?
1089            ),
1090        };
1091        Ok(value)
1092    }
1093}
1094
1095/// Options for actions like click and fill.
1096#[derive(Debug, Clone, Copy)]
1097pub struct ActionOptions {
1098    /// Timeout for auto-wait behavior.
1099    pub timeout: Duration,
1100    /// Skip actionability checks while still resolving the locator.
1101    pub force: bool,
1102}
1103
1104impl Default for ActionOptions {
1105    fn default() -> Self {
1106        Self {
1107            timeout: Duration::from_secs(30),
1108            force: false,
1109        }
1110    }
1111}
1112
1113/// Mouse buttons recognized by Chrome input dispatch.
1114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1115pub enum MouseButton {
1116    /// No mouse button.
1117    None,
1118    /// The primary mouse button.
1119    Left,
1120    /// The auxiliary mouse button.
1121    Middle,
1122    /// The secondary mouse button.
1123    Right,
1124    /// The browser back button.
1125    Back,
1126    /// The browser forward button.
1127    Forward,
1128}
1129
1130impl MouseButton {
1131    fn as_cdp_value(self) -> &'static str {
1132        match self {
1133            Self::None => "none",
1134            Self::Left => "left",
1135            Self::Middle => "middle",
1136            Self::Right => "right",
1137            Self::Back => "back",
1138            Self::Forward => "forward",
1139        }
1140    }
1141}
1142
1143/// Options for synthetic mouse clicks.
1144#[derive(Debug, Clone, Copy)]
1145pub struct ClickOptions {
1146    /// Which mouse button to click with.
1147    pub button: MouseButton,
1148    /// Number of clicks to report to the page.
1149    pub click_count: u8,
1150    /// Delay between mouse down and mouse up.
1151    pub down_up_delay: Duration,
1152}
1153
1154impl Default for ClickOptions {
1155    fn default() -> Self {
1156        Self {
1157            button: MouseButton::Left,
1158            click_count: 1,
1159            down_up_delay: Duration::from_millis(50),
1160        }
1161    }
1162}
1163
1164/// Request interception pattern.
1165#[derive(Debug, Clone, PartialEq, Eq)]
1166pub struct RequestPattern {
1167    /// Optional URL pattern.
1168    pub url_pattern: Option<String>,
1169    /// Optional request stage.
1170    pub request_stage: Option<String>,
1171}
1172
1173impl RequestPattern {
1174    fn to_json(&self) -> Value {
1175        json!({
1176            "urlPattern": self.url_pattern,
1177            "requestStage": self.request_stage,
1178        })
1179    }
1180}
1181
1182/// A normalized network or fetch event.
1183#[derive(Debug, Clone)]
1184pub struct NetworkEvent {
1185    /// Event method name.
1186    pub method: String,
1187    /// Optional page session id.
1188    pub session_id: Option<String>,
1189    /// Raw JSON parameters.
1190    pub params: Value,
1191}
1192
1193impl From<TransportEvent> for NetworkEvent {
1194    fn from(event: TransportEvent) -> Self {
1195        Self {
1196            method: event.method,
1197            session_id: event.session_id,
1198            params: event.params.unwrap_or(Value::Null),
1199        }
1200    }
1201}
1202
1203/// A filtered event stream.
1204pub struct EventStream {
1205    receiver: broadcast::Receiver<TransportEvent>,
1206    session_id: Option<String>,
1207    method_prefixes: Vec<String>,
1208}
1209
1210impl EventStream {
1211    fn new(
1212        receiver: broadcast::Receiver<TransportEvent>,
1213        session_id: Option<String>,
1214        method_prefix: Option<&str>,
1215    ) -> Self {
1216        let method_prefixes = method_prefix.into_iter().map(str::to_owned).collect();
1217        Self {
1218            receiver,
1219            session_id,
1220            method_prefixes,
1221        }
1222    }
1223
1224    fn with_extra_prefix(mut self, prefix: &str) -> Self {
1225        self.method_prefixes.push(prefix.to_owned());
1226        self
1227    }
1228
1229    /// Receive the next matching event.
1230    pub async fn recv(&mut self) -> Result<NetworkEvent> {
1231        loop {
1232            match self.receiver.recv().await {
1233                Ok(event) => {
1234                    if self.matches(&event) {
1235                        return Ok(event.into());
1236                    }
1237                }
1238                Err(broadcast::error::RecvError::Closed) => {
1239                    return Err(AutomationError::Timeout {
1240                        what: "event stream closed".to_owned(),
1241                    });
1242                }
1243                Err(broadcast::error::RecvError::Lagged(_)) => {}
1244            }
1245        }
1246    }
1247
1248    async fn recv_with_timeout(&mut self, duration: Duration) -> Result<Option<NetworkEvent>> {
1249        match timeout(duration, self.recv()).await {
1250            Ok(event) => event.map(Some),
1251            Err(_) => Ok(None),
1252        }
1253    }
1254
1255    fn matches(&self, event: &TransportEvent) -> bool {
1256        if let Some(session_id) = &self.session_id {
1257            if event.session_id.as_deref() != Some(session_id.as_str()) {
1258                return false;
1259            }
1260        }
1261
1262        if self.method_prefixes.is_empty() {
1263            return true;
1264        }
1265
1266        self.method_prefixes
1267            .iter()
1268            .any(|prefix| event.method.starts_with(prefix))
1269    }
1270}
1271
1272/// An intercepted request route.
1273pub struct Route {
1274    client: Arc<CdpClient<AutomationTransport>>,
1275    /// The intercepted page session.
1276    pub session_id: String,
1277    /// The CDP fetch request id.
1278    pub request_id: String,
1279    /// The intercepted request URL, when present.
1280    pub url: Option<String>,
1281    /// The intercepted request method, when present.
1282    pub method: Option<String>,
1283}
1284
1285impl Route {
1286    fn from_event(
1287        client: Arc<CdpClient<AutomationTransport>>,
1288        event: NetworkEvent,
1289    ) -> Result<Self> {
1290        let paused = serde_json::from_value::<RequestPausedEvent>(event.params)?;
1291        Ok(Self {
1292            client,
1293            session_id: event
1294                .session_id
1295                .ok_or(AutomationError::MissingField("sessionId"))?,
1296            request_id: paused.request_id,
1297            url: paused.request.as_ref().map(|request| request.url.clone()),
1298            method: paused.request.map(|request| request.method),
1299        })
1300    }
1301
1302    /// Continue the intercepted request.
1303    pub async fn continue_request(&self) -> Result<()> {
1304        self.client
1305            .execute_in_session::<FetchContinueRequestParams>(
1306                self.session_id.clone(),
1307                &FetchContinueRequestParams {
1308                    request_id: self.request_id.clone(),
1309                },
1310            )
1311            .await?;
1312        Ok(())
1313    }
1314
1315    /// Abort the intercepted request.
1316    pub async fn abort(&self, error_reason: impl Into<String>) -> Result<()> {
1317        self.client
1318            .execute_in_session::<FetchFailRequestParams>(
1319                self.session_id.clone(),
1320                &FetchFailRequestParams {
1321                    request_id: self.request_id.clone(),
1322                    error_reason: error_reason.into(),
1323                },
1324            )
1325            .await?;
1326        Ok(())
1327    }
1328
1329    /// Fulfill the intercepted request with a synthetic response.
1330    pub async fn fulfill(&self, response_code: u16, body: Option<Vec<u8>>) -> Result<()> {
1331        let body = body.map(|bytes| base64::engine::general_purpose::STANDARD.encode(bytes));
1332        self.client
1333            .execute_in_session::<FetchFulfillRequestParams>(
1334                self.session_id.clone(),
1335                &FetchFulfillRequestParams {
1336                    request_id: self.request_id.clone(),
1337                    response_code,
1338                    body,
1339                },
1340            )
1341            .await?;
1342        Ok(())
1343    }
1344}
1345
1346#[derive(Debug, Deserialize)]
1347struct RequestPausedEvent {
1348    #[serde(rename = "requestId")]
1349    request_id: String,
1350    request: Option<RequestDetails>,
1351}
1352
1353#[derive(Debug, Deserialize)]
1354struct RequestDetails {
1355    url: String,
1356    method: String,
1357}
1358
1359#[cfg(test)]
1360mod tests {
1361    use super::SelectorKind;
1362
1363    #[test]
1364    fn css_selector_should_compile_to_query_selector() {
1365        let selector = SelectorKind::Css("button.primary".to_owned());
1366        let expression = selector.javascript_expression().unwrap();
1367        assert!(expression.contains("document.querySelector"));
1368    }
1369
1370    #[test]
1371    fn role_selector_should_embed_common_role_inference() {
1372        let selector = SelectorKind::Role("button".to_owned());
1373        let expression = selector.javascript_expression().unwrap();
1374        assert!(expression.contains("inferRole"));
1375    }
1376
1377    #[test]
1378    fn test_id_selector_should_target_data_testid() {
1379        let selector = SelectorKind::TestId("checkout".to_owned());
1380        let expression = selector.javascript_expression().unwrap();
1381        assert!(expression.contains("data-testid"));
1382    }
1383}