//! High-level Chrome automation for Playhard.
//!
//! This crate composes `playhard-launcher`, `playhard-transport`, and
//! `playhard-cdp` into a Rust-native browser automation API.
#![deny(missing_docs)]
use std::{
collections::BTreeMap,
future::Future,
path::PathBuf,
sync::{Arc, Mutex, MutexGuard},
time::Duration,
};
use base64::Engine;
use playhard_cdp::{
CdpClient, CdpError, CdpResponse, CdpTransport, FetchContinueRequestParams, FetchEnableParams,
FetchFailRequestParams, FetchFulfillRequestParams, FetchGetResponseBodyParams,
FetchHeaderEntry, InputDispatchKeyEventParams, InputInsertTextParams, NetworkEnableParams,
PageCaptureScreenshotParams, PageCaptureScreenshotResult, PageCreateIsolatedWorldParams,
PageEnableParams, PageGetFrameTreeParams, PageNavigateParams, PageNavigateResult,
PageSetLifecycleEventsEnabledParams, RemoteObject, RuntimeCallArgument,
RuntimeCallFunctionOnParams, RuntimeEnableParams, RuntimeEvaluateParams,
RuntimeReleaseObjectParams, TargetAttachToTargetParams, TargetCreateTargetParams,
TargetSetDiscoverTargetsParams,
};
use playhard_launcher::{
LaunchConnection, LaunchError, LaunchOptions, LaunchedChrome, LaunchedChromeParts, Launcher,
ProfileDir, TransportMode,
};
use playhard_transport::{
Connection, ConnectionError, PipeTransport, TransportEvent, WebSocketTransport,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use thiserror::Error;
use tokio::{
process::Child,
sync::broadcast,
time::{sleep, timeout, Instant},
};
/// Result alias for the automation crate.
pub type Result<T> = std::result::Result<T, AutomationError>;
fn lock_unpoisoned<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
match mutex.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
/// Errors produced by `playhard-automation`.
#[derive(Debug, Error)]
pub enum AutomationError {
/// Chrome launch failed.
#[error(transparent)]
Launch(#[from] LaunchError),
/// Transport connection failed.
#[error(transparent)]
Connection(#[from] ConnectionError),
/// CDP command failed.
#[error(transparent)]
Cdp(#[from] CdpError),
/// JSON serialization or deserialization failed.
#[error(transparent)]
Json(#[from] serde_json::Error),
/// Base64 decoding failed.
#[error(transparent)]
Base64(#[from] base64::DecodeError),
/// UTF-8 decoding failed.
#[error(transparent)]
Utf8(#[from] std::string::FromUtf8Error),
/// A required websocket endpoint was missing.
#[error("launcher did not expose a websocket endpoint")]
MissingWebSocketEndpoint,
/// A wait timed out.
#[error("timed out waiting for {what}")]
Timeout {
/// Human-readable description of the timed operation.
what: String,
},
/// A locator action could not find a matching element.
#[error("locator did not match any element")]
MissingElement,
/// A selector operation failed.
#[error("{0}")]
Selector(String),
/// An expected protocol field was missing.
#[error("missing protocol field `{0}`")]
MissingField(&'static str),
/// A route operation required a different interception stage.
#[error("{0}")]
InvalidRouteState(&'static str),
/// Input dispatch failed because the requested key is unsupported.
#[error("{0}")]
Input(String),
}
#[derive(Clone)]
enum AutomationTransport {
WebSocket(Arc<Connection<WebSocketTransport>>),
Pipe(Arc<Connection<PipeTransport>>),
}
impl AutomationTransport {
fn subscribe_events(&self) -> broadcast::Receiver<TransportEvent> {
match self {
Self::WebSocket(connection) => connection.subscribe_events(),
Self::Pipe(connection) => connection.subscribe_events(),
}
}
async fn close(&self) -> Result<()> {
match self {
Self::WebSocket(connection) => connection.close().await.map_err(AutomationError::from),
Self::Pipe(connection) => connection.close().await.map_err(AutomationError::from),
}
}
}
impl CdpTransport for AutomationTransport {
async fn send(
&self,
request: playhard_cdp::CdpRequest,
) -> std::result::Result<CdpResponse, CdpError> {
let response = match self {
Self::WebSocket(connection) => send_over_connection(connection, request).await,
Self::Pipe(connection) => send_over_connection(connection, request).await,
};
response.map_err(Into::into)
}
}
async fn send_over_connection<T>(
connection: &Connection<T>,
request: playhard_cdp::CdpRequest,
) -> Result<CdpResponse>
where
T: playhard_transport::TransportHandle,
{
let message = if let Some(session_id) = request.session_id.clone() {
connection
.request_for_session(session_id, request.method, Some(request.params))
.await?
} else {
connection
.request(request.method, Some(request.params))
.await?
};
Ok(CdpResponse {
id: message.id.ok_or(AutomationError::MissingField("id"))?,
result: message.result,
error: message.error.map(|error| playhard_cdp::CdpResponseError {
code: error.code,
message: error.message,
}),
session_id: message.session_id,
})
}
impl From<AutomationError> for CdpError {
fn from(error: AutomationError) -> Self {
match error {
AutomationError::Cdp(error) => error,
other => CdpError::Transport(other.to_string()),
}
}
}
enum LaunchGuard {
WebSocket(LaunchedChrome),
Pipe(PipeGuard),
}
impl LaunchGuard {
async fn shutdown(self) -> Result<()> {
match self {
Self::WebSocket(launched) => launched.shutdown().await.map_err(AutomationError::from),
Self::Pipe(mut guard) => {
let _ = guard.child.start_kill();
let _ = timeout(Duration::from_secs(5), guard.child.wait()).await;
Ok(())
}
}
}
}
struct PipeGuard {
_executable_path: PathBuf,
_profile: ProfileDir,
child: Child,
}
impl Drop for PipeGuard {
fn drop(&mut self) {
let _ = self.child.start_kill();
}
}
struct BrowserState {
client: Arc<CdpClient<AutomationTransport>>,
transport: AutomationTransport,
launch_guard: Mutex<Option<LaunchGuard>>,
browser_interception_patterns: Mutex<Option<Vec<RequestPattern>>>,
page_sessions: Mutex<Vec<String>>,
}
/// A browser automation session.
pub struct Browser {
state: Arc<BrowserState>,
}
impl Browser {
/// Launch a new browser using the supplied options.
pub async fn launch(options: LaunchOptions) -> Result<Self> {
let launched = Launcher::new(options).launch().await?;
let transport = match launched.transport_mode() {
TransportMode::WebSocket => {
let endpoint = launched
.websocket_endpoint()
.ok_or(AutomationError::MissingWebSocketEndpoint)?;
let websocket = WebSocketTransport::connect(endpoint)
.await
.map_err(ConnectionError::from)?;
let connection = Connection::new(websocket)?;
let transport = AutomationTransport::WebSocket(Arc::new(connection));
(transport, LaunchGuard::WebSocket(launched))
}
TransportMode::Pipe => {
let parts = launched.into_parts();
let (pipe_transport, guard) = pipe_transport_from_parts(parts)?;
let connection = Connection::new(pipe_transport)?;
let transport = AutomationTransport::Pipe(Arc::new(connection));
(transport, guard)
}
};
let browser = Self::from_transport(transport.0, Some(transport.1));
browser.initialize().await?;
Ok(browser)
}
/// Connect to an already-running Chrome DevTools websocket endpoint.
pub async fn connect_websocket(url: impl AsRef<str>) -> Result<Self> {
let websocket = WebSocketTransport::connect(url.as_ref())
.await
.map_err(ConnectionError::from)?;
let connection = Connection::new(websocket)?;
let browser =
Self::from_transport(AutomationTransport::WebSocket(Arc::new(connection)), None);
browser.initialize().await?;
Ok(browser)
}
fn from_transport(transport: AutomationTransport, launch_guard: Option<LaunchGuard>) -> Self {
let client = Arc::new(CdpClient::new(transport.clone()));
let state = BrowserState {
client,
transport,
launch_guard: Mutex::new(launch_guard),
browser_interception_patterns: Mutex::new(None),
page_sessions: Mutex::new(Vec::new()),
};
Self {
state: Arc::new(state),
}
}
async fn initialize(&self) -> Result<()> {
self.state
.client
.execute::<TargetSetDiscoverTargetsParams>(&TargetSetDiscoverTargetsParams {
discover: true,
})
.await?;
Ok(())
}
/// Returns a raw CDP escape hatch rooted at the browser session.
#[must_use]
pub fn cdp(&self) -> CdpSession {
CdpSession {
client: Arc::clone(&self.state.client),
session_id: None,
}
}
/// Open a new page and bootstrap the common CDP domains used by Playhard.
pub async fn new_page(&self) -> Result<Page> {
let target = self
.state
.client
.execute::<TargetCreateTargetParams>(&TargetCreateTargetParams {
url: "about:blank".to_owned(),
new_window: None,
})
.await?;
let target_id = target.target_id.clone();
let attached = self
.state
.client
.execute::<TargetAttachToTargetParams>(&TargetAttachToTargetParams {
target_id,
flatten: Some(true),
})
.await?;
let session_id = attached.session_id;
bootstrap_page_session(Arc::clone(&self.state), session_id, target.target_id).await
}
/// Subscribe to all browser-wide network and fetch events.
pub fn network_events(&self) -> EventStream {
EventStream::new(
self.state.transport.subscribe_events(),
None,
Some("Network."),
)
.with_extra_prefix("Fetch.")
}
/// Enable request interception for all existing and future pages.
pub async fn enable_request_interception<I>(&self, patterns: I) -> Result<()>
where
I: IntoIterator<Item = RequestPattern>,
{
let patterns = collect_request_patterns(patterns);
{
let mut stored = lock_unpoisoned(&self.state.browser_interception_patterns);
*stored = Some(patterns.clone());
}
let session_ids = lock_unpoisoned(&self.state.page_sessions).clone();
for session_id in session_ids {
self.state
.client
.execute_in_session::<FetchEnableParams>(
session_id,
&FetchEnableParams {
patterns: Some(patterns.iter().map(RequestPattern::to_cdp).collect()),
},
)
.await?;
}
Ok(())
}
/// Wait for the next paused request across all pages.
pub async fn next_route(&self, timeout_duration: Duration) -> Result<Route> {
let mut events = EventStream::new(
self.state.transport.subscribe_events(),
None,
Some("Fetch."),
);
let event = events
.recv_with_timeout(timeout_duration)
.await?
.ok_or_else(|| AutomationError::Timeout {
what: "browser route".to_owned(),
})?;
Route::from_event(Arc::clone(&self.state.client), event)
}
/// Shut down the browser and close the underlying transport.
pub async fn shutdown(self) -> Result<()> {
self.state.transport.close().await?;
let launch_guard = {
let mut guard = lock_unpoisoned(&self.state.launch_guard);
guard.take()
};
if let Some(guard) = launch_guard {
guard.shutdown().await?;
}
Ok(())
}
}
fn pipe_transport_from_parts(parts: LaunchedChromeParts) -> Result<(PipeTransport, LaunchGuard)> {
let LaunchedChromeParts {
executable_path,
profile,
child,
connection,
} = parts;
let LaunchConnection::Pipe { stdin, stdout } = connection else {
return Err(AutomationError::MissingWebSocketEndpoint);
};
let transport = PipeTransport::new(stdin, stdout).map_err(ConnectionError::from)?;
let guard = LaunchGuard::Pipe(PipeGuard {
_executable_path: executable_path,
_profile: profile,
child,
});
Ok((transport, guard))
}
/// A raw CDP session escape hatch.
#[derive(Clone)]
pub struct CdpSession {
client: Arc<CdpClient<AutomationTransport>>,
session_id: Option<String>,
}
impl CdpSession {
/// Send an arbitrary CDP command and return its JSON result.
pub async fn call_raw(&self, method: impl Into<String>, params: Value) -> Result<Value> {
self.client
.call_raw(method.into(), params, self.session_id.clone())
.await
.map_err(AutomationError::from)
}
/// Return the target session id, if this session is page-scoped.
#[must_use]
pub fn session_id(&self) -> Option<&str> {
self.session_id.as_deref()
}
}
/// A page/tab automation handle.
#[derive(Clone)]
pub struct Page {
state: Arc<BrowserState>,
session_id: String,
target_id: String,
default_timeout: Duration,
}
impl Page {
/// Returns the attached target session id.
#[must_use]
pub fn session_id(&self) -> &str {
&self.session_id
}
/// Returns the underlying Chrome target id.
#[must_use]
pub fn target_id(&self) -> &str {
&self.target_id
}
/// Returns a raw CDP escape hatch scoped to this page session.
#[must_use]
pub fn cdp(&self) -> CdpSession {
CdpSession {
client: Arc::clone(&self.state.client),
session_id: Some(self.session_id.clone()),
}
}
/// Navigate the page and wait for the load event.
pub async fn goto(&self, url: impl AsRef<str>) -> Result<PageNavigateResult> {
self.goto_with_options(
url,
NavigateOptions {
wait_until: LoadState::Load,
timeout: self.default_timeout,
},
)
.await
}
/// Navigate the page with explicit waiting behavior.
pub async fn goto_with_options(
&self,
url: impl AsRef<str>,
options: NavigateOptions,
) -> Result<PageNavigateResult> {
let result = self
.state
.client
.execute_in_session::<PageNavigateParams>(
self.session_id.clone(),
&PageNavigateParams {
url: url.as_ref().to_owned(),
},
)
.await?;
self.wait_for_load_state(options.wait_until, options.timeout)
.await?;
Ok(result)
}
/// Evaluate JavaScript in the page main world and return the JSON result.
pub async fn evaluate(&self, expression: impl AsRef<str>) -> Result<Value> {
self.evaluate_in_frame_value(expression.as_ref(), None)
.await
}
/// Evaluate JavaScript and keep the resulting remote object alive.
pub async fn evaluate_handle(&self, expression: impl AsRef<str>) -> Result<JsHandle> {
self.evaluate_in_frame_handle(expression.as_ref(), None)
.await
}
/// Capture a page screenshot.
pub async fn screenshot(&self) -> Result<Vec<u8>> {
let result: PageCaptureScreenshotResult = self
.state
.client
.execute_in_session::<PageCaptureScreenshotParams>(
self.session_id.clone(),
&PageCaptureScreenshotParams {
format: Some("png".to_owned()),
},
)
.await?;
Ok(base64::engine::general_purpose::STANDARD.decode(result.data)?)
}
/// Capture a screenshot of the current element matched by the locator.
pub async fn element_screenshot(&self, locator: &Locator) -> Result<Vec<u8>> {
let rect = locator.bounding_rect().await?;
let clip = json!({
"x": rect.x,
"y": rect.y,
"width": rect.width,
"height": rect.height,
"scale": 1.0,
});
let result = self
.cdp()
.call_raw(
"Page.captureScreenshot",
json!({
"format": "png",
"clip": clip,
}),
)
.await?;
let data = result
.get("data")
.and_then(Value::as_str)
.ok_or(AutomationError::MissingField("data"))?;
Ok(base64::engine::general_purpose::STANDARD.decode(data)?)
}
/// Build a CSS locator.
#[must_use]
pub fn locator(&self, css_selector: impl Into<String>) -> Locator {
Locator::new(self.clone(), SelectorKind::Css(css_selector.into()), None)
}
/// Click the first element matching the CSS selector.
pub async fn click(&self, css_selector: impl Into<String>) -> Result<()> {
self.locator(css_selector).click().await
}
/// Click the first element matching the CSS selector with custom action options.
pub async fn click_with_options(
&self,
css_selector: impl Into<String>,
options: ActionOptions,
) -> Result<()> {
self.locator(css_selector).click_with_options(options).await
}
/// Fill the first form control matching the CSS selector.
pub async fn fill(
&self,
css_selector: impl Into<String>,
value: impl AsRef<str>,
) -> Result<()> {
self.locator(css_selector).fill(value).await
}
/// Fill the first form control matching the CSS selector with custom action options.
pub async fn fill_with_options(
&self,
css_selector: impl Into<String>,
value: impl AsRef<str>,
options: ActionOptions,
) -> Result<()> {
self.locator(css_selector)
.fill_with_options(value, options)
.await
}
/// Focus the first element matching the CSS selector.
pub async fn focus(&self, css_selector: impl Into<String>) -> Result<()> {
self.locator(css_selector).focus().await
}
/// Hover the first element matching the CSS selector.
pub async fn hover(&self, css_selector: impl Into<String>) -> Result<()> {
self.locator(css_selector).hover().await
}
/// Select an option by value on the first matching `<select>` element.
pub async fn select(
&self,
css_selector: impl Into<String>,
value: impl AsRef<str>,
) -> Result<()> {
self.locator(css_selector).select(value).await
}
/// Wait until the CSS selector matches an element.
pub async fn wait_for_selector(
&self,
css_selector: impl Into<String>,
timeout_duration: Duration,
) -> Result<()> {
self.locator(css_selector).wait(timeout_duration).await
}
/// Return whether the CSS selector currently matches an element.
pub async fn exists(&self, css_selector: impl Into<String>) -> Result<bool> {
self.locator(css_selector).exists().await
}
/// Read the text content of the first matching element.
pub async fn text_content(&self, css_selector: impl Into<String>) -> Result<String> {
self.locator(css_selector).text_content().await
}
/// Read the current page URL.
pub async fn url(&self) -> Result<String> {
let value = self.evaluate("window.location.href").await?;
value
.as_str()
.map(str::to_owned)
.ok_or_else(|| AutomationError::Selector("location.href was not a string".to_owned()))
}
/// Read the current page title.
pub async fn title(&self) -> Result<String> {
let value = self.evaluate("document.title").await?;
value
.as_str()
.map(str::to_owned)
.ok_or_else(|| AutomationError::Selector("document.title was not a string".to_owned()))
}
/// Click the first element whose text content contains the supplied text.
pub async fn click_text(&self, text: impl Into<String>) -> Result<()> {
self.locator_text(text).click().await
}
/// Click the first element whose text content contains the supplied text with custom action options.
pub async fn click_text_with_options(
&self,
text: impl Into<String>,
options: ActionOptions,
) -> Result<()> {
self.locator_text(text).click_with_options(options).await
}
/// Wait until an element containing the supplied text appears.
pub async fn wait_for_text(
&self,
text: impl Into<String>,
timeout_duration: Duration,
) -> Result<()> {
self.locator_text(text).wait(timeout_duration).await
}
/// Wait for the page to reach the requested load state.
pub async fn wait_for_load_state(
&self,
state: LoadState,
timeout_duration: Duration,
) -> Result<()> {
match state {
LoadState::DomContentLoaded => {
self.wait_for_event("Page.domContentEventFired", timeout_duration)
.await?;
}
LoadState::Load => {
self.wait_for_event("Page.loadEventFired", timeout_duration)
.await?;
}
LoadState::NetworkIdle => {
let deadline = Instant::now() + timeout_duration;
let mut events = EventStream::new(
self.state.transport.subscribe_events(),
Some(self.session_id.clone()),
Some("Page.lifecycleEvent"),
);
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
let event = events.recv_with_timeout(remaining).await?.ok_or_else(|| {
AutomationError::Timeout {
what: "Page.lifecycleEvent networkIdle".to_owned(),
}
})?;
if event
.params
.get("name")
.and_then(Value::as_str)
.is_some_and(|name| name == "networkIdle")
{
break;
}
}
}
}
Ok(())
}
/// Wait for a top-level navigation to commit and then for a chosen load state.
pub async fn wait_for_navigation(
&self,
state: LoadState,
timeout_duration: Duration,
) -> Result<String> {
let deadline = Instant::now() + timeout_duration;
let mut events = EventStream::new(
self.state.transport.subscribe_events(),
Some(self.session_id.clone()),
Some("Page.frameNavigated"),
);
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
let event = events.recv_with_timeout(remaining).await?.ok_or_else(|| {
AutomationError::Timeout {
what: "top-level navigation".to_owned(),
}
})?;
let frame = event
.params
.get("frame")
.and_then(Value::as_object)
.ok_or(AutomationError::MissingField("frame"))?;
if frame.get("parentId").is_none() {
let remaining = deadline.saturating_duration_since(Instant::now());
self.wait_for_load_state(state, remaining).await?;
return self.url().await;
}
}
}
/// Wait until the current page URL contains the supplied substring.
pub async fn wait_for_url_contains(
&self,
needle: impl AsRef<str>,
timeout_duration: Duration,
) -> Result<String> {
let deadline = Instant::now() + timeout_duration;
let needle = needle.as_ref().to_owned();
loop {
let current_url = self.url().await?;
if current_url.contains(&needle) {
return Ok(current_url);
}
if Instant::now() >= deadline {
return Err(AutomationError::Timeout {
what: format!("URL containing {needle}"),
});
}
sleep(Duration::from_millis(200)).await;
}
}
/// Return the current page frame tree flattened into frame handles.
pub async fn frames(&self) -> Result<Vec<Frame>> {
let tree = self
.state
.client
.execute_in_session::<PageGetFrameTreeParams>(
self.session_id.clone(),
&PageGetFrameTreeParams {},
)
.await?
.frame_tree;
Ok(flatten_frame_tree(self.clone(), tree))
}
/// Return the current top-level frame.
pub async fn main_frame(&self) -> Result<Frame> {
self.frames()
.await?
.into_iter()
.find(|frame| frame.parent_frame_id().is_none())
.ok_or(AutomationError::MissingField("main frame"))
}
/// Capture cookies plus the current page origin's local and session storage.
pub async fn storage_state(&self) -> Result<StorageState> {
Ok(StorageState {
cookies: self.cookie_state().await?,
origins: self
.current_origin_storage_state()
.await?
.into_iter()
.collect(),
})
}
/// Restore cookies plus origin-scoped local and session storage onto this page.
pub async fn restore_storage_state(&self, state: &StorageState) -> Result<()> {
self.set_cookie_state(&state.cookies).await?;
for origin in &state.origins {
self.goto_with_options(
&origin.origin,
NavigateOptions {
wait_until: LoadState::DomContentLoaded,
timeout: self.default_timeout,
},
)
.await?;
let local_storage = serde_json::to_string(&origin.local_storage)?;
let session_storage = serde_json::to_string(&origin.session_storage)?;
self.evaluate(format!(
"(() => {{ 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; }})()"
))
.await?;
}
Ok(())
}
/// Build a text locator.
#[must_use]
pub fn locator_text(&self, text: impl Into<String>) -> Locator {
Locator::new(self.clone(), SelectorKind::Text(text.into()), None)
}
/// Build a role locator.
#[must_use]
pub fn locator_role(&self, role: impl Into<String>) -> Locator {
Locator::new(self.clone(), SelectorKind::Role(role.into()), None)
}
/// Build a test-id locator.
#[must_use]
pub fn locator_test_id(&self, test_id: impl Into<String>) -> Locator {
Locator::new(self.clone(), SelectorKind::TestId(test_id.into()), None)
}
/// Press a key against the active element.
pub async fn press(&self, key: impl AsRef<str>) -> Result<()> {
let key = KeyDefinition::parse(key.as_ref())?;
self.state
.client
.execute_in_session::<InputDispatchKeyEventParams>(
self.session_id.clone(),
&InputDispatchKeyEventParams {
event_type: "keyDown".to_owned(),
key: key.key.clone(),
code: key.code.clone(),
text: None,
unmodified_text: None,
windows_virtual_key_code: key.key_code,
native_virtual_key_code: key.key_code,
},
)
.await?;
if let Some(text) = key.text.clone() {
self.state
.client
.execute_in_session::<InputDispatchKeyEventParams>(
self.session_id.clone(),
&InputDispatchKeyEventParams {
event_type: "char".to_owned(),
key: key.key.clone(),
code: key.code.clone(),
text: Some(text.clone()),
unmodified_text: Some(text),
windows_virtual_key_code: key.key_code,
native_virtual_key_code: key.key_code,
},
)
.await?;
}
self.state
.client
.execute_in_session::<InputDispatchKeyEventParams>(
self.session_id.clone(),
&InputDispatchKeyEventParams {
event_type: "keyUp".to_owned(),
key: key.key,
code: key.code,
text: None,
unmodified_text: None,
windows_virtual_key_code: key.key_code,
native_virtual_key_code: key.key_code,
},
)
.await?;
Ok(())
}
/// Move the mouse pointer to the given viewport coordinates.
pub async fn move_mouse(&self, x: f64, y: f64) -> Result<()> {
self.dispatch_mouse_event("mouseMoved", x, y, MouseButton::None, 0, 0)
.await
}
/// Press a mouse button at the given viewport coordinates.
pub async fn mouse_down(&self, x: f64, y: f64, button: MouseButton) -> Result<()> {
self.dispatch_mouse_event("mousePressed", x, y, button, 1, 1)
.await
}
/// Release a mouse button at the given viewport coordinates.
pub async fn mouse_up(&self, x: f64, y: f64, button: MouseButton) -> Result<()> {
self.dispatch_mouse_event("mouseReleased", x, y, button, 0, 1)
.await
}
/// Click at the given viewport coordinates.
pub async fn click_at(&self, x: f64, y: f64, options: ClickOptions) -> Result<()> {
self.move_mouse(x, y).await?;
self.dispatch_mouse_event("mousePressed", x, y, options.button, 1, options.click_count)
.await?;
sleep(options.down_up_delay).await;
self.dispatch_mouse_event(
"mouseReleased",
x,
y,
options.button,
0,
options.click_count,
)
.await
}
/// Insert text into the currently focused element.
pub async fn insert_text(&self, text: impl AsRef<str>) -> Result<()> {
self.state
.client
.execute_in_session::<InputInsertTextParams>(
self.session_id.clone(),
&InputInsertTextParams {
text: text.as_ref().to_owned(),
},
)
.await?;
Ok(())
}
/// Type text into the currently focused element, waiting between characters.
pub async fn type_text(&self, text: impl AsRef<str>, delay: Duration) -> Result<()> {
for character in text.as_ref().chars() {
self.insert_text(character.to_string()).await?;
sleep(delay).await;
}
Ok(())
}
/// Subscribe to all events for this page session.
pub fn events(&self) -> EventStream {
EventStream::new(
self.state.transport.subscribe_events(),
Some(self.session_id.clone()),
None,
)
}
/// Subscribe to network and fetch events for this page session.
pub fn network_events(&self) -> EventStream {
EventStream::new(
self.state.transport.subscribe_events(),
Some(self.session_id.clone()),
Some("Network."),
)
.with_extra_prefix("Fetch.")
}
/// Spawn a background task that runs for each outgoing page request.
pub fn on_request<F, Fut>(&self, handler: F) -> tokio::task::JoinHandle<()>
where
F: Fn(Request) -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
let mut events = self.network_events();
let handler = Arc::new(handler);
tokio::spawn(async move {
loop {
let Ok(event) = events.recv().await else {
break;
};
let Some(request) = Request::from_network_event(event) else {
continue;
};
handler(request).await;
}
})
}
/// Spawn a background task that runs for each page response.
pub fn on_response<F, Fut>(&self, handler: F) -> tokio::task::JoinHandle<()>
where
F: Fn(Response) -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
let mut events = self.network_events();
let handler = Arc::new(handler);
tokio::spawn(async move {
loop {
let Ok(event) = events.recv().await else {
break;
};
let Some(response) = Response::from_network_event(event) else {
continue;
};
handler(response).await;
}
})
}
/// Wait for the next request that satisfies the predicate.
pub async fn wait_for_request<F>(
&self,
timeout_duration: Duration,
predicate: F,
) -> Result<Request>
where
F: Fn(&Request) -> bool,
{
let deadline = Instant::now() + timeout_duration;
let mut events = self.network_events();
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
let event = events.recv_with_timeout(remaining).await?.ok_or_else(|| {
AutomationError::Timeout {
what: "request event".to_owned(),
}
})?;
let Some(request) = Request::from_network_event(event) else {
continue;
};
if predicate(&request) {
return Ok(request);
}
}
}
/// Wait for the next response that satisfies the predicate.
pub async fn wait_for_response<F>(
&self,
timeout_duration: Duration,
predicate: F,
) -> Result<Response>
where
F: Fn(&Response) -> bool,
{
let deadline = Instant::now() + timeout_duration;
let mut events = self.network_events();
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
let event = events.recv_with_timeout(remaining).await?.ok_or_else(|| {
AutomationError::Timeout {
what: "response event".to_owned(),
}
})?;
let Some(response) = Response::from_network_event(event) else {
continue;
};
if predicate(&response) {
return Ok(response);
}
}
}
/// Enable request interception for this page.
pub async fn enable_request_interception<I>(&self, patterns: I) -> Result<()>
where
I: IntoIterator<Item = RequestPattern>,
{
let patterns = collect_request_patterns(patterns);
self.state
.client
.execute_in_session::<FetchEnableParams>(
self.session_id.clone(),
&FetchEnableParams {
patterns: Some(patterns.iter().map(RequestPattern::to_cdp).collect()),
},
)
.await?;
Ok(())
}
/// Enable request interception and handle the next matched route in the background.
pub async fn route_once<I, F, Fut>(
&self,
patterns: I,
timeout_duration: Duration,
handler: F,
) -> Result<tokio::task::JoinHandle<Result<()>>>
where
I: IntoIterator<Item = RequestPattern>,
F: FnOnce(Route) -> Fut + Send + 'static,
Fut: Future<Output = Result<()>> + Send + 'static,
{
let mut events = EventStream::new(
self.state.transport.subscribe_events(),
Some(self.session_id.clone()),
Some("Fetch."),
);
self.enable_request_interception(patterns).await?;
let client = Arc::clone(&self.state.client);
let session_id = self.session_id.clone();
Ok(tokio::spawn(async move {
let event = events
.recv_with_timeout(timeout_duration)
.await?
.ok_or_else(|| AutomationError::Timeout {
what: format!("page route for session {session_id}"),
})?;
let route = Route::from_event(client, event)?;
handler(route).await
}))
}
/// Wait for the next intercepted route on this page.
pub async fn next_route(&self, timeout_duration: Duration) -> Result<Route> {
let mut events = EventStream::new(
self.state.transport.subscribe_events(),
Some(self.session_id.clone()),
Some("Fetch."),
);
let event = events
.recv_with_timeout(timeout_duration)
.await?
.ok_or_else(|| AutomationError::Timeout {
what: "page route".to_owned(),
})?;
Route::from_event(Arc::clone(&self.state.client), event)
}
/// Wait for a popup page opened by this page.
pub async fn wait_for_popup(&self, timeout_duration: Duration) -> Result<Page> {
let deadline = Instant::now() + timeout_duration;
let mut events = EventStream::new(
self.state.transport.subscribe_events(),
None,
Some("Target.targetCreated"),
);
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
let event = events.recv_with_timeout(remaining).await?.ok_or_else(|| {
AutomationError::Timeout {
what: "popup target".to_owned(),
}
})?;
let target = serde_json::from_value::<CreatedTargetEvent>(event.params)?;
if target.target_info.target_type != "page" {
continue;
}
if target.target_info.opener_id.as_deref() != Some(self.target_id.as_str()) {
continue;
}
let attached = self
.state
.client
.execute::<TargetAttachToTargetParams>(&TargetAttachToTargetParams {
target_id: target.target_info.target_id.clone(),
flatten: Some(true),
})
.await?;
return bootstrap_page_session(
Arc::clone(&self.state),
attached.session_id,
target.target_info.target_id,
)
.await;
}
}
async fn wait_for_event(
&self,
method: &str,
timeout_duration: Duration,
) -> Result<NetworkEvent> {
let mut events = EventStream::new(
self.state.transport.subscribe_events(),
Some(self.session_id.clone()),
Some(method),
);
events
.recv_with_timeout(timeout_duration)
.await?
.ok_or_else(|| AutomationError::Timeout {
what: method.to_owned(),
})
}
async fn dispatch_mouse_event(
&self,
event_type: &str,
x: f64,
y: f64,
button: MouseButton,
buttons: u8,
click_count: u8,
) -> Result<()> {
self.cdp()
.call_raw(
"Input.dispatchMouseEvent",
json!({
"type": event_type,
"x": x,
"y": y,
"button": button.as_cdp_value(),
"buttons": buttons,
"clickCount": click_count,
}),
)
.await?;
Ok(())
}
async fn evaluate_in_frame_value(
&self,
expression: &str,
frame_id: Option<&str>,
) -> Result<Value> {
let result = self
.state
.client
.execute_in_session::<RuntimeEvaluateParams>(
self.session_id.clone(),
&RuntimeEvaluateParams {
expression: expression.to_owned(),
await_promise: Some(true),
return_by_value: Some(true),
context_id: match frame_id {
Some(frame_id) => Some(self.execution_context_id(frame_id).await?),
None => None,
},
},
)
.await?;
Ok(result.result.value.unwrap_or(Value::Null))
}
async fn evaluate_in_frame_handle(
&self,
expression: &str,
frame_id: Option<&str>,
) -> Result<JsHandle> {
let result = self
.state
.client
.execute_in_session::<RuntimeEvaluateParams>(
self.session_id.clone(),
&RuntimeEvaluateParams {
expression: expression.to_owned(),
await_promise: Some(true),
return_by_value: Some(false),
context_id: match frame_id {
Some(frame_id) => Some(self.execution_context_id(frame_id).await?),
None => None,
},
},
)
.await?;
JsHandle::from_remote_object(self.clone(), result.result)
}
async fn execution_context_id(&self, frame_id: &str) -> Result<i64> {
Ok(self
.state
.client
.execute_in_session::<PageCreateIsolatedWorldParams>(
self.session_id.clone(),
&PageCreateIsolatedWorldParams {
frame_id: frame_id.to_owned(),
world_name: Some("playhard".to_owned()),
grant_universal_access: None,
},
)
.await?
.execution_context_id)
}
async fn cookie_state(&self) -> Result<Vec<CookieState>> {
let value = self
.state
.client
.call_raw("Storage.getCookies", json!({}), None)
.await
.map_err(AutomationError::from)?;
Ok(serde_json::from_value::<CookieResult>(value)?
.cookies
.into_iter()
.map(CookieState::from)
.collect())
}
async fn set_cookie_state(&self, cookies: &[CookieState]) -> Result<()> {
if cookies.is_empty() {
return Ok(());
}
let cookies = cookies
.iter()
.cloned()
.map(SetCookie::from)
.collect::<Vec<_>>();
self.state
.client
.call_raw("Storage.setCookies", json!({ "cookies": cookies }), None)
.await
.map_err(AutomationError::from)?;
Ok(())
}
async fn current_origin_storage_state(&self) -> Result<Option<OriginStorageState>> {
let value = self
.evaluate(
"(() => { 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) }; })()",
)
.await?;
Ok(serde_json::from_value(value)?)
}
}
async fn bootstrap_page_session(
state: Arc<BrowserState>,
session_id: String,
target_id: String,
) -> Result<Page> {
state
.client
.execute_in_session::<PageEnableParams>(session_id.clone(), &PageEnableParams {})
.await?;
state
.client
.execute_in_session::<PageSetLifecycleEventsEnabledParams>(
session_id.clone(),
&PageSetLifecycleEventsEnabledParams { enabled: true },
)
.await?;
state
.client
.execute_in_session::<RuntimeEnableParams>(session_id.clone(), &RuntimeEnableParams {})
.await?;
state
.client
.execute_in_session::<NetworkEnableParams>(session_id.clone(), &NetworkEnableParams {})
.await?;
state
.client
.call_raw("DOM.enable", json!({}), Some(session_id.clone()))
.await?;
{
let mut sessions = lock_unpoisoned(&state.page_sessions);
if !sessions.iter().any(|existing| existing == &session_id) {
sessions.push(session_id.clone());
}
}
let page = Page {
state: Arc::clone(&state),
session_id,
target_id,
default_timeout: Duration::from_secs(30),
};
let browser_patterns = {
let patterns = lock_unpoisoned(&state.browser_interception_patterns);
patterns.clone()
};
if let Some(patterns) = browser_patterns {
page.enable_request_interception(patterns).await?;
}
Ok(page)
}
fn flatten_frame_tree(page: Page, tree: playhard_cdp::PageFrameTree) -> Vec<Frame> {
fn visit(page: &Page, tree: playhard_cdp::PageFrameTree, frames: &mut Vec<Frame>) {
let frame = tree.frame;
frames.push(Frame {
page: page.clone(),
frame_id: frame.id,
parent_frame_id: frame.parent_id,
name: frame.name,
url: frame.url,
});
for child in tree.child_frames {
visit(page, child, frames);
}
}
let mut frames = Vec::new();
visit(&page, tree, &mut frames);
frames
}
/// Load states that a page can wait for after navigation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoadState {
/// The DOMContentLoaded event fired.
DomContentLoaded,
/// The full load event fired.
Load,
/// Chrome reported the page as network idle.
NetworkIdle,
}
/// Navigation options for page loads.
#[derive(Debug, Clone, Copy)]
pub struct NavigateOptions {
/// Load state that must be reached before the call returns.
pub wait_until: LoadState,
/// Maximum wait time for the chosen load state.
pub timeout: Duration,
}
impl Default for NavigateOptions {
fn default() -> Self {
Self {
wait_until: LoadState::Load,
timeout: Duration::from_secs(30),
}
}
}
/// Serializable page storage state.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StorageState {
/// Browser cookies that should be restored before navigation.
pub cookies: Vec<CookieState>,
/// Origin-scoped local and session storage values.
pub origins: Vec<OriginStorageState>,
}
/// Serializable browser cookie state.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CookieState {
/// Cookie name.
pub name: String,
/// Cookie value.
pub value: String,
/// Cookie domain.
pub domain: String,
/// Cookie path.
pub path: String,
/// Expiration timestamp in seconds since the epoch, when persistent.
#[serde(skip_serializing_if = "Option::is_none")]
pub expires: Option<f64>,
/// Whether the cookie is HTTP only.
#[serde(rename = "httpOnly")]
pub http_only: bool,
/// Whether the cookie is marked secure.
pub secure: bool,
/// SameSite policy when Chrome reported one.
#[serde(rename = "sameSite", skip_serializing_if = "Option::is_none")]
pub same_site: Option<String>,
}
/// Serializable origin-scoped web storage state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OriginStorageState {
/// Storage origin such as `https://example.com`.
pub origin: String,
/// Local storage entries for the origin.
#[serde(rename = "localStorage", default)]
pub local_storage: Vec<StorageEntry>,
/// Session storage entries for the origin on the current page.
#[serde(rename = "sessionStorage", default)]
pub session_storage: Vec<StorageEntry>,
}
/// Serializable key/value storage entry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StorageEntry {
/// Storage key.
pub name: String,
/// Storage value.
pub value: String,
}
/// A page frame handle.
#[derive(Clone)]
pub struct Frame {
page: Page,
frame_id: String,
parent_frame_id: Option<String>,
name: Option<String>,
url: String,
}
impl Frame {
/// Returns the unique frame id.
#[must_use]
pub fn id(&self) -> &str {
&self.frame_id
}
/// Returns the parent frame id when this is not the main frame.
#[must_use]
pub fn parent_frame_id(&self) -> Option<&str> {
self.parent_frame_id.as_deref()
}
/// Returns the current frame name, when present.
#[must_use]
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
/// Returns the last observed frame URL.
#[must_use]
pub fn url(&self) -> &str {
&self.url
}
/// Evaluate JavaScript inside this frame.
pub async fn evaluate(&self, expression: impl AsRef<str>) -> Result<Value> {
self.page
.evaluate_in_frame_value(expression.as_ref(), Some(&self.frame_id))
.await
}
/// Evaluate JavaScript inside this frame and keep the result alive remotely.
pub async fn evaluate_handle(&self, expression: impl AsRef<str>) -> Result<JsHandle> {
self.page
.evaluate_in_frame_handle(expression.as_ref(), Some(&self.frame_id))
.await
}
/// Build a CSS locator rooted in this frame.
#[must_use]
pub fn locator(&self, css_selector: impl Into<String>) -> Locator {
Locator::new(
self.page.clone(),
SelectorKind::Css(css_selector.into()),
Some(self.frame_id.clone()),
)
}
/// Read text from the first element matching the CSS selector inside this frame.
pub async fn text_content(&self, css_selector: impl Into<String>) -> Result<String> {
self.locator(css_selector).text_content().await
}
/// Wait for an element inside this frame.
pub async fn wait_for_selector(
&self,
css_selector: impl Into<String>,
timeout_duration: Duration,
) -> Result<()> {
self.locator(css_selector).wait(timeout_duration).await
}
}
/// A query that resolves a DOM element.
#[derive(Clone)]
pub struct Locator {
page: Page,
selector: SelectorKind,
frame_id: Option<String>,
}
impl Locator {
fn new(page: Page, selector: SelectorKind, frame_id: Option<String>) -> Self {
Self {
page,
selector,
frame_id,
}
}
/// Resolve the current locator into an element handle.
pub async fn element_handle(&self) -> Result<ElementHandle> {
let script = format!(
"(() => {{ const el = {selector}; return el; }})()",
selector = self.selector.javascript_expression()?,
);
self.page
.evaluate_in_frame_handle(&script, self.frame_id.as_deref())
.await?
.as_element()
.ok_or_else(|| {
AutomationError::Selector("locator did not resolve to an element".to_owned())
})
}
/// Click the matched element.
pub async fn click(&self) -> Result<()> {
self.click_with_options(ActionOptions::default()).await
}
/// Click the matched element with custom action options.
pub async fn click_with_options(&self, options: ActionOptions) -> Result<()> {
self.wait(options.timeout).await?;
if !options.force {
self.wait_for_actionable(options.timeout).await?;
}
self.scroll_into_view().await?;
let rect = self.bounding_rect().await?;
let x = rect.x + (rect.width / 2.0);
let y = rect.y + (rect.height / 2.0);
self.page.click_at(x, y, ClickOptions::default()).await
}
/// Fill the matched form field.
pub async fn fill(&self, value: impl AsRef<str>) -> Result<()> {
self.fill_with_options(value, ActionOptions::default())
.await
}
/// Fill the matched form field with custom action options.
pub async fn fill_with_options(
&self,
value: impl AsRef<str>,
options: ActionOptions,
) -> Result<()> {
self.wait(options.timeout).await?;
if !options.force {
self.wait_for_actionable(options.timeout).await?;
}
let element = self.element_handle().await?;
element.scroll_into_view().await?;
element.focus().await?;
element.select_text().await?;
self.page.insert_text(value.as_ref()).await
}
/// Focus the matched element.
pub async fn focus(&self) -> Result<()> {
self.wait(ActionOptions::default().timeout).await?;
self.element_handle().await?.focus().await
}
/// Hover the matched element.
pub async fn hover(&self) -> Result<()> {
self.wait(ActionOptions::default().timeout).await?;
self.wait_for_actionable(ActionOptions::default().timeout)
.await?;
self.scroll_into_view().await?;
let rect = self.bounding_rect().await?;
let x = rect.x + (rect.width / 2.0);
let y = rect.y + (rect.height / 2.0);
self.page.move_mouse(x, y).await
}
/// Select an option by value on the matched `<select>`.
pub async fn select(&self, value: impl AsRef<str>) -> Result<()> {
self.wait(ActionOptions::default().timeout).await?;
let value = serde_json::to_string(value.as_ref())?;
self.run_selector_action(&format!(
"el.value = {value}; el.dispatchEvent(new Event('input', {{ bubbles: true }})); el.dispatchEvent(new Event('change', {{ bubbles: true }}));"
))
.await
}
/// Wait until the locator matches an element.
pub async fn wait(&self, timeout_duration: Duration) -> Result<()> {
let deadline = Instant::now() + timeout_duration;
loop {
if self.exists().await? {
return Ok(());
}
if Instant::now() >= deadline {
return Err(AutomationError::Timeout {
what: "locator existence".to_owned(),
});
}
sleep(Duration::from_millis(100)).await;
}
}
/// Returns true when the locator currently matches an element.
pub async fn exists(&self) -> Result<bool> {
let status = self.element_status().await?;
Ok(status.exists)
}
/// Read the text content of the matched element.
pub async fn text_content(&self) -> Result<String> {
self.element_handle().await?.text_content().await
}
/// Wait until the matched element's text content satisfies the predicate.
pub async fn wait_for_text_content<F>(
&self,
timeout_duration: Duration,
predicate: F,
) -> Result<String>
where
F: Fn(&str) -> bool,
{
let deadline = Instant::now() + timeout_duration;
loop {
match self.text_content().await {
Ok(text) if predicate(&text) => return Ok(text),
Ok(_) | Err(AutomationError::MissingElement) => {}
Err(error) => return Err(error),
}
if Instant::now() >= deadline {
return Err(AutomationError::Timeout {
what: "locator text content".to_owned(),
});
}
sleep(Duration::from_millis(100)).await;
}
}
async fn bounding_rect(&self) -> Result<BoundingRect> {
self.element_handle().await?.bounding_rect().await
}
async fn scroll_into_view(&self) -> Result<()> {
self.element_handle().await?.scroll_into_view().await
}
async fn wait_for_actionable(&self, timeout_duration: Duration) -> Result<()> {
let deadline = Instant::now() + timeout_duration;
loop {
let status = self.element_status().await?;
if status.exists && status.visible && status.enabled {
return Ok(());
}
if Instant::now() >= deadline {
return Err(AutomationError::Timeout {
what: "locator actionability".to_owned(),
});
}
sleep(Duration::from_millis(100)).await;
}
}
async fn element_status(&self) -> Result<ElementStatus> {
let script = format!(
"(() => {{ 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 }}; }})()",
selector = self.selector.javascript_expression()?,
);
let value = self
.page
.evaluate_in_frame_value(&script, self.frame_id.as_deref())
.await?;
Ok(serde_json::from_value(value)?)
}
async fn run_selector_action(&self, body: &str) -> Result<()> {
let script = format!(
"(() => {{ const el = {selector}; if (!el) return {{ ok: false, message: 'missing element' }}; {body} return {{ ok: true }}; }})()",
selector = self.selector.javascript_expression()?,
body = body,
);
let value = self
.page
.evaluate_in_frame_value(&script, self.frame_id.as_deref())
.await?;
let result = serde_json::from_value::<SelectorActionResult>(value)?;
if result.ok {
Ok(())
} else {
Err(AutomationError::Selector(
result
.message
.unwrap_or_else(|| "selector action failed".to_owned()),
))
}
}
}
#[derive(Debug, Deserialize)]
struct SelectorActionResult {
ok: bool,
message: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ElementStatus {
exists: bool,
visible: bool,
enabled: bool,
}
/// Rectangle returned from DOM element geometry queries.
#[derive(Debug, Deserialize)]
pub struct BoundingRect {
/// The left edge in CSS pixels.
pub x: f64,
/// The top edge in CSS pixels.
pub y: f64,
/// The rectangle width in CSS pixels.
pub width: f64,
/// The rectangle height in CSS pixels.
pub height: f64,
}
#[derive(Clone, Debug)]
enum SelectorKind {
Css(String),
Text(String),
Role(String),
TestId(String),
}
impl SelectorKind {
fn javascript_expression(&self) -> Result<String> {
let value = match self {
Self::Css(selector) => {
format!(
"document.querySelector({})",
serde_json::to_string(selector)?
)
}
Self::Text(text) => {
let text = serde_json::to_string(text)?;
format!(
"(() => {{ 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; }})()"
)
}
Self::Role(role) => {
let role = serde_json::to_string(role)?;
format!(
"(() => {{ 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; }})()"
)
}
Self::TestId(test_id) => format!(
"document.querySelector({})",
serde_json::to_string(&format!(r#"[data-testid="{test_id}"]"#))?
),
};
Ok(value)
}
}
/// Options for actions like click and fill.
#[derive(Debug, Clone, Copy)]
pub struct ActionOptions {
/// Timeout for auto-wait behavior.
pub timeout: Duration,
/// Skip actionability checks while still resolving the locator.
pub force: bool,
}
impl Default for ActionOptions {
fn default() -> Self {
Self {
timeout: Duration::from_secs(30),
force: false,
}
}
}
/// Mouse buttons recognized by Chrome input dispatch.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MouseButton {
/// No mouse button.
None,
/// The primary mouse button.
Left,
/// The auxiliary mouse button.
Middle,
/// The secondary mouse button.
Right,
/// The browser back button.
Back,
/// The browser forward button.
Forward,
}
impl MouseButton {
fn as_cdp_value(self) -> &'static str {
match self {
Self::None => "none",
Self::Left => "left",
Self::Middle => "middle",
Self::Right => "right",
Self::Back => "back",
Self::Forward => "forward",
}
}
}
/// Options for synthetic mouse clicks.
#[derive(Debug, Clone, Copy)]
pub struct ClickOptions {
/// Which mouse button to click with.
pub button: MouseButton,
/// Number of clicks to report to the page.
pub click_count: u8,
/// Delay between mouse down and mouse up.
pub down_up_delay: Duration,
}
impl Default for ClickOptions {
fn default() -> Self {
Self {
button: MouseButton::Left,
click_count: 1,
down_up_delay: Duration::from_millis(50),
}
}
}
/// Request interception pattern.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestPattern {
/// Optional URL pattern.
pub url_pattern: Option<String>,
/// Optional request stage.
pub request_stage: Option<String>,
}
impl RequestPattern {
/// Create a request interception pattern for any stage.
#[must_use]
pub fn new(url_pattern: impl Into<String>) -> Self {
Self {
url_pattern: Some(url_pattern.into()),
request_stage: None,
}
}
/// Create a request-stage interception pattern.
#[must_use]
pub fn request(url_pattern: impl Into<String>) -> Self {
Self {
url_pattern: Some(url_pattern.into()),
request_stage: Some("Request".to_owned()),
}
}
/// Create a response-stage interception pattern.
#[must_use]
pub fn response(url_pattern: impl Into<String>) -> Self {
Self {
url_pattern: Some(url_pattern.into()),
request_stage: Some("Response".to_owned()),
}
}
fn to_cdp(&self) -> playhard_cdp::FetchPattern {
playhard_cdp::FetchPattern {
url_pattern: self.url_pattern.clone(),
request_stage: self.request_stage.clone(),
}
}
}
impl From<&str> for RequestPattern {
fn from(url_pattern: &str) -> Self {
Self::new(url_pattern)
}
}
impl From<String> for RequestPattern {
fn from(url_pattern: String) -> Self {
Self::new(url_pattern)
}
}
fn collect_request_patterns<I>(patterns: I) -> Vec<RequestPattern>
where
I: IntoIterator<Item = RequestPattern>,
{
patterns.into_iter().collect()
}
/// A normalized network or fetch event.
#[derive(Debug, Clone)]
pub struct NetworkEvent {
/// Event method name.
pub method: String,
/// Optional page session id.
pub session_id: Option<String>,
/// Raw JSON parameters.
pub params: Value,
}
impl From<TransportEvent> for NetworkEvent {
fn from(event: TransportEvent) -> Self {
Self {
method: event.method,
session_id: event.session_id,
params: event.params.unwrap_or(Value::Null),
}
}
}
/// A page request surfaced from `Network.requestWillBeSent`.
#[derive(Debug, Clone)]
pub struct Request {
request_id: String,
method: String,
url: String,
headers: BTreeMap<String, String>,
post_data: Option<String>,
resource_type: Option<String>,
frame_id: Option<String>,
session_id: Option<String>,
}
impl Request {
fn from_network_event(event: NetworkEvent) -> Option<Self> {
if event.method != "Network.requestWillBeSent" {
return None;
}
let request_id = event.params.get("requestId")?.as_str()?.to_owned();
let request = event.params.get("request")?;
Some(Self {
request_id,
method: request.get("method")?.as_str()?.to_owned(),
url: request.get("url")?.as_str()?.to_owned(),
headers: normalize_headers(request.get("headers")),
post_data: request
.get("postData")
.and_then(Value::as_str)
.map(str::to_owned),
resource_type: event
.params
.get("type")
.and_then(Value::as_str)
.map(str::to_owned),
frame_id: event
.params
.get("frameId")
.and_then(Value::as_str)
.map(str::to_owned),
session_id: event.session_id,
})
}
/// Returns the protocol request id.
#[must_use]
pub fn request_id(&self) -> &str {
&self.request_id
}
/// Returns the HTTP method.
#[must_use]
pub fn method(&self) -> &str {
&self.method
}
/// Returns the request URL.
#[must_use]
pub fn url(&self) -> &str {
&self.url
}
/// Returns the outgoing request headers.
#[must_use]
pub fn headers(&self) -> &BTreeMap<String, String> {
&self.headers
}
/// Returns the request body payload when the browser exposed one.
#[must_use]
pub fn post_data(&self) -> Option<&str> {
self.post_data.as_deref()
}
/// Returns the resource type when Chrome reported one.
#[must_use]
pub fn resource_type(&self) -> Option<&str> {
self.resource_type.as_deref()
}
/// Returns the associated frame id when available.
#[must_use]
pub fn frame_id(&self) -> Option<&str> {
self.frame_id.as_deref()
}
/// Returns the originating page session when available.
#[must_use]
pub fn session_id(&self) -> Option<&str> {
self.session_id.as_deref()
}
}
/// A page response surfaced from `Network.responseReceived`.
#[derive(Debug, Clone)]
pub struct Response {
request_id: String,
url: String,
status: u16,
status_text: Option<String>,
headers: BTreeMap<String, String>,
mime_type: Option<String>,
resource_type: Option<String>,
frame_id: Option<String>,
session_id: Option<String>,
}
impl Response {
fn from_network_event(event: NetworkEvent) -> Option<Self> {
if event.method != "Network.responseReceived" {
return None;
}
let response = event.params.get("response")?;
Some(Self {
request_id: event.params.get("requestId")?.as_str()?.to_owned(),
url: response.get("url")?.as_str()?.to_owned(),
status: response.get("status")?.as_f64()? as u16,
status_text: response
.get("statusText")
.and_then(Value::as_str)
.map(str::to_owned),
headers: normalize_headers(response.get("headers")),
mime_type: response
.get("mimeType")
.and_then(Value::as_str)
.map(str::to_owned),
resource_type: event
.params
.get("type")
.and_then(Value::as_str)
.map(str::to_owned),
frame_id: event
.params
.get("frameId")
.and_then(Value::as_str)
.map(str::to_owned),
session_id: event.session_id,
})
}
/// Returns the protocol request id associated with this response.
#[must_use]
pub fn request_id(&self) -> &str {
&self.request_id
}
/// Returns the response URL.
#[must_use]
pub fn url(&self) -> &str {
&self.url
}
/// Returns the HTTP status code.
#[must_use]
pub fn status(&self) -> u16 {
self.status
}
/// Returns the HTTP reason phrase when Chrome exposed it.
#[must_use]
pub fn status_text(&self) -> Option<&str> {
self.status_text.as_deref()
}
/// Returns the response headers.
#[must_use]
pub fn headers(&self) -> &BTreeMap<String, String> {
&self.headers
}
/// Returns the response MIME type when available.
#[must_use]
pub fn mime_type(&self) -> Option<&str> {
self.mime_type.as_deref()
}
/// Returns the resource type when Chrome reported one.
#[must_use]
pub fn resource_type(&self) -> Option<&str> {
self.resource_type.as_deref()
}
/// Returns the frame id that initiated the response when available.
#[must_use]
pub fn frame_id(&self) -> Option<&str> {
self.frame_id.as_deref()
}
/// Returns the originating page session when available.
#[must_use]
pub fn session_id(&self) -> Option<&str> {
self.session_id.as_deref()
}
}
/// A filtered event stream.
pub struct EventStream {
receiver: broadcast::Receiver<TransportEvent>,
session_id: Option<String>,
method_prefixes: Vec<String>,
}
impl EventStream {
fn new(
receiver: broadcast::Receiver<TransportEvent>,
session_id: Option<String>,
method_prefix: Option<&str>,
) -> Self {
let method_prefixes = method_prefix.into_iter().map(str::to_owned).collect();
Self {
receiver,
session_id,
method_prefixes,
}
}
fn with_extra_prefix(mut self, prefix: &str) -> Self {
self.method_prefixes.push(prefix.to_owned());
self
}
/// Receive the next matching event.
pub async fn recv(&mut self) -> Result<NetworkEvent> {
loop {
match self.receiver.recv().await {
Ok(event) => {
if self.matches(&event) {
return Ok(event.into());
}
}
Err(broadcast::error::RecvError::Closed) => {
return Err(AutomationError::Timeout {
what: "event stream closed".to_owned(),
});
}
Err(broadcast::error::RecvError::Lagged(_)) => {}
}
}
}
async fn recv_with_timeout(&mut self, duration: Duration) -> Result<Option<NetworkEvent>> {
match timeout(duration, self.recv()).await {
Ok(event) => event.map(Some),
Err(_) => Ok(None),
}
}
fn matches(&self, event: &TransportEvent) -> bool {
if let Some(session_id) = &self.session_id {
if event.session_id.as_deref() != Some(session_id.as_str()) {
return false;
}
}
if self.method_prefixes.is_empty() {
return true;
}
self.method_prefixes
.iter()
.any(|prefix| event.method.starts_with(prefix))
}
}
/// An intercepted request route.
pub struct Route {
client: Arc<CdpClient<AutomationTransport>>,
/// The intercepted page session.
pub session_id: String,
/// The CDP fetch request id.
pub request_id: String,
/// The intercepted request URL, when present.
pub url: Option<String>,
/// The intercepted request method, when present.
pub method: Option<String>,
/// Response code when intercepted in the response stage.
pub response_status_code: Option<u16>,
/// Response status text when intercepted in the response stage.
pub response_status_text: Option<String>,
/// Response headers when intercepted in the response stage.
pub response_headers: Option<Vec<HeaderEntry>>,
}
impl Route {
fn from_event(
client: Arc<CdpClient<AutomationTransport>>,
event: NetworkEvent,
) -> Result<Self> {
let paused = serde_json::from_value::<RequestPausedEvent>(event.params)?;
Ok(Self {
client,
session_id: event
.session_id
.ok_or(AutomationError::MissingField("sessionId"))?,
request_id: paused.request_id,
url: paused.request.as_ref().map(|request| request.url.clone()),
method: paused.request.map(|request| request.method),
response_status_code: paused.response_status_code.map(|status| status as u16),
response_status_text: paused.response_status_text,
response_headers: paused.response_headers,
})
}
/// Returns true when the route was paused after the upstream response arrived.
#[must_use]
pub fn is_response_stage(&self) -> bool {
self.response_status_code.is_some()
}
/// Continue the intercepted request.
pub async fn continue_request(&self) -> Result<()> {
self.client
.execute_in_session::<FetchContinueRequestParams>(
self.session_id.clone(),
&FetchContinueRequestParams {
request_id: self.request_id.clone(),
},
)
.await?;
Ok(())
}
/// Abort the intercepted request.
pub async fn abort(&self, error_reason: impl Into<String>) -> Result<()> {
self.client
.execute_in_session::<FetchFailRequestParams>(
self.session_id.clone(),
&FetchFailRequestParams {
request_id: self.request_id.clone(),
error_reason: error_reason.into(),
},
)
.await?;
Ok(())
}
/// Fulfill the intercepted request with a synthetic response.
pub async fn fulfill(&self, response_code: u16, body: Option<Vec<u8>>) -> Result<()> {
let body = body.map(|bytes| base64::engine::general_purpose::STANDARD.encode(bytes));
self.client
.execute_in_session::<FetchFulfillRequestParams>(
self.session_id.clone(),
&FetchFulfillRequestParams {
request_id: self.request_id.clone(),
response_code,
response_headers: None,
body,
response_phrase: None,
},
)
.await?;
Ok(())
}
/// Read the upstream response body for a route paused in the response stage.
pub async fn response_body(&self) -> Result<Vec<u8>> {
if !self.is_response_stage() {
return Err(AutomationError::InvalidRouteState(
"response body is only available in the response stage",
));
}
let result = self
.client
.execute_in_session::<FetchGetResponseBodyParams>(
self.session_id.clone(),
&FetchGetResponseBodyParams {
request_id: self.request_id.clone(),
},
)
.await?;
if result.base64_encoded {
Ok(base64::engine::general_purpose::STANDARD.decode(result.body)?)
} else {
Ok(result.body.into_bytes())
}
}
/// Read the upstream response body as UTF-8 text for a route paused in the response stage.
pub async fn response_text(&self) -> Result<String> {
String::from_utf8(self.response_body().await?).map_err(AutomationError::from)
}
/// Fulfill a response-stage route while preserving upstream status and headers by default.
pub async fn fulfill_response(&self, body: Vec<u8>) -> Result<()> {
if !self.is_response_stage() {
return Err(AutomationError::InvalidRouteState(
"response fulfillment requires a route paused in the response stage",
));
}
let mut response_headers = Vec::new();
if let Some(content_type) = self
.response_headers
.as_ref()
.and_then(|headers| find_header(headers, "content-type"))
{
response_headers.push(FetchHeaderEntry {
name: "Content-Type".to_owned(),
value: content_type.to_owned(),
});
}
response_headers.push(FetchHeaderEntry {
name: "Content-Length".to_owned(),
value: body.len().to_string(),
});
self.client
.execute_in_session::<FetchFulfillRequestParams>(
self.session_id.clone(),
&FetchFulfillRequestParams {
request_id: self.request_id.clone(),
response_code: self.response_status_code.unwrap_or(200),
response_headers: Some(response_headers),
body: Some(base64::engine::general_purpose::STANDARD.encode(body)),
response_phrase: None,
},
)
.await?;
Ok(())
}
}
#[derive(Debug, Deserialize)]
struct RequestPausedEvent {
#[serde(rename = "requestId")]
request_id: String,
request: Option<RequestDetails>,
#[serde(rename = "responseStatusCode")]
response_status_code: Option<u64>,
#[serde(rename = "responseStatusText")]
response_status_text: Option<String>,
#[serde(rename = "responseHeaders")]
response_headers: Option<Vec<HeaderEntry>>,
}
fn find_header<'a>(headers: &'a [HeaderEntry], name: &str) -> Option<&'a str> {
headers
.iter()
.find(|header| header.name.eq_ignore_ascii_case(name))
.map(|header| header.value.as_str())
}
#[derive(Debug, Deserialize)]
struct RequestDetails {
url: String,
method: String,
}
/// Response header captured for an intercepted route.
#[derive(Debug, Clone, Deserialize)]
pub struct HeaderEntry {
/// Header name.
pub name: String,
/// Header value.
pub value: String,
}
#[derive(Debug, Deserialize)]
struct CookieResult {
cookies: Vec<RawCookie>,
}
#[derive(Debug, Clone, Deserialize)]
struct RawCookie {
name: String,
value: String,
domain: String,
path: String,
expires: f64,
#[serde(rename = "httpOnly")]
http_only: bool,
secure: bool,
#[serde(rename = "sameSite")]
same_site: Option<String>,
}
impl From<RawCookie> for CookieState {
fn from(cookie: RawCookie) -> Self {
let expires = if cookie.expires < 0.0 {
None
} else {
Some(cookie.expires)
};
Self {
name: cookie.name,
value: cookie.value,
domain: cookie.domain,
path: cookie.path,
expires,
http_only: cookie.http_only,
secure: cookie.secure,
same_site: cookie.same_site,
}
}
}
#[derive(Debug, Clone, Serialize)]
struct SetCookie {
name: String,
value: String,
domain: String,
path: String,
#[serde(skip_serializing_if = "Option::is_none")]
expires: Option<f64>,
#[serde(rename = "httpOnly")]
http_only: bool,
secure: bool,
#[serde(rename = "sameSite", skip_serializing_if = "Option::is_none")]
same_site: Option<String>,
}
impl From<CookieState> for SetCookie {
fn from(cookie: CookieState) -> Self {
Self {
name: cookie.name,
value: cookie.value,
domain: cookie.domain,
path: cookie.path,
expires: cookie.expires,
http_only: cookie.http_only,
secure: cookie.secure,
same_site: cookie.same_site,
}
}
}
/// A remote JavaScript object handle.
#[derive(Clone)]
pub struct JsHandle {
page: Page,
object_id: String,
object_type: String,
subtype: Option<String>,
description: Option<String>,
}
impl JsHandle {
fn from_remote_object(page: Page, object: RemoteObject) -> Result<Self> {
Ok(Self {
page,
object_id: object.object_id.ok_or_else(|| {
AutomationError::Selector("expression did not produce a remote object".to_owned())
})?,
object_type: object.object_type,
subtype: object.subtype,
description: object.description,
})
}
/// Returns the underlying CDP remote object id.
#[must_use]
pub fn object_id(&self) -> &str {
&self.object_id
}
/// Returns the remote object type.
#[must_use]
pub fn object_type(&self) -> &str {
&self.object_type
}
/// Returns the remote object subtype when present.
#[must_use]
pub fn subtype(&self) -> Option<&str> {
self.subtype.as_deref()
}
/// Returns the browser-side description when present.
#[must_use]
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
/// Materialize the remote value as JSON.
pub async fn json_value(&self) -> Result<Value> {
self.call_function_value("function() { return this; }", Vec::new())
.await
}
/// Release the remote object from Chrome.
pub async fn dispose(&self) -> Result<()> {
self.page
.state
.client
.execute_in_session::<RuntimeReleaseObjectParams>(
self.page.session_id.clone(),
&RuntimeReleaseObjectParams {
object_id: self.object_id.clone(),
},
)
.await?;
Ok(())
}
/// Convert the handle into an element handle when the remote object is a DOM node.
#[must_use]
pub fn as_element(&self) -> Option<ElementHandle> {
if self.subtype.as_deref() == Some("node") {
Some(ElementHandle {
handle: self.clone(),
})
} else {
None
}
}
async fn call_function_value(
&self,
function_declaration: &str,
arguments: Vec<RuntimeCallArgument>,
) -> Result<Value> {
let result = self
.page
.state
.client
.execute_in_session::<RuntimeCallFunctionOnParams>(
self.page.session_id.clone(),
&RuntimeCallFunctionOnParams {
object_id: self.object_id.clone(),
function_declaration: function_declaration.to_owned(),
arguments,
await_promise: Some(true),
return_by_value: Some(true),
},
)
.await?;
Ok(result.result.value.unwrap_or(Value::Null))
}
}
/// A DOM element handle.
#[derive(Clone)]
pub struct ElementHandle {
handle: JsHandle,
}
impl ElementHandle {
/// Focus the element.
pub async fn focus(&self) -> Result<()> {
let _ = self
.handle
.call_function_value("function() { this.focus(); return true; }", Vec::new())
.await?;
Ok(())
}
/// Scroll the element into view.
pub async fn scroll_into_view(&self) -> Result<()> {
let _ = self
.handle
.call_function_value(
"function() { this.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' }); return true; }",
Vec::new(),
)
.await?;
Ok(())
}
/// Select the element's editable contents when possible.
pub async fn select_text(&self) -> Result<()> {
let _ = self
.handle
.call_function_value(
"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; }",
Vec::new(),
)
.await?;
Ok(())
}
/// Read the text content.
pub async fn text_content(&self) -> Result<String> {
let value = self
.handle
.call_function_value("function() { return this.textContent ?? ''; }", Vec::new())
.await?;
value
.as_str()
.map(str::to_owned)
.ok_or(AutomationError::MissingElement)
}
/// Return the current bounding client rect.
pub async fn bounding_rect(&self) -> Result<BoundingRect> {
let value = self
.handle
.call_function_value(
"function() { const rect = this.getBoundingClientRect(); return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; }",
Vec::new(),
)
.await?;
Ok(serde_json::from_value(value)?)
}
}
#[derive(Debug, Deserialize)]
struct CreatedTargetEvent {
#[serde(rename = "targetInfo")]
target_info: CreatedTargetInfo,
}
#[derive(Debug, Deserialize)]
struct CreatedTargetInfo {
#[serde(rename = "targetId")]
target_id: String,
#[serde(rename = "type")]
target_type: String,
#[serde(rename = "openerId")]
opener_id: Option<String>,
}
struct KeyDefinition {
key: String,
code: String,
text: Option<String>,
key_code: i64,
}
impl KeyDefinition {
fn parse(key: &str) -> Result<Self> {
match key {
"Enter" => Ok(Self::named("Enter", "Enter", 13)),
"Tab" => Ok(Self::named("Tab", "Tab", 9)),
"Escape" => Ok(Self::named("Escape", "Escape", 27)),
"Backspace" => Ok(Self::named("Backspace", "Backspace", 8)),
"Delete" => Ok(Self::named("Delete", "Delete", 46)),
"ArrowLeft" => Ok(Self::named("ArrowLeft", "ArrowLeft", 37)),
"ArrowUp" => Ok(Self::named("ArrowUp", "ArrowUp", 38)),
"ArrowRight" => Ok(Self::named("ArrowRight", "ArrowRight", 39)),
"ArrowDown" => Ok(Self::named("ArrowDown", "ArrowDown", 40)),
" " => Ok(Self {
key: " ".to_owned(),
code: "Space".to_owned(),
text: Some(" ".to_owned()),
key_code: 32,
}),
_ if key.chars().count() == 1 => {
let character = key
.chars()
.next()
.ok_or_else(|| AutomationError::Input("empty key".to_owned()))?;
Ok(Self {
key: character.to_string(),
code: format!("Key{}", character.to_ascii_uppercase()),
text: Some(character.to_string()),
key_code: character.to_ascii_uppercase() as i64,
})
}
_ => Err(AutomationError::Input(format!("unsupported key `{key}`"))),
}
}
fn named(key: &str, code: &str, key_code: i64) -> Self {
Self {
key: key.to_owned(),
code: code.to_owned(),
text: None,
key_code,
}
}
}
fn normalize_headers(value: Option<&Value>) -> BTreeMap<String, String> {
let Some(Value::Object(headers)) = value else {
return BTreeMap::new();
};
headers
.iter()
.map(|(name, value)| {
let value = value
.as_str()
.map(str::to_owned)
.unwrap_or_else(|| value.to_string());
(name.clone(), value)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::{NetworkEvent, Request, SelectorKind};
use serde_json::json;
#[test]
fn css_selector_should_compile_to_query_selector() {
let selector = SelectorKind::Css("button.primary".to_owned());
let expression = selector.javascript_expression().unwrap();
assert!(expression.contains("document.querySelector"));
}
#[test]
fn role_selector_should_embed_common_role_inference() {
let selector = SelectorKind::Role("button".to_owned());
let expression = selector.javascript_expression().unwrap();
assert!(expression.contains("inferRole"));
}
#[test]
fn test_id_selector_should_target_data_testid() {
let selector = SelectorKind::TestId("checkout".to_owned());
let expression = selector.javascript_expression().unwrap();
assert!(expression.contains("data-testid"));
}
#[test]
fn text_selector_should_prefer_smallest_matching_element() {
let selector = SelectorKind::Text("Fingerprint JSON".to_owned());
let expression = selector.javascript_expression().unwrap();
assert!(expression.contains("node.children"));
assert!(expression.contains("candidates.find"));
}
#[test]
fn request_event_should_parse_network_request_will_be_sent() {
let event = NetworkEvent {
method: "Network.requestWillBeSent".to_owned(),
session_id: Some("page-session".to_owned()),
params: json!({
"requestId": "request-1",
"request": {
"url": "https://example.com/",
"method": "GET",
"headers": {
"accept": "text/html"
}
},
"type": "Document",
"frameId": "frame-1"
}),
};
let request = Request::from_network_event(event).expect("request event");
assert_eq!(request.request_id(), "request-1");
assert_eq!(request.method(), "GET");
assert_eq!(request.url(), "https://example.com/");
assert_eq!(
request.headers().get("accept"),
Some(&"text/html".to_owned())
);
assert_eq!(request.resource_type(), Some("Document"));
assert_eq!(request.frame_id(), Some("frame-1"));
assert_eq!(request.session_id(), Some("page-session"));
}
}