1#![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
44pub 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#[derive(Debug, Error)]
56pub enum AutomationError {
57 #[error(transparent)]
59 Launch(#[from] LaunchError),
60 #[error(transparent)]
62 Connection(#[from] ConnectionError),
63 #[error(transparent)]
65 Cdp(#[from] CdpError),
66 #[error(transparent)]
68 Json(#[from] serde_json::Error),
69 #[error(transparent)]
71 Base64(#[from] base64::DecodeError),
72 #[error(transparent)]
74 Utf8(#[from] std::string::FromUtf8Error),
75 #[error("launcher did not expose a websocket endpoint")]
77 MissingWebSocketEndpoint,
78 #[error("timed out waiting for {what}")]
80 Timeout {
81 what: String,
83 },
84 #[error("locator did not match any element")]
86 MissingElement,
87 #[error("{0}")]
89 Selector(String),
90 #[error("missing protocol field `{0}`")]
92 MissingField(&'static str),
93 #[error("{0}")]
95 InvalidRouteState(&'static str),
96 #[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
211pub struct Browser {
213 state: Arc<BrowserState>,
214}
215
216impl Browser {
217 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 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 #[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 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 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 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 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 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#[derive(Clone)]
405pub struct CdpSession {
406 client: Arc<CdpClient<AutomationTransport>>,
407 session_id: Option<String>,
408}
409
410impl CdpSession {
411 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 #[must_use]
421 pub fn session_id(&self) -> Option<&str> {
422 self.session_id.as_deref()
423 }
424}
425
426#[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 #[must_use]
438 pub fn session_id(&self) -> &str {
439 &self.session_id
440 }
441
442 #[must_use]
444 pub fn target_id(&self) -> &str {
445 &self.target_id
446 }
447
448 #[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 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 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 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 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 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 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 #[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 pub async fn click(&self, css_selector: impl Into<String>) -> Result<()> {
552 self.locator(css_selector).click().await
553 }
554
555 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 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 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 pub async fn focus(&self, css_selector: impl Into<String>) -> Result<()> {
587 self.locator(css_selector).focus().await
588 }
589
590 pub async fn hover(&self, css_selector: impl Into<String>) -> Result<()> {
592 self.locator(css_selector).hover().await
593 }
594
595 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 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 pub async fn exists(&self, css_selector: impl Into<String>) -> Result<bool> {
615 self.locator(css_selector).exists().await
616 }
617
618 pub async fn text_content(&self, css_selector: impl Into<String>) -> Result<String> {
620 self.locator(css_selector).text_content().await
621 }
622
623 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 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 pub async fn click_text(&self, text: impl Into<String>) -> Result<()> {
643 self.locator_text(text).click().await
644 }
645
646 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1397pub enum LoadState {
1398 DomContentLoaded,
1400 Load,
1402 NetworkIdle,
1404}
1405
1406#[derive(Debug, Clone, Copy)]
1408pub struct NavigateOptions {
1409 pub wait_until: LoadState,
1411 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1426pub struct StorageState {
1427 pub cookies: Vec<CookieState>,
1429 pub origins: Vec<OriginStorageState>,
1431}
1432
1433#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1435pub struct CookieState {
1436 pub name: String,
1438 pub value: String,
1440 pub domain: String,
1442 pub path: String,
1444 #[serde(skip_serializing_if = "Option::is_none")]
1446 pub expires: Option<f64>,
1447 #[serde(rename = "httpOnly")]
1449 pub http_only: bool,
1450 pub secure: bool,
1452 #[serde(rename = "sameSite", skip_serializing_if = "Option::is_none")]
1454 pub same_site: Option<String>,
1455}
1456
1457#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1459pub struct OriginStorageState {
1460 pub origin: String,
1462 #[serde(rename = "localStorage", default)]
1464 pub local_storage: Vec<StorageEntry>,
1465 #[serde(rename = "sessionStorage", default)]
1467 pub session_storage: Vec<StorageEntry>,
1468}
1469
1470#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1472pub struct StorageEntry {
1473 pub name: String,
1475 pub value: String,
1477}
1478
1479#[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 #[must_use]
1492 pub fn id(&self) -> &str {
1493 &self.frame_id
1494 }
1495
1496 #[must_use]
1498 pub fn parent_frame_id(&self) -> Option<&str> {
1499 self.parent_frame_id.as_deref()
1500 }
1501
1502 #[must_use]
1504 pub fn name(&self) -> Option<&str> {
1505 self.name.as_deref()
1506 }
1507
1508 #[must_use]
1510 pub fn url(&self) -> &str {
1511 &self.url
1512 }
1513
1514 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 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 #[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 pub async fn text_content(&self, css_selector: impl Into<String>) -> Result<String> {
1540 self.locator(css_selector).text_content().await
1541 }
1542
1543 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#[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 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 pub async fn click(&self) -> Result<()> {
1587 self.click_with_options(ActionOptions::default()).await
1588 }
1589
1590 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 pub async fn fill(&self, value: impl AsRef<str>) -> Result<()> {
1605 self.fill_with_options(value, ActionOptions::default())
1606 .await
1607 }
1608
1609 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 pub async fn focus(&self) -> Result<()> {
1628 self.wait(ActionOptions::default().timeout).await?;
1629 self.element_handle().await?.focus().await
1630 }
1631
1632 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 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 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 pub async fn exists(&self) -> Result<bool> {
1672 let status = self.element_status().await?;
1673 Ok(status.exists)
1674 }
1675
1676 pub async fn text_content(&self) -> Result<String> {
1678 self.element_handle().await?.text_content().await
1679 }
1680
1681 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#[derive(Debug, Deserialize)]
1782pub struct BoundingRect {
1783 pub x: f64,
1785 pub y: f64,
1787 pub width: f64,
1789 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#[derive(Debug, Clone, Copy)]
1833pub struct ActionOptions {
1834 pub timeout: Duration,
1836 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1851pub enum MouseButton {
1852 None,
1854 Left,
1856 Middle,
1858 Right,
1860 Back,
1862 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#[derive(Debug, Clone, Copy)]
1881pub struct ClickOptions {
1882 pub button: MouseButton,
1884 pub click_count: u8,
1886 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#[derive(Debug, Clone, PartialEq, Eq)]
1902pub struct RequestPattern {
1903 pub url_pattern: Option<String>,
1905 pub request_stage: Option<String>,
1907}
1908
1909impl RequestPattern {
1910 #[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 #[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 #[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#[derive(Debug, Clone)]
1966pub struct NetworkEvent {
1967 pub method: String,
1969 pub session_id: Option<String>,
1971 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#[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 #[must_use]
2031 pub fn request_id(&self) -> &str {
2032 &self.request_id
2033 }
2034
2035 #[must_use]
2037 pub fn method(&self) -> &str {
2038 &self.method
2039 }
2040
2041 #[must_use]
2043 pub fn url(&self) -> &str {
2044 &self.url
2045 }
2046
2047 #[must_use]
2049 pub fn headers(&self) -> &BTreeMap<String, String> {
2050 &self.headers
2051 }
2052
2053 #[must_use]
2055 pub fn post_data(&self) -> Option<&str> {
2056 self.post_data.as_deref()
2057 }
2058
2059 #[must_use]
2061 pub fn resource_type(&self) -> Option<&str> {
2062 self.resource_type.as_deref()
2063 }
2064
2065 #[must_use]
2067 pub fn frame_id(&self) -> Option<&str> {
2068 self.frame_id.as_deref()
2069 }
2070
2071 #[must_use]
2073 pub fn session_id(&self) -> Option<&str> {
2074 self.session_id.as_deref()
2075 }
2076}
2077
2078#[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 #[must_use]
2128 pub fn request_id(&self) -> &str {
2129 &self.request_id
2130 }
2131
2132 #[must_use]
2134 pub fn url(&self) -> &str {
2135 &self.url
2136 }
2137
2138 #[must_use]
2140 pub fn status(&self) -> u16 {
2141 self.status
2142 }
2143
2144 #[must_use]
2146 pub fn status_text(&self) -> Option<&str> {
2147 self.status_text.as_deref()
2148 }
2149
2150 #[must_use]
2152 pub fn headers(&self) -> &BTreeMap<String, String> {
2153 &self.headers
2154 }
2155
2156 #[must_use]
2158 pub fn mime_type(&self) -> Option<&str> {
2159 self.mime_type.as_deref()
2160 }
2161
2162 #[must_use]
2164 pub fn resource_type(&self) -> Option<&str> {
2165 self.resource_type.as_deref()
2166 }
2167
2168 #[must_use]
2170 pub fn frame_id(&self) -> Option<&str> {
2171 self.frame_id.as_deref()
2172 }
2173
2174 #[must_use]
2176 pub fn session_id(&self) -> Option<&str> {
2177 self.session_id.as_deref()
2178 }
2179}
2180
2181pub 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 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
2250pub struct Route {
2252 client: Arc<CdpClient<AutomationTransport>>,
2253 pub session_id: String,
2255 pub request_id: String,
2257 pub url: Option<String>,
2259 pub method: Option<String>,
2261 pub response_status_code: Option<u16>,
2263 pub response_status_text: Option<String>,
2265 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 #[must_use]
2291 pub fn is_response_stage(&self) -> bool {
2292 self.response_status_code.is_some()
2293 }
2294
2295 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 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 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 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 pub async fn response_text(&self) -> Result<String> {
2367 String::from_utf8(self.response_body().await?).map_err(AutomationError::from)
2368 }
2369
2370 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#[derive(Debug, Clone, Deserialize)]
2438pub struct HeaderEntry {
2439 pub name: String,
2441 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#[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 #[must_use]
2540 pub fn object_id(&self) -> &str {
2541 &self.object_id
2542 }
2543
2544 #[must_use]
2546 pub fn object_type(&self) -> &str {
2547 &self.object_type
2548 }
2549
2550 #[must_use]
2552 pub fn subtype(&self) -> Option<&str> {
2553 self.subtype.as_deref()
2554 }
2555
2556 #[must_use]
2558 pub fn description(&self) -> Option<&str> {
2559 self.description.as_deref()
2560 }
2561
2562 pub async fn json_value(&self) -> Result<Value> {
2564 self.call_function_value("function() { return this; }", Vec::new())
2565 .await
2566 }
2567
2568 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 #[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#[derive(Clone)]
2621pub struct ElementHandle {
2622 handle: JsHandle,
2623}
2624
2625impl ElementHandle {
2626 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 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 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 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 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}