1#![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
38pub 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#[derive(Debug, Error)]
50pub enum AutomationError {
51 #[error(transparent)]
53 Launch(#[from] LaunchError),
54 #[error(transparent)]
56 Connection(#[from] ConnectionError),
57 #[error(transparent)]
59 Cdp(#[from] CdpError),
60 #[error(transparent)]
62 Json(#[from] serde_json::Error),
63 #[error(transparent)]
65 Base64(#[from] base64::DecodeError),
66 #[error("launcher did not expose a websocket endpoint")]
68 MissingWebSocketEndpoint,
69 #[error("timed out waiting for {what}")]
71 Timeout {
72 what: String,
74 },
75 #[error("locator did not match any element")]
77 MissingElement,
78 #[error("{0}")]
80 Selector(String),
81 #[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
198pub struct Browser {
200 state: Arc<BrowserState>,
201}
202
203impl Browser {
204 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 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 #[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 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 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 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 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 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#[derive(Clone)]
410pub struct CdpSession {
411 client: Arc<CdpClient<AutomationTransport>>,
412 session_id: Option<String>,
413}
414
415impl CdpSession {
416 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 #[must_use]
426 pub fn session_id(&self) -> Option<&str> {
427 self.session_id.as_deref()
428 }
429}
430
431#[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 #[must_use]
443 pub fn session_id(&self) -> &str {
444 &self.session_id
445 }
446
447 #[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 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 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 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 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 #[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 pub async fn click(&self, css_selector: impl Into<String>) -> Result<()> {
537 self.locator(css_selector).click().await
538 }
539
540 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 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 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 pub async fn focus(&self, css_selector: impl Into<String>) -> Result<()> {
572 self.locator(css_selector).focus().await
573 }
574
575 pub async fn hover(&self, css_selector: impl Into<String>) -> Result<()> {
577 self.locator(css_selector).hover().await
578 }
579
580 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 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 pub async fn exists(&self, css_selector: impl Into<String>) -> Result<bool> {
600 self.locator(css_selector).exists().await
601 }
602
603 pub async fn text_content(&self, css_selector: impl Into<String>) -> Result<String> {
605 self.locator(css_selector).text_content().await
606 }
607
608 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 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 pub async fn click_text(&self, text: impl Into<String>) -> Result<()> {
628 self.locator_text(text).click().await
629 }
630
631 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 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 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 #[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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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#[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 pub async fn click(&self) -> Result<()> {
867 self.click_with_options(ActionOptions::default()).await
868 }
869
870 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 pub async fn fill(&self, value: impl AsRef<str>) -> Result<()> {
885 self.fill_with_options(value, ActionOptions::default())
886 .await
887 }
888
889 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 pub async fn focus(&self) -> Result<()> {
915 self.wait(ActionOptions::default().timeout).await?;
916 self.run_selector_action("el.focus();").await
917 }
918
919 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 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 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 pub async fn exists(&self) -> Result<bool> {
956 let status = self.element_status().await?;
957 Ok(status.exists)
958 }
959
960 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#[derive(Debug, Clone, Copy)]
1097pub struct ActionOptions {
1098 pub timeout: Duration,
1100 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1115pub enum MouseButton {
1116 None,
1118 Left,
1120 Middle,
1122 Right,
1124 Back,
1126 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#[derive(Debug, Clone, Copy)]
1145pub struct ClickOptions {
1146 pub button: MouseButton,
1148 pub click_count: u8,
1150 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#[derive(Debug, Clone, PartialEq, Eq)]
1166pub struct RequestPattern {
1167 pub url_pattern: Option<String>,
1169 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#[derive(Debug, Clone)]
1184pub struct NetworkEvent {
1185 pub method: String,
1187 pub session_id: Option<String>,
1189 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
1203pub 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 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
1272pub struct Route {
1274 client: Arc<CdpClient<AutomationTransport>>,
1275 pub session_id: String,
1277 pub request_id: String,
1279 pub url: Option<String>,
1281 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 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 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 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}